refactor(mcp): address review — split files, fix naming, stateless router

Split the monolithic mcp.router.ts into focused modules:
- mcp.service.ts: coordination helpers (rundownListResponse, renameRundown, deleteRundown)
- mcp.tools.ts: tool definitions (ontime_ prefix, annotation presets, shared field schemas,
  CHARACTER_LIMIT truncation) + handleToolCall dispatcher
- mcp.prompts.ts: prompt definitions + handleGetPrompt
- mcp.resources.ts: resource definitions (schema, live data, docs) + handleReadResource
- mcp.server.ts: createMcpServer factory that wires the above modules
- mcp.router.ts: thin stateless Express router (POST/GET405/DELETE405)

Other changes per review comments:
- Rename ontime_delete_event → ontime_delete_entry (entry vs event naming convention)
- Remove hardcoded cue/colour prefix conventions from tool descriptions and prompts;
  replace with "ask the user what convention they prefer"
- Remove automations tools (left for a later PR)
- Add Bearer auth comment in authenticate.ts explaining the MCP use case
- Extract McpSection component from FeaturePanel to isolate URL state re-renders
- Update bulk_edit prompt to mention linkStart cascade behaviour
- Add 405 comments explaining why GET/DELETE are not supported in stateless mode

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
This commit is contained in:
Claude
2026-05-05 19:16:25 +00:00
committed by Carlos Valente
parent b850a51204
commit 71a7f26493
9 changed files with 1191 additions and 1217 deletions
@@ -1,14 +1,10 @@
import { useEffect, useState } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { isOntimeCloud, serverURL } from '../../../../externals';
import { isOntimeCloud } from '../../../../externals';
import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import InfoNif from '../network-panel/NetworkInterfaces';
import McpSection from './McpSection';
import ReportSettings from './ReportSettings';
import URLPresets from './URLPresets';
@@ -18,29 +14,6 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
useEffect(() => {
const baseUrl = isOntimeCloud
? serverURL
: infoData.networkInterfaces.length > 0
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;
generateUrl({ baseUrl, path: '/mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl)
.catch(() => {
setMcpEndpointUrl(`${baseUrl}/mcp`);
});
}, [infoData]);
const claudeDesktopConfig = JSON.stringify(
{ mcpServers: { ontime: { url: mcpEndpointUrl } } },
null,
2,
);
return (
<>
<Panel.Header>Sharing and reporting</Panel.Header>
@@ -63,30 +36,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
</Panel.Section>
</div>
<div ref={mcpRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>MCP Server (AI Agent Integration)</Panel.SubHeader>
<Panel.Paragraph>
Connect AI agents (e.g. Claude Desktop) to Ontime via the Model Context Protocol endpoint.
</Panel.Paragraph>
<Panel.Divider />
<Panel.Field
title='MCP Endpoint URL'
description='Use this URL in your MCP client configuration'
/>
{mcpEndpointUrl && (
<CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>
)}
<Panel.Divider />
<Panel.Field
title='Claude Desktop config'
description='Paste this into your claude_desktop_config.json under "mcpServers"'
/>
{mcpEndpointUrl && (
<CopyTag copyValue={claudeDesktopConfig}>{claudeDesktopConfig}</CopyTag>
)}
</Panel.Card>
</Panel.Section>
<McpSection />
</div>
<div ref={reportRef}>
<ReportSettings />
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
/** MCP endpoint card — isolated so that URL state changes don't re-render the rest of FeaturePanel */
export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
useEffect(() => {
const baseUrl = isOntimeCloud
? serverURL
: infoData.networkInterfaces.length > 0
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;
generateUrl({ baseUrl, path: '/mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl)
.catch(() => {
setMcpEndpointUrl(`${baseUrl}/mcp`);
});
}, [infoData]);
const mcpClientConfig = JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider />
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
<Panel.Divider />
<Panel.Field
title='Client configuration snippet'
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.Card>
</Panel.Section>
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { GetPromptResult, ListPromptsResult } from '@modelcontextprotocol/sdk/types.js';
export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
{
name: 'create_rundown_from_agenda',
description: 'Convert a plain-text agenda into an Ontime rundown using ontime_create_events_batch',
arguments: [{ name: 'agenda', description: 'Plain-text agenda to convert', required: true }],
},
{
name: 'bulk_edit_rundown',
description: 'Apply a bulk change across the rundown (recolour, reschedule, skip events, etc.)',
arguments: [
{
name: 'instruction',
description: 'What to change, e.g. "colour all keynotes blue"',
required: true,
},
],
},
{
name: 'validate_rundown',
description: 'Check the current rundown for common issues: missing cues, overlaps, gaps, zero-duration events',
arguments: [],
},
{
name: 'restructure_rundown',
description: 'Reorder events in the rundown according to an instruction',
arguments: [
{
name: 'instruction',
description: 'How to restructure, e.g. "move all breaks to after keynotes"',
required: true,
},
],
},
];
export function handleGetPrompt(name: string, args: Record<string, string>): GetPromptResult {
if (name === 'create_rundown_from_agenda') {
const agenda = args.agenda ?? '';
return {
description: 'Build an Ontime rundown from a plain-text agenda',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Convert the following agenda into an Ontime rundown.
Data model:
- Times are milliseconds from midnight. 09:00 = 32400000, 10:30 = 37800000, etc.
- duration = timeEnd - timeStart
- endAction: "load-next" for back-to-back sessions, "none" otherwise
- Ask the user what cue naming and colour conventions they prefer
Steps:
1. Call ontime_get_rundown to see current state and identify an \`after\` anchor if appending.
2. Build an array of events in order and call ontime_create_events_batch ONCE with all of them. This is much faster than calling ontime_create_event per item.
3. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
Agenda:
${agenda}`,
},
},
],
};
}
if (name === 'bulk_edit_rundown') {
const instruction = args.instruction ?? '';
return {
description: 'Apply a bulk change across the rundown',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Apply the following bulk edit to the current Ontime rundown: "${instruction}"
Strategy:
1. Call ontime_get_rundown to see the current events, their IDs, and field values.
2. Determine which event IDs are affected by the instruction.
3. If every affected event receives the SAME field changes (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_events once with { ids, data }.
4. If each event needs DIFFERENT values (e.g. "shift everything 30 minutes"): check first if events use linkStart. If they do, changing the first linked event's timeStart cascades to all linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_event for each.
5. Time fields are milliseconds from midnight; compute arithmetic before calling the tools.
Confirm with the user before making destructive changes like setting skip=true on many events.`,
},
},
],
};
}
if (name === 'validate_rundown') {
return {
description: 'Check the current rundown for common issues',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Validate the currently loaded Ontime rundown and report issues.
Steps:
1. Call ontime_get_rundown to read all events.
2. Call ontime_get_rundown_metadata for totals (total duration, first/last times, flagged IDs).
3. Check and report:
- Events with missing or duplicate \`cue\`
- Events with missing \`title\`
- Events with \`duration\` of 0 or negative
- Events where \`timeEnd\` is before \`timeStart\`
- Events whose \`timeStart\` overlaps the previous event's \`timeEnd\` (schedule conflict)
- Large unexplained gaps between consecutive events (> 30 min) that may indicate missing breaks
- Events flagged \`skip: true\` — confirm with the user these are intentional
- Total rundown duration and whether it matches the user's expected show length (ask if unknown)
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
},
},
],
};
}
if (name === 'restructure_rundown') {
const instruction = args.instruction ?? '';
return {
description: 'Reorder events in the rundown',
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Restructure the current Ontime rundown: "${instruction}"
Steps:
1. Call ontime_get_rundown to see the current order and event fields.
2. Compute the target order as an array of event IDs.
3. For each event that needs to move, call ontime_reorder_event with { entryId, destinationId, order: 'before' | 'after' }.
4. Call ontime_get_rundown again at the end to confirm the new order.
Tip: moving items in the "to" direction of the target position minimises reorder calls. Plan the sequence of moves to avoid moving the same event twice.`,
},
},
],
};
}
throw new Error(`Unknown prompt: ${name}`);
}
+203
View File
@@ -0,0 +1,203 @@
import { getProjectData } from '../api-data/project-data/projectData.dao.js';
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
import type { ListResourcesResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
// Static Ontime data model reference — agents read this once per session to understand
// the rundown structure, time format, and entry types before issuing tool calls.
const ONTIME_SCHEMA_MARKDOWN = `# Ontime data model
A concise reference for how Ontime structures rundowns, events, and related data.
## Rundown
A rundown is an ordered list of entries rendered as a show schedule. A project can contain multiple rundowns; one is "loaded" at a time.
\`\`\`
Rundown {
id: string
title: string
order: EntryId[] // top-level entry order
flatOrder: EntryId[] // includes entries nested in groups
entries: { [id: EntryId]: OntimeEntry }
revision: number
}
\`\`\`
## Entries
There are three entry types discriminated by \`type\`:
### \`event\` — OntimeEvent (a timed show item)
\`\`\`
{
type: 'event'
id: EntryId
cue: string // human-facing cue label
title: string
note: string
colour: string // hex, e.g. "#4A90D9"
timeStart: number // ms from midnight (09:00 = 32400000)
timeEnd: number // ms from midnight
duration: number // ms (= timeEnd - timeStart)
delay: number // accumulated delay in ms
timerType: 'count-down' | 'count-up' | 'time-to-end' | 'clock'
endAction: 'none' | 'stop' | 'load-next' | 'play-next'
linkStart: boolean // chain start to previous event's end
countToEnd: boolean // timer counts to planned end time
skip: boolean // event is skipped during playback
timeWarning: number // ms before end to trigger 'warning' state
timeDanger: number // ms before end to trigger 'danger' state
custom: { [key: string]: string } // custom field values
triggers: AutomationTrigger[]
}
\`\`\`
### \`delay\` — Delay (schedule shift applied to following events)
\`\`\`
{ type: 'delay', id, duration: number }
\`\`\`
### \`group\` — Group (nested container of entries)
\`\`\`
{ type: 'group', id, title, colour, note, entries: EntryId[], targetDuration?: number }
\`\`\`
## Time format
All time fields are **milliseconds from midnight (local)**. Examples:
- 09:00:00 = 32400000
- 09:30:00 = 34200000
- 14:15:00 = 51300000
- duration of 45 min = 2700000
## Custom fields
Custom fields are project-scoped name/type/colour definitions stored at \`ontime://project/custom-fields\`. Each event stores values at \`event.custom[fieldKey]\`.
## Playback states (runtime only)
\`'stop' | 'play' | 'pause' | 'armed' | 'roll'\`. When playback is not \`stop\`, mutating tools warn that changes are visible immediately.
## Useful resource URIs
- \`ontime://schema\` — this document
- \`ontime://rundown/current\` — the currently loaded rundown (JSON)
- \`ontime://rundowns\` — all rundowns in the project (JSON)
- \`ontime://project/info\` — project metadata (JSON)
- \`ontime://project/custom-fields\` — custom field definitions (JSON)
- \`ontime://docs\` — index of Ontime documentation topics with URLs
## Further reading
Full Ontime documentation: **https://docs.getontime.no**
Read \`ontime://docs\` for a topic index with direct links.
`;
// Curated documentation index — agents read this to find official docs on specific topics.
const ONTIME_DOCS_MARKDOWN = `# Ontime Documentation Index
Main site: https://docs.getontime.no
## Getting started
- Installation & setup: https://docs.getontime.no/installation/
## Core concepts
- Rundown: https://docs.getontime.no/concepts/rundown/
- Timer types (count-down, count-up, time-to-end, clock): https://docs.getontime.no/concepts/timer/
- Time entry format: https://docs.getontime.no/concepts/time-entry/
- Event actions (end action, link start): https://docs.getontime.no/concepts/event-actions/
- Delays and blocks: https://docs.getontime.no/concepts/delays-and-blocks/
## Features
- Custom fields: https://docs.getontime.no/features/custom-fields/
- Automations (triggers, filters, outputs): https://docs.getontime.no/features/automations/
- URL presets / shared views: https://docs.getontime.no/features/url-presets/
- HTTP Integration: https://docs.getontime.no/api/http/
- OSC Integration: https://docs.getontime.no/api/osc/
## API reference
- REST API overview: https://docs.getontime.no/api/
- WebSocket events: https://docs.getontime.no/api/websocket/
`;
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
{
uri: 'ontime://schema',
name: 'ontime-schema',
title: 'Ontime data model reference',
description:
'Markdown reference for rundown structure, event fields, time format, and entry types. Read once per session to ground tool calls in the correct data model.',
mimeType: 'text/markdown',
},
{
uri: 'ontime://rundown/current',
name: 'current-rundown',
title: 'Currently loaded rundown',
description:
'The rundown currently active in Ontime, with its full entries map and order. Re-read after any mutating call to see updated state.',
mimeType: 'application/json',
},
{
uri: 'ontime://rundowns',
name: 'project-rundowns',
title: 'All rundowns in the project',
description: 'List of every rundown stored in the current project file, plus the ID of the one currently loaded.',
mimeType: 'application/json',
},
{
uri: 'ontime://project/info',
name: 'project-info',
title: 'Project metadata',
description: 'Project title, description, URL, info, logo, and custom header fields.',
mimeType: 'application/json',
},
{
uri: 'ontime://project/custom-fields',
name: 'project-custom-fields',
title: 'Custom field definitions',
description:
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
mimeType: 'application/json',
},
{
uri: 'ontime://docs',
name: 'ontime-docs',
title: 'Ontime documentation index',
description:
'Curated index of Ontime documentation topics with direct links to https://docs.getontime.no. Read this when you need to understand a concept in more depth or want to point the user to official documentation.',
mimeType: 'text/markdown',
},
];
export function handleReadResource(uri: string): ReadResourceResult {
if (uri === 'ontime://schema') {
return { contents: [{ uri, mimeType: 'text/markdown', text: ONTIME_SCHEMA_MARKDOWN }] };
}
if (uri === 'ontime://rundown/current') {
return {
contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(getCurrentRundown()) }],
};
}
if (uri === 'ontime://rundowns') {
const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns());
const loaded = getCurrentRundown().id;
return {
contents: [{ uri, mimeType: 'application/json', text: JSON.stringify({ loaded, rundowns }) }],
};
}
if (uri === 'ontime://project/info') {
return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(getProjectData()) }] };
}
if (uri === 'ontime://project/custom-fields') {
return {
contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(getProjectCustomFields()) }],
};
}
if (uri === 'ontime://docs') {
return { contents: [{ uri, mimeType: 'text/markdown', text: ONTIME_DOCS_MARKDOWN }] };
}
throw new Error(`Unknown resource URI: ${uri}`);
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
type CallToolResult,
type ListToolsResult,
} from '@modelcontextprotocol/sdk/types.js';
import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
export function createMcpServer(): Server {
const server = new Server(
{ name: 'ontime-mcp-server', version: '1.0.0' },
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}));
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
return handleToolCall(name, args as Record<string, unknown>);
});
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPT_DEFINITIONS }));
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
return handleGetPrompt(name, args as Record<string, string>);
});
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DEFINITIONS }));
server.setRequestHandler(ReadResourceRequestSchema, async (request) =>
handleReadResource(request.params.uri),
);
return server;
}
+36
View File
@@ -0,0 +1,36 @@
import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js';
import { initRundown } from '../api-data/rundown/rundown.service.js';
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
/** Returns the standard rundown list payload used by rundown management tool responses */
export function rundownListResponse() {
const loaded = getCurrentRundown().id;
const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns());
return { loaded, rundowns };
}
/** Renames a rundown and reinitialises runtime state if it is currently loaded */
export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
if (!rundown) throw new Error(`Rundown ${id} not found`);
await dataProvider.setRundown(id, { ...rundown, title });
if (id === getCurrentRundown().id) {
await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
}
return rundownListResponse();
}
/** Deletes a rundown, guarding against deleting the active or the last remaining rundown */
export async function deleteRundown(id: string) {
if (id === getCurrentRundown().id) {
throw new Error('Cannot delete the currently loaded rundown');
}
const dataProvider = getDataProvider();
if (Object.keys(dataProvider.getProjectRundowns()).length <= 1) {
throw new Error('Cannot delete the last rundown');
}
await dataProvider.deleteRundown(id);
return rundownListResponse();
}
+688
View File
@@ -0,0 +1,688 @@
import { SupportedEntry } from 'ontime-types';
import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js';
import { getCurrentRundown, getRundownMetadata, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import {
addEntry,
editEntry,
deleteEntries,
reorderEntry,
loadRundown,
batchEditEntries,
} from '../api-data/rundown/rundown.service.js';
import { duplicateRundown } from '../api-data/rundown/rundown.utils.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
import { makeNewRundown } from '../models/dataModel.js';
import {
getProjectList,
loadProjectFile,
createProjectWithPatch,
renameProjectFile,
duplicateProjectFile,
deleteProjectFile,
} from '../services/project-service/ProjectService.js';
import { getState } from '../stores/runtimeState.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { deleteRundown, renameRundown, rundownListResponse } from './mcp.service.js';
// Graceful truncation to keep tool responses within typical MCP context windows
const CHARACTER_LIMIT = 25_000;
// ---- Shared event field JSON schemas ----
// Reused across create_event, update_event, create_events_batch, batch_update_events to avoid repetition.
const EVENT_TIMER_FIELDS = {
timerType: {
type: 'string',
enum: ['count-down', 'count-up', 'clock', 'none'],
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
},
endAction: {
type: 'string',
enum: ['none', 'load-next', 'play-next'],
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
},
linkStart: {
type: 'boolean',
description:
"Chain this event's start time to the previous event's end time — changing the first linked event propagates schedule changes to all linked followers",
},
countToEnd: { type: 'boolean', description: 'Timer counts toward the scheduled end time rather than elapsed time' },
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
} as const;
const EVENT_WRITABLE_FIELDS = {
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
title: { type: 'string', description: 'Event title shown in the rundown and views' },
note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: {
type: 'string',
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
},
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
...EVENT_TIMER_FIELDS,
} as const;
// ---- MCP tool annotation presets ----
// https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const;
const WRITE_IDEM = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
const WRITE_DESTRUCTIVE = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
} as const;
// ---- Tool definitions ----
export const TOOL_DEFINITIONS = [
// --- Rundown read ---
{
name: 'ontime_get_rundown',
description:
'Get the currently loaded rundown. Returns { order: EntryId[], entries: { [id]: OntimeEntry } }. If the rundown exceeds 25 000 chars, returns only the order array with a warning — fetch individual events with ontime_get_event.',
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
{
name: 'ontime_get_rundown_metadata',
description:
'Get cached metadata for the current rundown. Returns: totalDelay, totalDuration, totalDays, firstStart, lastEnd, flags (flagged entry IDs), playableEventOrder, timedEventOrder, flatEntryOrder.',
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
{
name: 'ontime_get_event',
description: 'Get a single event by id or cue. Provide either id or cue (not both). Returns the full entry object.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Event ID (from rundown.entries key or event.id)' },
cue: { type: 'string', description: 'Human-facing cue label' },
},
},
annotations: READ,
},
// --- Rundown mutations ---
{
name: 'ontime_create_event',
description: 'Create a new event in the rundown. Omit after/before to append at the end.',
inputSchema: {
type: 'object',
required: ['cue', 'title', 'timeStart', 'timeEnd', 'duration'],
properties: {
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
title: { type: 'string', description: 'Event title shown in the rundown and views' },
timeStart: { type: 'number', description: 'Start time in ms from midnight (e.g. 09:00 = 32400000)' },
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
duration: { type: 'number', description: 'Duration in ms (should equal timeEnd - timeStart)' },
after: { type: 'string', description: 'Insert after this event ID' },
before: { type: 'string', description: 'Insert before this event ID' },
note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: {
type: 'string',
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
},
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
...EVENT_TIMER_FIELDS,
},
},
annotations: WRITE,
},
{
name: 'ontime_update_event',
description:
'Update fields of an existing event. Only provided fields are changed. Time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination.',
inputSchema: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', description: 'ID of the event to update' },
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
duration: { type: 'number', description: 'Duration in ms' },
...EVENT_WRITABLE_FIELDS,
},
},
annotations: WRITE_DESTRUCTIVE,
},
{
name: 'ontime_delete_entry',
description: 'Delete one or more entries (events, delays, or groups) from the rundown',
inputSchema: {
type: 'object',
required: ['ids'],
properties: {
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to delete' },
},
},
annotations: WRITE_DESTRUCTIVE,
},
{
name: 'ontime_reorder_event',
description:
'Move an event to a new position relative to another event. Use before/after for sibling reordering; use insert to place an event inside a group.',
inputSchema: {
type: 'object',
required: ['entryId', 'destinationId', 'order'],
properties: {
entryId: { type: 'string', description: 'ID of the event to move' },
destinationId: { type: 'string', description: 'ID of the target event (sibling or parent group)' },
order: {
type: 'string',
enum: ['before', 'after', 'insert'],
description: 'before/after: place as sibling; insert: place inside a group',
},
},
},
annotations: WRITE_IDEM,
},
{
name: 'ontime_create_events_batch',
description:
'Create multiple events in one call. Use this for "build from agenda" flows to avoid many round trips. Events are inserted in array order; if `after` is provided it positions the first event, subsequent events chain from the previous.',
inputSchema: {
type: 'object',
required: ['events'],
properties: {
after: { type: 'string', description: 'Insert the first event after this entry ID' },
events: {
type: 'array',
description: 'Array of events to create, in desired order',
items: {
type: 'object',
required: ['cue', 'title', 'timeStart', 'timeEnd', 'duration'],
properties: {
cue: {
type: 'string',
description: 'Short free-form cue label — ask the user what naming convention they prefer',
},
title: { type: 'string', description: 'Event title shown in the rundown and views' },
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
duration: { type: 'number', description: 'Duration in ms (should equal timeEnd - timeStart)' },
note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: {
type: 'string',
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
},
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
...EVENT_TIMER_FIELDS,
},
},
},
},
},
annotations: WRITE,
},
{
name: 'ontime_batch_update_events',
description:
'Apply the same field changes to multiple events by ID. Use for bulk operations like recolouring all keynotes or shifting times by a constant offset (compute new times client-side first).',
inputSchema: {
type: 'object',
required: ['ids', 'data'],
properties: {
ids: { type: 'array', items: { type: 'string' }, description: 'Array of event IDs to update' },
data: {
type: 'object',
description: 'Partial event fields to apply to every ID',
properties: {
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
duration: { type: 'number', description: 'Duration in ms' },
...EVENT_WRITABLE_FIELDS,
},
},
},
},
annotations: WRITE_DESTRUCTIVE,
},
// --- Rundown management ---
{
name: 'ontime_list_rundowns',
description:
'List all rundowns in the current project. Returns rundown IDs and titles, plus the ID of the currently loaded one.',
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
{
name: 'ontime_create_rundown',
description:
'Create a new empty rundown in the current project. Does not switch to it — use ontime_load_rundown to activate.',
inputSchema: {
type: 'object',
required: ['title'],
properties: { title: { type: 'string', description: 'Title for the new rundown' } },
},
annotations: WRITE,
},
{
name: 'ontime_load_rundown',
description:
'Make a rundown the active rundown. Resets the runtime and clears playback state. Prefer to use when playback is stopped.',
inputSchema: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', description: 'Rundown ID to load' } },
},
annotations: WRITE_DESTRUCTIVE,
},
{
name: 'ontime_rename_rundown',
description: 'Rename an existing rundown',
inputSchema: {
type: 'object',
required: ['id', 'title'],
properties: {
id: { type: 'string', description: 'Rundown ID to rename' },
title: { type: 'string', description: 'New title' },
},
},
annotations: WRITE_IDEM,
},
{
name: 'ontime_delete_rundown',
description: 'Delete a rundown (cannot delete the currently loaded rundown or the last remaining rundown)',
inputSchema: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', description: 'Rundown ID to delete' } },
},
annotations: WRITE_DESTRUCTIVE,
},
{
name: 'ontime_duplicate_rundown',
description: 'Duplicate a rundown, creating a copy with a new ID. Does not switch to the copy.',
inputSchema: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', description: 'Rundown ID to duplicate' } },
},
annotations: WRITE,
},
// --- Timer & project ---
{
name: 'ontime_get_timer_state',
description:
'Get the current timer/playback state. Returns: clock (time of day), timer ({ playback, current, elapsed, phase, expectedFinish, addedTime, startedAt }), eventNow (full event object or null), eventNext (full event object or null), offset.',
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
{
name: 'ontime_get_project_info',
description:
'Get current project metadata: title, description, url, info, logo, and custom header fields (array of { title, value, url }).',
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
{
name: 'ontime_update_project_info',
description: 'Update project metadata fields. All fields are optional — only provided fields are updated.',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Project title' },
description: { type: 'string', description: 'Project description' },
url: { type: 'string', description: 'URL shown on viewer pages' },
info: { type: 'string', description: 'Info text shown on viewer pages' },
},
},
annotations: WRITE_DESTRUCTIVE,
},
{
name: 'ontime_get_custom_fields',
description:
'Get the project custom field definitions. Returns { [key]: { label, type: "text"|"image", colour } }. Keys are referenced in event.custom[key].',
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
// --- Project file management ---
{
name: 'ontime_list_projects',
description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.',
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
{
name: 'ontime_load_project',
description:
'Load a different project file by filename. This stops playback, swaps the database, and reinitialises runtime. Prefer to use when playback is stopped.',
inputSchema: {
type: 'object',
required: ['filename'],
properties: { filename: { type: 'string', description: 'Project filename, e.g. "my-show.json"' } },
},
annotations: WRITE_DESTRUCTIVE,
},
{
name: 'ontime_create_project',
description:
'Create a new empty project file and save it to disk. Does not switch to the new project. Omit the .json extension — Ontime appends it.',
inputSchema: {
type: 'object',
required: ['filename'],
properties: {
filename: { type: 'string', description: 'Filename without extension, e.g. "my-show"' },
title: { type: 'string', description: 'Optional project title' },
description: { type: 'string', description: 'Optional project description' },
},
},
annotations: WRITE,
},
{
name: 'ontime_rename_project',
description:
'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.',
inputSchema: {
type: 'object',
required: ['filename', 'newFilename'],
properties: {
filename: { type: 'string', description: 'Current filename (with .json extension)' },
newFilename: { type: 'string', description: 'New filename (with .json extension)' },
},
},
annotations: WRITE_IDEM,
},
{
name: 'ontime_duplicate_project',
description: 'Duplicate a project file on disk with a new filename. Does not switch to the copy.',
inputSchema: {
type: 'object',
required: ['filename', 'newFilename'],
properties: {
filename: { type: 'string', description: 'Source filename to copy (with .json extension)' },
newFilename: { type: 'string', description: 'Filename of the new copy (with .json extension)' },
},
},
annotations: WRITE,
},
{
name: 'ontime_delete_project',
description: 'Delete a project file from disk. Fails if the file is currently loaded.',
inputSchema: {
type: 'object',
required: ['filename'],
properties: { filename: { type: 'string', description: 'Project filename to delete (with .json extension)' } },
},
annotations: WRITE_DESTRUCTIVE,
},
] as const;
// ---- Response helpers (module-level to avoid re-allocation on every tool call) ----
const text = (data: unknown): string => JSON.stringify(data);
export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] });
export const err = (e: unknown): CallToolResult => ({
content: [{ type: 'text', text: text({ error: String(e) }) }],
isError: true,
});
/** Wraps mutating-tool results with a playback warning when Ontime is not stopped */
export const okMutation = (data: unknown): CallToolResult => {
const playback = getState().timer.playback;
const payload =
playback !== 'stop'
? { warning: 'Playback is running — this change takes effect immediately.', result: data }
: data;
return { content: [{ type: 'text', text: text(payload) }] };
};
// ---- Tool call dispatcher ----
export async function handleToolCall(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
switch (name) {
case 'ontime_get_rundown': {
const rundown = getCurrentRundown();
const data = { order: rundown.order, entries: rundown.entries };
const serialised = text(data);
if (serialised.length > CHARACTER_LIMIT) {
return ok({
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_event. Entry IDs in order: ${rundown.order.join(', ')}`,
truncated: true,
order: rundown.order,
});
}
return ok(data);
}
case 'ontime_get_rundown_metadata':
return ok(getRundownMetadata());
case 'ontime_get_event': {
const rundown = getCurrentRundown();
const id = args.id as string | undefined;
const cue = args.cue as string | undefined;
if (id) {
const entry = rundown.entries[id];
if (!entry) return err(`No event with id ${id}`);
return ok(entry);
}
if (cue) {
const entry = Object.values(rundown.entries).find((e) => 'cue' in e && (e as { cue: string }).cue === cue);
if (!entry) return err(`No event with cue ${cue}`);
return ok(entry);
}
return err('Provide id or cue');
}
case 'ontime_create_event': {
try {
const entry = await addEntry({ type: SupportedEntry.Event, ...args } as never);
return okMutation(entry);
} catch (e) {
return err(e);
}
}
case 'ontime_update_event': {
try {
const entry = await editEntry(args as never);
return okMutation(entry);
} catch (e) {
return err(e);
}
}
case 'ontime_delete_entry': {
try {
const ids = args.ids as string[];
const rundown = await deleteEntries(ids);
return okMutation({ deleted: ids, order: rundown.order });
} catch (e) {
return err(e);
}
}
case 'ontime_reorder_event': {
try {
const { entryId, destinationId, order } = args as {
entryId: string;
destinationId: string;
order: 'before' | 'after' | 'insert';
};
const rundown = await reorderEntry(entryId, destinationId, order);
return okMutation({ order: rundown.order });
} catch (e) {
return err(e);
}
}
case 'ontime_create_events_batch': {
try {
const events = (args.events as Array<Record<string, unknown>>) ?? [];
let previousId = (args.after as string | undefined) ?? undefined;
const created: unknown[] = [];
for (const eventArgs of events) {
const entry = await addEntry({
type: SupportedEntry.Event,
...eventArgs,
...(previousId ? { after: previousId } : {}),
} as never);
created.push(entry);
previousId = (entry as { id: string }).id;
}
return okMutation({ created });
} catch (e) {
return err(e);
}
}
case 'ontime_batch_update_events': {
try {
const ids = args.ids as string[];
const data = args.data as Partial<Record<string, unknown>>;
const rundown = await batchEditEntries(ids, data as never);
return okMutation({ updated: ids, order: rundown.order });
} catch (e) {
return err(e);
}
}
case 'ontime_list_rundowns':
return ok(rundownListResponse());
case 'ontime_create_rundown': {
try {
const rundown = makeNewRundown();
rundown.title = args.title as string;
await getDataProvider().setRundown(rundown.id, rundown);
return okMutation(rundownListResponse());
} catch (e) {
return err(e);
}
}
case 'ontime_load_rundown': {
try {
await loadRundown(args.id as string);
return okMutation(rundownListResponse());
} catch (e) {
return err(e);
}
}
case 'ontime_rename_rundown': {
try {
const result = await renameRundown(args.id as string, args.title as string);
return okMutation(result);
} catch (e) {
return err(e);
}
}
case 'ontime_delete_rundown': {
try {
const result = await deleteRundown(args.id as string);
return okMutation(result);
} catch (e) {
return err(e);
}
}
case 'ontime_duplicate_rundown': {
try {
const id = args.id as string;
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
if (!rundown) throw new Error(`Rundown ${id} not found`);
const copy = duplicateRundown(rundown as never, `Copy of ${rundown.title}`);
await dataProvider.setRundown(copy.id, copy);
return okMutation(rundownListResponse());
} catch (e) {
return err(e);
}
}
case 'ontime_get_timer_state': {
const state = getState();
return ok({
clock: state.clock,
timer: state.timer,
eventNow: state.eventNow,
eventNext: state.eventNext,
offset: state.offset,
});
}
case 'ontime_get_project_info':
return ok(getProjectData());
case 'ontime_update_project_info': {
try {
const updated = await editCurrentProjectData(args as never);
return ok(updated);
} catch (e) {
return err(e);
}
}
case 'ontime_get_custom_fields':
return ok(getProjectCustomFields());
case 'ontime_list_projects':
return ok(await getProjectList());
case 'ontime_load_project': {
try {
await loadProjectFile(args.filename as string);
return okMutation(await getProjectList());
} catch (e) {
return err(e);
}
}
case 'ontime_create_project': {
try {
const { filename, title, description } = args as {
filename: string;
title?: string;
description?: string;
};
const patch =
title || description
? { project: { title: title ?? '', description: description ?? '' } as never }
: {};
const newFileName = await createProjectWithPatch(filename, patch);
return ok({ filename: newFileName });
} catch (e) {
return err(e);
}
}
case 'ontime_rename_project': {
try {
const { filename, newFilename } = args as { filename: string; newFilename: string };
await renameProjectFile(filename, newFilename);
return ok(await getProjectList());
} catch (e) {
return err(e);
}
}
case 'ontime_duplicate_project': {
try {
const { filename, newFilename } = args as { filename: string; newFilename: string };
await duplicateProjectFile(filename, newFilename);
return ok(await getProjectList());
} catch (e) {
return err(e);
}
}
case 'ontime_delete_project': {
try {
await deleteProjectFile(args.filename as string);
return ok(await getProjectList());
} catch (e) {
return err(e);
}
}
default:
return err(`Unknown tool: ${name}`);
}
}
@@ -90,6 +90,7 @@ export function makeAuthenticateMiddleware(prefix: string) {
}
}
// MCP clients send Authorization: Bearer <token> rather than cookies
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const bearerToken = authHeader.slice(7);