From 71a7f264937097f03a1d61e7fef0570f006cf836 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 19:16:25 +0000 Subject: [PATCH] =?UTF-8?q?refactor(mcp):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20split=20files,=20fix=20naming,=20stateless=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../panel/feature-panel/FeaturePanel.tsx | 56 +- .../panel/feature-panel/McpSection.tsx | 47 + apps/server/src/api-mcp/mcp.prompts.ts | 149 +++ apps/server/src/api-mcp/mcp.resources.ts | 203 +++ apps/server/src/api-mcp/mcp.router.ts | 1182 +---------------- apps/server/src/api-mcp/mcp.server.ts | 46 + apps/server/src/api-mcp/mcp.service.ts | 36 + apps/server/src/api-mcp/mcp.tools.ts | 688 ++++++++++ apps/server/src/middleware/authenticate.ts | 1 + 9 files changed, 1191 insertions(+), 1217 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx create mode 100644 apps/server/src/api-mcp/mcp.prompts.ts create mode 100644 apps/server/src/api-mcp/mcp.resources.ts create mode 100644 apps/server/src/api-mcp/mcp.server.ts create mode 100644 apps/server/src/api-mcp/mcp.service.ts create mode 100644 apps/server/src/api-mcp/mcp.tools.ts diff --git a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx index 848631389..101b0c2a7 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx @@ -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('report', location); const mcpRef = useScrollIntoView('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 ( <> Sharing and reporting @@ -63,30 +36,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
- - - MCP Server (AI Agent Integration) - - Connect AI agents (e.g. Claude Desktop) to Ontime via the Model Context Protocol endpoint. - - - - {mcpEndpointUrl && ( - {mcpEndpointUrl} - )} - - - {mcpEndpointUrl && ( - {claudeDesktopConfig} - )} - - +
diff --git a/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx b/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx new file mode 100644 index 000000000..ebff5a1c2 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx @@ -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 ( + + + MCP Server + Connect any MCP-compatible AI agent to Ontime using the endpoint below. + + + {mcpEndpointUrl && {mcpEndpointUrl}} + + + {mcpEndpointUrl && {mcpClientConfig}} + + + ); +} diff --git a/apps/server/src/api-mcp/mcp.prompts.ts b/apps/server/src/api-mcp/mcp.prompts.ts new file mode 100644 index 000000000..5da816e08 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.prompts.ts @@ -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): 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: \` 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}`); +} diff --git a/apps/server/src/api-mcp/mcp.resources.ts b/apps/server/src/api-mcp/mcp.resources.ts new file mode 100644 index 000000000..e57b10c3b --- /dev/null +++ b/apps/server/src/api-mcp/mcp.resources.ts @@ -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}`); +} diff --git a/apps/server/src/api-mcp/mcp.router.ts b/apps/server/src/api-mcp/mcp.router.ts index c01ecc1ad..250207065 100644 --- a/apps/server/src/api-mcp/mcp.router.ts +++ b/apps/server/src/api-mcp/mcp.router.ts @@ -1,1177 +1,31 @@ -import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import express from 'express'; -import { SupportedEntry } from 'ontime-types'; - -import { getAutomationSettings, addAutomation } from '../api-data/automation/automation.dao.js'; -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, - initRundown, - batchEditEntries, -} from '../api-data/rundown/rundown.service.js'; -import { duplicateRundown, normalisedToRundownArray } 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'; - -// MCP SDK imports -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - isInitializeRequest, - ListToolsRequestSchema, - CallToolRequestSchema, - ListPromptsRequestSchema, - GetPromptRequestSchema, - ListResourcesRequestSchema, - ReadResourceRequestSchema, - CallToolResult, - ListToolsResult, - ListPromptsResult, - GetPromptResult, - ListResourcesResult, - ReadResourceResult, -} from '@modelcontextprotocol/sdk/types.js'; -/** Active sessions indexed by session ID */ -const sessions = new Map(); +import { createMcpServer } from './mcp.server.js'; -// ---- Static schema document exposed as a resource ---- -const ONTIME_SCHEMA_MARKDOWN = `# Ontime data model - -A concise reference for how Ontime structures rundowns, events, and related data. Read this once per session to ground your answers in Ontime's semantics. - -## 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, e.g. "K01" - 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 - -## Cue conventions (not enforced — useful when generating) -- Keynotes: K01, K02, … -- Panels: P01, P02, … -- Breaks: B01, B02, … -- Meals: M01, M02, … - -## Colours (common Ontime palette) -- Keynotes: #4A90D9 -- Panels: #7B68EE -- Breaks: #888888 -- Meals: #E8A838 - -## 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. -`; - -// ---- Documentation index exposed as a resource ---- -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/ -`; - -// ---- Tool definitions ---- -const TOOL_DEFINITIONS = [ - { - name: 'get_rundown', - description: 'Get the currently loaded rundown including order and entries', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'get_rundown_metadata', - description: 'Get cached metadata for the current rundown', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'get_event', - description: 'Get a single event by id or cue', - inputSchema: { - type: 'object', - properties: { - id: { type: 'string', description: 'Event ID' }, - cue: { type: 'string', description: 'Event cue' }, - }, - }, - }, - { - name: 'create_event', - description: 'Create a new event in the rundown', - inputSchema: { - type: 'object', - required: ['cue', 'title', 'timeStart', 'timeEnd', 'duration'], - properties: { - cue: { type: 'string' }, - title: { type: 'string' }, - 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' }, - after: { type: 'string', description: 'Insert after this event ID' }, - before: { type: 'string', description: 'Insert before this event ID' }, - note: { type: 'string' }, - colour: { type: 'string' }, - skip: { type: 'boolean' }, - timerType: { type: 'string', enum: ['count-down', 'count-up', 'time-to-end', 'clock'] }, - endAction: { type: 'string', enum: ['none', 'stop', 'load-next', 'play-next'] }, - linkStart: { type: 'boolean' }, - countToEnd: { type: 'boolean' }, - timeWarning: { type: 'number' }, - timeDanger: { type: 'number' }, - }, - }, - }, - { - name: 'update_event', - description: 'Update fields of an existing event', - inputSchema: { - type: 'object', - required: ['id'], - properties: { - id: { type: 'string' }, - cue: { type: 'string' }, - title: { type: 'string' }, - timeStart: { type: 'number' }, - timeEnd: { type: 'number' }, - duration: { type: 'number' }, - note: { type: 'string' }, - colour: { type: 'string' }, - skip: { type: 'boolean' }, - timerType: { type: 'string', enum: ['count-down', 'count-up', 'time-to-end', 'clock'] }, - endAction: { type: 'string', enum: ['none', 'stop', 'load-next', 'play-next'] }, - linkStart: { type: 'boolean' }, - countToEnd: { type: 'boolean' }, - timeWarning: { type: 'number' }, - timeDanger: { type: 'number' }, - }, - }, - }, - { - name: 'delete_event', - description: 'Delete one or more events from the rundown', - inputSchema: { - type: 'object', - required: ['ids'], - properties: { - ids: { type: 'array', items: { type: 'string' }, description: 'Array of event IDs to delete' }, - }, - }, - }, - { - name: 'reorder_event', - description: 'Move an event to a new position relative to another event', - 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 destination event' }, - order: { type: 'string', enum: ['before', 'after', 'insert'], description: 'Position relative to destination' }, - }, - }, - }, - { - name: 'list_rundowns', - description: 'List all available rundowns', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'create_rundown', - description: 'Create a new rundown', - inputSchema: { - type: 'object', - required: ['title'], - properties: { - title: { type: 'string', description: 'Title for the new rundown' }, - }, - }, - }, - { - name: 'load_rundown', - description: 'Load a rundown, making it the active rundown', - inputSchema: { - type: 'object', - required: ['id'], - properties: { - id: { type: 'string', description: 'Rundown ID to load' }, - }, - }, - }, - { - name: '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' }, - }, - }, - }, - { - name: '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' }, - }, - }, - }, - { - name: 'duplicate_rundown', - description: 'Duplicate a rundown', - inputSchema: { - type: 'object', - required: ['id'], - properties: { - id: { type: 'string', description: 'Rundown ID to duplicate' }, - }, - }, - }, - { - name: 'get_timer_state', - description: 'Get the current timer/playback state', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'get_project_info', - description: 'Get current project information', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'update_project_info', - description: 'Update project information fields', - inputSchema: { - type: 'object', - properties: { - title: { type: 'string' }, - description: { type: 'string' }, - publicUrl: { type: 'string' }, - publicInfo: { type: 'string' }, - }, - }, - }, - { - name: 'list_automations', - description: 'List all automations and triggers', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: 'create_automation', - description: - 'Create a new automation (trigger + filter + outputs). Ontime provides a test button in the Automations panel — encourage the user to preview the output before relying on it in a live show.', - inputSchema: { - type: 'object', - required: ['title', 'filterRule', 'filters', 'outputs'], - properties: { - title: { type: 'string' }, - filterRule: { type: 'string', enum: ['all', 'any'] }, - filters: { type: 'array', items: { type: 'object' }, description: 'Array of filter objects' }, - outputs: { type: 'array', items: { type: 'object' }, description: 'Array of output action objects' }, - }, - }, - }, - { - name: 'get_custom_fields', - description: 'Get the project custom fields', - inputSchema: { type: 'object', properties: {} }, - }, - // --- Batch rundown edits --- - { - name: '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' }, - title: { type: 'string' }, - timeStart: { type: 'number' }, - timeEnd: { type: 'number' }, - duration: { type: 'number' }, - note: { type: 'string' }, - colour: { type: 'string' }, - skip: { type: 'boolean' }, - timerType: { type: 'string', enum: ['count-down', 'count-up', 'time-to-end', 'clock'] }, - endAction: { type: 'string', enum: ['none', 'stop', 'load-next', 'play-next'] }, - linkStart: { type: 'boolean' }, - countToEnd: { type: 'boolean' }, - timeWarning: { type: 'number' }, - timeDanger: { type: 'number' }, - }, - }, - }, - }, - }, - }, - { - name: '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: { - cue: { type: 'string' }, - title: { type: 'string' }, - note: { type: 'string' }, - colour: { type: 'string' }, - skip: { type: 'boolean' }, - timerType: { type: 'string', enum: ['count-down', 'count-up', 'time-to-end', 'clock'] }, - endAction: { type: 'string', enum: ['none', 'stop', 'load-next', 'play-next'] }, - timeWarning: { type: 'number' }, - timeDanger: { type: 'number' }, - }, - }, - }, - }, - }, - // --- Project file management --- - { - name: 'list_projects', - description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.', - inputSchema: { type: 'object', properties: {} }, - }, - { - name: '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"' }, - }, - }, - }, - { - name: 'create_project', - description: 'Create a new empty project file and save it to disk. Does not switch to the new project.', - inputSchema: { - type: 'object', - required: ['filename'], - properties: { - filename: { type: 'string', description: 'Filename for the new project (e.g. "my-show")' }, - title: { type: 'string', description: 'Optional project title' }, - description: { type: 'string', description: 'Optional project description' }, - }, - }, - }, - { - name: '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' }, - newFilename: { type: 'string', description: 'New filename' }, - }, - }, - }, - { - name: 'duplicate_project', - description: 'Duplicate a project file on disk with a new filename.', - inputSchema: { - type: 'object', - required: ['filename', 'newFilename'], - properties: { - filename: { type: 'string', description: 'Source filename to copy' }, - newFilename: { type: 'string', description: 'Filename of the new copy' }, - }, - }, - }, - { - name: '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' }, - }, - }, - }, -] as const; - -// ---- Helper to build rundown list response ---- -function rundownListResponse() { - const loaded = getCurrentRundown().id; - const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns()); - return { loaded, rundowns }; -} - -// ---- Tool handlers ---- -async function handleToolCall(name: string, args: Record): Promise { - const text = (data: unknown) => JSON.stringify(data); - const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] }); - const err = (e: unknown): CallToolResult => ({ content: [{ type: 'text', text: text({ error: String(e) }) }], isError: true }); - // Wraps mutating-tool results with a warning when playback is active so the agent can relay it to the user. - 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) }] }; - }; - - switch (name) { - case 'get_rundown': { - const rundown = getCurrentRundown(); - return ok({ order: rundown.order, entries: rundown.entries }); - } - - case 'get_rundown_metadata': { - return ok(getRundownMetadata()); - } - - case '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 'create_event': { - try { - const entry = await addEntry({ type: SupportedEntry.Event, ...args } as never); - return okMutation(entry); - } catch (e) { - return err(e); - } - } - - case 'update_event': { - try { - const entry = await editEntry(args as never); - return okMutation(entry); - } catch (e) { - return err(e); - } - } - - case 'delete_event': { - 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 '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 'list_rundowns': { - return ok(rundownListResponse()); - } - - case '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 'load_rundown': { - try { - await loadRundown(args.id as string); - return okMutation(rundownListResponse()); - } catch (e) { - return err(e); - } - } - - case 'rename_rundown': { - try { - const { id, title } = args as { 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 okMutation(rundownListResponse()); - } catch (e) { - return err(e); - } - } - - case 'delete_rundown': { - try { - const id = args.id as string; - const dataProvider = getDataProvider(); - const currentId = getCurrentRundown().id; - if (id === currentId) { - return err('Cannot delete the currently loaded rundown'); - } - const rundowns = dataProvider.getProjectRundowns(); - if (Object.keys(rundowns).length <= 1) { - return err('Cannot delete the last rundown'); - } - await dataProvider.deleteRundown(id); - return okMutation(rundownListResponse()); - } catch (e) { - return err(e); - } - } - - case 'duplicate_rundown': { - try { - const id = args.id as string; - const dataProvider = getDataProvider(); - const rundown = dataProvider.getRundown(id); - 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 'get_timer_state': { - const state = getState(); - return ok({ - clock: state.clock, - timer: state.timer, - eventNow: state.eventNow, - eventNext: state.eventNext, - offset: state.offset, - }); - } - - case 'get_project_info': { - return ok(getProjectData()); - } - - case 'update_project_info': { - try { - const updated = await editCurrentProjectData(args as never); - return ok(updated); - } catch (e) { - return err(e); - } - } - - case 'list_automations': { - const settings = getAutomationSettings(); - return ok({ - enabledAutomations: settings.enabledAutomations, - triggers: settings.triggers, - automations: settings.automations, - }); - } - - case 'create_automation': { - try { - const result = await addAutomation(args as never); - return okMutation(result); - } catch (e) { - return err(e); - } - } - - case 'get_custom_fields': { - return ok(getProjectCustomFields()); - } - - // --- Batch rundown edits --- - - case 'create_events_batch': { - try { - const events = (args.events as Array>) ?? []; - 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 'batch_update_events': { - try { - const ids = args.ids as string[]; - const data = args.data as Partial>; - const rundown = await batchEditEntries(ids, data as never); - return okMutation({ updated: ids, order: rundown.order }); - } catch (e) { - return err(e); - } - } - - // --- Project file management --- - - case 'list_projects': { - return ok(await getProjectList()); - } - - case 'load_project': { - try { - await loadProjectFile(args.filename as string); - return okMutation(await getProjectList()); - } catch (e) { - return err(e); - } - } - - case '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 '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 '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 'delete_project': { - try { - await deleteProjectFile(args.filename as string); - return ok(await getProjectList()); - } catch (e) { - return err(e); - } - } - - default: - return err(`Unknown tool: ${name}`); - } -} - -/** Build and configure a new MCP Server instance */ -function createMcpServer(): Server { - const server = new Server( - { name: 'ontime', version: '1.0.0' }, - { - capabilities: { - tools: {}, - prompts: {}, - resources: {}, - }, - }, - ); - - // Handle tools/list - server.setRequestHandler(ListToolsRequestSchema, async (): Promise => { - return { tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'] }; - }); - - // Handle tools/call - server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { - const { name, arguments: args = {} } = request.params; - return handleToolCall(name, args as Record); - }); - - // Handle prompts/list - server.setRequestHandler(ListPromptsRequestSchema, async (): Promise => { - return { - prompts: [ - { - name: 'create_rundown_from_agenda', - description: 'Build an Ontime rundown from a plain-text agenda', - arguments: [ - { - name: 'agenda', - description: 'Plain-text agenda to convert into an Ontime rundown', - required: true, - }, - ], - }, - { - name: 'bulk_edit_rundown', - description: 'Apply a bulk change across the rundown (shift times, recolour, skip, etc.)', - arguments: [ - { - name: 'instruction', - description: 'What to change (e.g. "shift everything 30 minutes later", "make all breaks 10 minutes")', - required: true, - }, - ], - }, - { - name: 'validate_rundown', - description: 'Check the current rundown for common issues before a show', - arguments: [], - }, - { - name: 'restructure_rundown', - description: 'Reorder or group events in the rundown', - arguments: [ - { - name: 'instruction', - description: 'What to restructure (e.g. "group all keynotes together", "reorder to match this template")', - required: true, - }, - ], - }, - ], - }; - }); - - // Handle prompts/get - server.setRequestHandler(GetPromptRequestSchema, async (request): Promise => { - const { name, arguments: args = {} } = request.params; - const a = args as Record; - - if (name === 'create_rundown_from_agenda') { - const agenda = a.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 -- Cue prefixes by type: K01/K02/... for keynotes, P01/P02/... for panels, B01/B02/... for breaks, M01/... for meals -- Colours by type: #4A90D9 keynotes, #7B68EE panels, #888888 breaks, #E8A838 meals -- timerType: "count-down" for timed sessions, "clock" for clock-relative -- endAction: "load-next" for back-to-back sessions, "none" otherwise - -Steps: -1. Call get_rundown to see current state and identify an \`after\` anchor if appending. -2. Build an array of events in order and call create_events_batch ONCE with all of them. This is much faster than calling create_event per item. -3. If the rundown already has events, pass \`after: \` on the batch call so new events chain from the end. - -Agenda: -${agenda}`, - }, - }, - ], - }; - } - - if (name === 'bulk_edit_rundown') { - const instruction = a.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 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 batch_update_events once with { ids, data }. -4. If each event needs DIFFERENT values (e.g. "shift everything 30 minutes" — each event gets a different timeStart/timeEnd): compute the new values per event, then call update_event for each, or call batch_update_events multiple times grouped by shared data. -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 get_rundown to read all events. -2. Call 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 = a.instruction ?? ''; - return { - description: 'Reorder events in the rundown', - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Restructure the current Ontime rundown: "${instruction}" - -Steps: -1. Call 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 reorder_event with { entryId, destinationId, order: 'before' | 'after' }. -4. Call 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}`); - }); - - // Handle resources/list — static list of readable resources the agent can load into context - server.setRequestHandler(ListResourcesRequestSchema, async (): Promise => { - return { - resources: [ - { - uri: 'ontime://schema', - name: 'ontime-schema', - title: 'Ontime data model reference', - description: - 'Markdown reference for how Ontime structures rundowns, events, delays, groups, time fields, cue conventions, and colours. Read once per session to ground your answers.', - 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', - }, - ], - }; - }); - - // Handle resources/read — return the resource body for a given URI - server.setRequestHandler(ReadResourceRequestSchema, async (request): Promise => { - const uri = request.params.uri; - - if (uri === 'ontime://schema') { - return { - contents: [{ uri, mimeType: 'text/markdown', text: ONTIME_SCHEMA_MARKDOWN }], - }; - } - - if (uri === 'ontime://rundown/current') { - const rundown = getCurrentRundown(); - return { - contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(rundown) }], - }; - } - - 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}`); - }); - - return server; -} - -/** Express router for the MCP endpoint */ export const mcpRouter = express.Router(); -// POST / — handle new or existing session mcpRouter.post('/', async (req, res) => { - const body = req.body as unknown; - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - if (isInitializeRequest(body)) { - // New session: pin the session ID so the map key matches what the transport - // sends back to the client in the mcp-session-id response header. - const sessionId = randomUUID(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionId, - }); - - sessions.set(sessionId, transport); - transport.onclose = () => sessions.delete(sessionId); - - const mcpServer = createMcpServer(); - await mcpServer.connect(transport); - await transport.handleRequest(req as never, res as never, body); - return; - } - - // Existing session - if (!sessionId || !sessions.has(sessionId)) { - res.status(400).json({ error: 'Invalid or missing mcp-session-id' }); - return; - } - - const transport = sessions.get(sessionId)!; - await transport.handleRequest(req as never, res as never, body); + // A new Server instance is created per request — required for stateless mode where + // each POST is independent and concurrent requests must not share transport state. + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless: no session tracking + enableJsonResponse: true, + }); + res.on('close', () => transport.close()); + const server = createMcpServer(); + await server.connect(transport); + await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body); }); -// GET / — SSE stream for existing session -mcpRouter.get('/', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !sessions.has(sessionId)) { - res.status(400).json({ error: 'Invalid or missing mcp-session-id' }); - return; - } - - const transport = sessions.get(sessionId)!; - await transport.handleRequest(req as never, res as never); +// Stateless mode: GET (SSE) and DELETE (session teardown) are not applicable. +// All MCP interactions happen via POST in a single request/response cycle. +mcpRouter.get('/', (_req, res) => { + res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }); }); -// DELETE / — close and remove session -mcpRouter.delete('/', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !sessions.has(sessionId)) { - res.status(400).json({ error: 'Invalid or missing mcp-session-id' }); - return; - } - - const transport = sessions.get(sessionId)!; - sessions.delete(sessionId); - await transport.close(); - res.status(204).send(); +mcpRouter.delete('/', (_req, res) => { + res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }); }); diff --git a/apps/server/src/api-mcp/mcp.server.ts b/apps/server/src/api-mcp/mcp.server.ts new file mode 100644 index 000000000..ec6afc8ed --- /dev/null +++ b/apps/server/src/api-mcp/mcp.server.ts @@ -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 => ({ + tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { + const { name, arguments: args = {} } = request.params; + return handleToolCall(name, args as Record); + }); + + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPT_DEFINITIONS })); + + server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params; + return handleGetPrompt(name, args as Record); + }); + + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DEFINITIONS })); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => + handleReadResource(request.params.uri), + ); + + return server; +} diff --git a/apps/server/src/api-mcp/mcp.service.ts b/apps/server/src/api-mcp/mcp.service.ts new file mode 100644 index 000000000..7b219624f --- /dev/null +++ b/apps/server/src/api-mcp/mcp.service.ts @@ -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(); +} diff --git a/apps/server/src/api-mcp/mcp.tools.ts b/apps/server/src/api-mcp/mcp.tools.ts new file mode 100644 index 000000000..f3651c0c2 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.tools.ts @@ -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): Promise { + 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>) ?? []; + 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>; + 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}`); + } +} diff --git a/apps/server/src/middleware/authenticate.ts b/apps/server/src/middleware/authenticate.ts index 9a58462dd..9fc9c3c4e 100644 --- a/apps/server/src/middleware/authenticate.ts +++ b/apps/server/src/middleware/authenticate.ts @@ -90,6 +90,7 @@ export function makeAuthenticateMiddleware(prefix: string) { } } + // MCP clients send Authorization: Bearer rather than cookies const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { const bearerToken = authHeader.slice(7);