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 index dcbe7d4be..37addf1d1 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx @@ -18,10 +18,10 @@ export default function McpSection() { ? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}` : serverURL; - generateUrl({ baseUrl, path: '/mcp', authenticate: true, lockConfig: false, lockNav: false }) + generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false }) .then(setMcpEndpointUrl) .catch(() => { - setMcpEndpointUrl(`${baseUrl}/mcp`); + setMcpEndpointUrl(''); }); }, [infoData]); diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index f796b3a47..1999da107 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -34,43 +34,34 @@ import { import { RundownMetadata } from './rundown.types.js'; -type CompleteEntry = - T extends Partial - ? OntimeEvent - : T extends Partial - ? OntimeDelay - : T extends Partial - ? OntimeGroup - : T extends Partial - ? OntimeMilestone - : never; - /** * Generates a fully formed RundownEntry of the patch type */ -export function generateEvent< - T extends Partial | Partial | Partial | Partial, ->(rundown: Rundown, eventData: T, afterId: EntryId | null, parent?: EntryId): CompleteEntry { +export function generateEvent( + rundown: Rundown, + eventData: Partial | Partial | Partial | Partial, + afterId: EntryId | null, + parent?: EntryId, +): OntimeEntry { if (isOntimeEvent(eventData)) { - return createEvent( - eventData, - getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent), - ) as CompleteEntry; + const event = createEvent(eventData, getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent)); + if (!event) throw new Error('Invalid event type'); + return event; } const id = eventData.id || getUniqueId(rundown); if (isOntimeDelay(eventData)) { - return createDelay({ duration: eventData.duration ?? 0, id }) as CompleteEntry; + return createDelay({ duration: eventData.duration ?? 0, id }); } // TODO(v4): allow user to provide a larger patch of the group entry if (isOntimeGroup(eventData)) { - return createGroup({ id, title: eventData.title ?? '' }) as CompleteEntry; + return createGroup({ id, title: eventData.title ?? '' }); } if (isOntimeMilestone(eventData)) { - return createMilestone({ ...eventData, id }) as CompleteEntry; + return createMilestone({ ...eventData, id }); } throw new Error('Invalid event type'); diff --git a/apps/server/src/api-mcp/MCP.md b/apps/server/src/api-mcp/MCP.md index b58b3d4c7..9bdf0d3e7 100644 --- a/apps/server/src/api-mcp/MCP.md +++ b/apps/server/src/api-mcp/MCP.md @@ -24,7 +24,15 @@ The MCP route is stateless. Clients should send MCP requests with `POST`; `GET` If Ontime has no session password configured, no MCP authentication is required. -If a session password is configured, authenticate with the hashed Ontime token: +If a session password is configured, authenticate with the hashed Ontime token. +The settings UI generates an endpoint URL with the token in the query string: + +```text +http://localhost:4001/mcp?token= +``` + +If your MCP client supports request headers, you can send the same token as a +bearer token instead: ```http Authorization: Bearer @@ -41,16 +49,15 @@ Use a Streamable HTTP MCP client and point it at the MCP endpoint: { "mcpServers": { "ontime": { - "url": "http://localhost:4001/mcp", - "headers": { - "Authorization": "Bearer " - } + "url": "http://localhost:4001/mcp?token=" } } } ``` -Omit `headers` when Ontime is not password protected. +Omit the token when Ontime is not password protected. If you prefer headers, use +`"url": "http://localhost:4001/mcp"` and add an `Authorization` header with the +same bearer token. ## Quick Check diff --git a/apps/server/src/api-mcp/mcp.prompts.ts b/apps/server/src/api-mcp/mcp.prompts.ts index 161fd1059..fc6dd6c7d 100644 --- a/apps/server/src/api-mcp/mcp.prompts.ts +++ b/apps/server/src/api-mcp/mcp.prompts.ts @@ -47,9 +47,11 @@ export function handleGetPrompt(name: string, args: Record): Get Read the ontime://schema resource if you need a data model reference. 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_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item. -3. If the rundown already has events, pass \`after: \` on the batch call so new events chain from the end. +1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it. +2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending. +3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback. +4. Build an array of events in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item. +5. If the rundown already has events, pass \`after: \` on the batch call so new events chain from the end. Entry type guidance: - Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals). @@ -106,10 +108,12 @@ ${args.agenda}`, `Apply the following bulk edit to the current Ontime rundown: "${args.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 entry receives the SAME field values (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_entries once with { ids, data }. -4. If each event needs DIFFERENT field values (e.g. "shift everything 30 minutes later"): check first if events use linkStart. If they do, changing the anchor event's timeStart or duration can cascade to linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_entry for each. +1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it. +2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current events, their IDs, and field values. +3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback. +4. Determine which event IDs are affected by the instruction. +5. If every affected entry receives the SAME field values (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_entries once with { ids, data, rundownId }. +6. If each event needs DIFFERENT field values (e.g. "shift everything 30 minutes later"): check first if events use linkStart. If they do, changing the anchor event's timeStart or duration can cascade to linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_entry for each. Time shift mechanics: - All time fields are milliseconds from midnight; compute arithmetic before calling the tools. @@ -166,13 +170,15 @@ Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mis `Restructure the current Ontime rundown: "${args.instruction}" Steps: -1. Call ontime_get_rundown to see the current order, groups, and event fields. -2. Note which events are inside groups (check each group's \`entries\` array vs the top-level \`order\` array). -3. Compute the target arrangement as a sequence of moves. -4. For each event that needs to move, call ontime_reorder_entry: +1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it. +2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current order, groups, and event fields. +3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP reorders the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback. +4. Note which events are inside groups (check each group's \`entries\` array vs the top-level \`order\` array). +5. Compute the target arrangement as a sequence of moves. +6. For each event that needs to move, call ontime_reorder_entry: - \`order: 'before'\` or \`'after'\` — places the event as a sibling next to destinationId - \`order: 'insert'\` — places the event inside a group (destinationId must be the group's ID) -5. Call ontime_get_rundown again to confirm the new order. +7. Call ontime_get_rundown again to confirm the new order. Group awareness: - Events inside a group appear in the group's \`entries\` array, not in the top-level \`order\`. diff --git a/apps/server/src/api-mcp/mcp.router.ts b/apps/server/src/api-mcp/mcp.router.ts index 5dd9e04f2..b7038921a 100644 --- a/apps/server/src/api-mcp/mcp.router.ts +++ b/apps/server/src/api-mcp/mcp.router.ts @@ -3,7 +3,9 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; import type { Request, Response, Router } from 'express'; +import { LogOrigin } from 'ontime-types'; +import { logger } from '../classes/Logger.js'; import { createMcpServer } from './mcp.server.js'; export const mcpRouter: Router = express.Router(); @@ -16,9 +18,17 @@ mcpRouter.post('/', async (req, res) => { 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); + try { + const server = createMcpServer(); + await server.connect(transport); + await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(LogOrigin.Server, `MCP request failed: ${message}`); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null }); + } + } }); // Stateless mode: GET (SSE) and DELETE (session teardown) are not applicable. diff --git a/apps/server/src/api-mcp/mcp.schema.ts b/apps/server/src/api-mcp/mcp.schema.ts index b48230c7b..e5282090f 100644 --- a/apps/server/src/api-mcp/mcp.schema.ts +++ b/apps/server/src/api-mcp/mcp.schema.ts @@ -1,12 +1,12 @@ /** - * Single source of truth for Ontime MCP documentation. + * Agent-facing Ontime MCP documentation. * * Tool field schemas (EVENT_TIMER_FIELDS, EVENT_WRITABLE_FIELDS) and the schema resource - * (ONTIME_SCHEMA_MARKDOWN) are maintained here so that a single edit keeps tool descriptions, - * prompt guidance, and the agent-readable reference document in sync. + * (ONTIME_SCHEMA_MARKDOWN) are maintained here so tool descriptions, prompt guidance, + * and the agent-readable reference document use the same wording. * * Canonical type definitions live in packages/types/src/definitions/core/OntimeEntry.ts. - * Update this file when the data model changes. + * Keep this file concise and update it when MCP-exposed fields change. */ // ---- Shared event field JSON schemas ---- @@ -29,6 +29,12 @@ export const EVENT_TIMER_FIELDS = { "Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule", }, countToEnd: { type: 'boolean', description: 'Timer counts toward the scheduled end time rather than elapsed time' }, + timeStrategy: { + type: 'string', + enum: ['lock-duration', 'lock-end'], + description: + 'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration', + }, 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; @@ -55,6 +61,14 @@ export const EVENT_WRITABLE_FIELDS = { ...EVENT_TIMER_FIELDS, } as const; +export const RUNDOWN_TARGET_FIELD = { + rundownId: { + type: 'string', + description: + 'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.', + }, +} as const; + // ---- Agent-readable schema document ---- // Served at ontime://schema. Agents read this once per session to orient themselves // before issuing tool calls. @@ -103,9 +117,11 @@ There are four entry types discriminated by \`type\`: countToEnd: boolean // timer counts to planned end time skip: boolean // event is skipped during playback flag: boolean // critical operational marker, highlighted to operators + timeStrategy: 'lock-duration' | 'lock-end' 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: Trigger[] // automation trigger references parent: EntryId | null // parent group, when nested revision: number // entry revision } @@ -158,9 +174,15 @@ Events, milestones and groups store values at \`entry.custom[fieldKey]\`. Only use existing field keys when setting \`custom\` values. Adding, renaming, or deleting custom field definitions is a separate project-level operation. When the user wants to assign custom values, show the existing custom field list first if there is any ambiguity, so you do not create duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`. +## Targeting rundowns +Entry read/write tools accept an optional \`rundownId\`. +- Omit \`rundownId\` to target the currently loaded live rundown. +- Pass a rundown ID from \`ontime_list_rundowns\` to edit a background rundown without loading it. +- Loading a rundown with \`ontime_load_rundown\` switches the live rundown and resets runtime state; do not load a rundown just to edit it in the background. + ## Playback states (runtime only) \`'stop' | 'play' | 'pause' | 'armed' | 'roll'\` -When playback is not \`stop\`, mutating tools warn that changes are visible immediately. +When playback is not \`stop\`, mutations to the loaded rundown affect the live rundown immediately, so confirm with the user before editing. Mutations to a background rundown using \`rundownId\` do not interrupt playback. ## Available resources - \`ontime://schema\` — this document 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..bed129356 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.service.ts @@ -0,0 +1,183 @@ +import { + EntryId, + EventPostPayload, + InsertOptions, + OntimeDelay, + OntimeEntry, + OntimeEvent, + OntimeGroup, + OntimeMilestone, + PatchWithId, + ProjectRundowns, + Rundown, + SupportedEntry, +} from 'ontime-types'; + +import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js'; +import { + addEntry, + batchEditEntries, + deleteEntries, + editEntry, + reorderEntry, +} from '../api-data/rundown/rundown.service.js'; +import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js'; +import { getDataProvider } from '../classes/data-provider/DataProvider.js'; + +export type EventFieldArgs = Partial< + Pick< + OntimeEvent, + | 'cue' + | 'title' + | 'note' + | 'colour' + | 'skip' + | 'flag' + | 'custom' + | 'timerType' + | 'endAction' + | 'linkStart' + | 'countToEnd' + | 'timeStrategy' + | 'timeWarning' + | 'timeDanger' + | 'timeStart' + | 'timeEnd' + | 'duration' + > +>; +export type MilestoneFieldArgs = Partial>; +export type DelayFieldArgs = Partial>; +export type GroupFieldArgs = Partial>; + +export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs; +export type TargetRundownArgs = { rundownId?: string }; +export type CreateEntryArgs = EntryFieldArgs & InsertOptions & TargetRundownArgs & { type?: `${SupportedEntry}` }; +export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId }; + +export function resolveTargetRundownId(args: TargetRundownArgs): string { + return args.rundownId ?? getCurrentRundownId(); +} + +function getTargetMeta(rundownId: string) { + const loaded = rundownId === getCurrentRundownId(); + return { rundownId, loaded }; +} + +export function getRundownById(rundownId?: string): Readonly { + const targetId = rundownId ?? getCurrentRundownId(); + return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId); +} + +export function findEntry(args: TargetRundownArgs & { id?: EntryId; cue?: string }): OntimeEntry | undefined { + const rundown = getRundownById(args.rundownId); + if (args.id) { + return rundown.entries[args.id]; + } + if (args.cue) { + return Object.values(rundown.entries).find((entry) => 'cue' in entry && entry.cue === args.cue); + } +} + +export function toRundownList(projectRundowns: Readonly) { + return { loaded: getCurrentRundownId(), rundowns: normalisedToRundownArray(projectRundowns) }; +} + +export function assertKnownCustomFields(...customValues: Array) { + const customFields = getProjectCustomFields(); + const knownKeys = new Set(Object.keys(customFields)); + const unknownKeys = new Set(); + + for (const custom of customValues) { + if (!custom) continue; + for (const key of Object.keys(custom)) { + if (!knownKeys.has(key)) { + unknownKeys.add(key); + } + } + } + + if (unknownKeys.size > 0) { + const keys = [...unknownKeys].join(', '); + throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`); + } +} + +/** Translates tool arguments into the payload consumed by rundown.service addEntry */ +export function toEntryPayload(args: CreateEntryArgs): EventPostPayload { + const { type = SupportedEntry.Event, after, before } = args; + + switch (type) { + case SupportedEntry.Delay: + return { type: SupportedEntry.Delay, duration: args.duration, after, before }; + case SupportedEntry.Milestone: { + const { cue, title, note, colour, custom } = args; + return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before }; + } + case SupportedEntry.Group: + // group creation currently only accepts a title, see generateEvent in rundown.utils.ts + return { type: SupportedEntry.Group, title: args.title, after, before }; + case SupportedEntry.Event: { + const { type: _type, rundownId: _rundownId, ...eventFields } = args; + return { type: SupportedEntry.Event, ...eventFields }; + } + default: + throw new Error(`Invalid entry type: ${String(type)}`); + } +} + +export async function createEntryForMcp(args: CreateEntryArgs) { + assertKnownCustomFields(args.custom); + const rundownId = resolveTargetRundownId(args); + const entry = await addEntry(rundownId, toEntryPayload(args)); + return { target: getTargetMeta(rundownId), entry }; +} + +export async function updateEntryForMcp(args: UpdateEntryArgs) { + assertKnownCustomFields(args.custom); + const rundownId = resolveTargetRundownId(args); + const { rundownId: _rundownId, ...patch } = args; + const entry = await editEntry(rundownId, patch as PatchWithId); + return { target: getTargetMeta(rundownId), entry }; +} + +export async function deleteEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[] }) { + const rundownId = resolveTargetRundownId(args); + const rundown = await deleteEntries(rundownId, args.ids); + return { target: getTargetMeta(rundownId), deleted: args.ids, order: rundown.order }; +} + +export async function reorderEntryForMcp( + args: TargetRundownArgs & { entryId: EntryId; destinationId: EntryId; order: 'before' | 'after' | 'insert' }, +) { + const rundownId = resolveTargetRundownId(args); + const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order); + return { target: getTargetMeta(rundownId), order: rundown.order }; +} + +export async function batchCreateEntriesForMcp( + args: TargetRundownArgs & { entries: CreateEntryArgs[]; after?: EntryId }, +) { + const { entries = [], after } = args; + assertKnownCustomFields(...entries.map((entry) => entry.custom)); + const rundownId = resolveTargetRundownId(args); + let previousId = after; + const created: OntimeEntry[] = []; + + for (const entryArgs of entries) { + const payload = toEntryPayload(entryArgs); + // eslint-disable-next-line no-await-in-loop -- each entry chains after the previously created one + const entry = await addEntry(rundownId, previousId ? { ...payload, after: previousId } : payload); + created.push(entry); + previousId = entry.id; + } + + return { target: getTargetMeta(rundownId), created }; +} + +export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }) { + assertKnownCustomFields(args.data.custom); + const rundownId = resolveTargetRundownId(args); + const rundown = await batchEditEntries(rundownId, args.ids, args.data); + return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order }; +} diff --git a/apps/server/src/api-mcp/mcp.tools.ts b/apps/server/src/api-mcp/mcp.tools.ts index 532829f5b..cf8bd363c 100644 --- a/apps/server/src/api-mcp/mcp.tools.ts +++ b/apps/server/src/api-mcp/mcp.tools.ts @@ -1,39 +1,15 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { - EntryId, - EventPostPayload, - InsertOptions, - OntimeDelay, - OntimeEvent, - OntimeGroup, - OntimeMilestone, - PatchWithId, - Playback, - ProjectData, - ProjectRundowns, - SupportedEntry, -} from 'ontime-types'; +import { EntryId, ProjectData } from 'ontime-types'; import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js'; +import { getProjectCustomFields, getRundownMetadata } from '../api-data/rundown/rundown.dao.js'; import { - getCurrentRundown, - getCurrentRundownId, - getProjectCustomFields, - getRundownMetadata, -} from '../api-data/rundown/rundown.dao.js'; -import { - addEntry, - batchEditEntries, createNewRundown, - deleteEntries, deleteRundown, duplicateExistingRundown, - editEntry, loadRundown, renameRundown, - reorderEntry, } from '../api-data/rundown/rundown.service.js'; -import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js'; import { getDataProvider } from '../classes/data-provider/DataProvider.js'; import { makeNewProject } from '../models/dataModel.js'; import { @@ -45,7 +21,22 @@ import { renameProjectFile, } from '../services/project-service/ProjectService.js'; import { getState } from '../stores/runtimeState.js'; -import { EVENT_WRITABLE_FIELDS } from './mcp.schema.js'; +import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js'; +import { + batchCreateEntriesForMcp, + batchUpdateEntriesForMcp, + createEntryForMcp, + deleteEntriesForMcp, + findEntry, + getRundownById, + reorderEntryForMcp, + toRundownList, + updateEntryForMcp, + type CreateEntryArgs, + type EntryFieldArgs, + type TargetRundownArgs, + type UpdateEntryArgs, +} from './mcp.service.js'; // Graceful truncation to keep tool responses within typical MCP context windows const CHARACTER_LIMIT = 25_000; @@ -68,8 +59,8 @@ export const TOOL_DEFINITIONS = [ { 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 entries with ontime_get_entry.', - inputSchema: { type: 'object', properties: {} }, + 'Get a rundown. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns { order: EntryId[], entries: { [id]: OntimeEntry } }. If the rundown exceeds 25 000 chars, returns only the order array with a warning — fetch individual entries with ontime_get_entry.', + inputSchema: { type: 'object', properties: { ...RUNDOWN_TARGET_FIELD } }, annotations: READ, }, { @@ -81,10 +72,12 @@ export const TOOL_DEFINITIONS = [ }, { name: 'ontime_get_entry', - description: 'Get a single entry by id or cue. Provide either id or cue (not both). Returns the full entry object.', + description: + 'Get a single entry by id or cue. Provide either id or cue (not both). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns the full entry object.', inputSchema: { type: 'object', properties: { + ...RUNDOWN_TARGET_FIELD, id: { type: 'string', description: 'Entry ID (from rundown.entries key or entry.id)' }, cue: { type: 'string', description: 'Human-facing cue label' }, }, @@ -95,10 +88,11 @@ export const TOOL_DEFINITIONS = [ { name: 'ontime_create_entry', description: - 'Create a new entry in the current rundown. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration, and timeEnd+duration calculates timeStart. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title only — set colour, note, custom, or targetDuration with ontime_update_entry after creation.', + 'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title only — set colour, note, custom, or targetDuration with ontime_update_entry after creation.', inputSchema: { type: 'object', properties: { + ...RUNDOWN_TARGET_FIELD, type: { type: 'string', enum: ['event', 'delay', 'milestone', 'group'], @@ -121,11 +115,12 @@ export const TOOL_DEFINITIONS = [ { name: 'ontime_update_entry', description: - 'Update fields of an existing entry (event, milestone, delay or group). Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group fields: title, note, colour, custom, targetDuration. Delay field: duration. Milestone fields: cue, title, note, colour, custom. Custom values must use existing project custom field keys; adding a new custom field is a separate operation.', + 'Update fields of an existing entry (event, milestone, delay or group). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group fields: title, note, colour, custom, targetDuration. Delay field: duration. Milestone fields: cue, title, note, colour, custom. Custom values must use existing project custom field keys; adding a new custom field is a separate operation.', inputSchema: { type: 'object', required: ['id'], properties: { + ...RUNDOWN_TARGET_FIELD, id: { type: 'string', description: 'ID of the entry to update' }, timeStart: { type: 'number', description: 'Start time in ms from midnight' }, timeEnd: { type: 'number', description: 'End time in ms from midnight' }, @@ -138,11 +133,13 @@ export const TOOL_DEFINITIONS = [ }, { name: 'ontime_delete_entries', - description: 'Delete one or more entries (events, milestones, delays, or groups) from the current rundown', + description: + 'Delete one or more entries (events, milestones, delays, or groups). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it.', inputSchema: { type: 'object', required: ['ids'], properties: { + ...RUNDOWN_TARGET_FIELD, ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to delete' }, }, }, @@ -151,11 +148,12 @@ export const TOOL_DEFINITIONS = [ { name: 'ontime_reorder_entry', description: - 'Move an entry to a new position relative to another entry. Use before/after for sibling reordering; use insert to place an entry inside a group.', + 'Move an entry to a new position relative to another entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use before/after for sibling reordering; use insert to place an entry inside a group.', inputSchema: { type: 'object', required: ['entryId', 'destinationId', 'order'], properties: { + ...RUNDOWN_TARGET_FIELD, entryId: { type: 'string', description: 'ID of the entry to move' }, destinationId: { type: 'string', description: 'ID of the target entry (sibling or parent group)' }, order: { @@ -170,11 +168,12 @@ export const TOOL_DEFINITIONS = [ { name: 'ontime_batch_create_entries', description: - 'Create multiple entries in one call. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first entry, subsequent entries chain from the previous. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration, and timeEnd+duration calculates timeStart.', + 'Create multiple entries. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first entry, subsequent entries chain from the previous. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.', inputSchema: { type: 'object', required: ['entries'], properties: { + ...RUNDOWN_TARGET_FIELD, after: { type: 'string', description: 'Insert the first entry after this entry ID' }, entries: { type: 'array', @@ -201,11 +200,12 @@ export const TOOL_DEFINITIONS = [ { name: 'ontime_batch_update_entries', description: - 'Apply the same field values to multiple entries by ID. Use for bulk operations like recolouring all keynotes, skipping all breaks, or setting the same custom value on several entries. Custom values must use existing project custom field keys. Do not use for changes where each entry needs a different value, such as time shifts with different timeStart/timeEnd values; compute those per entry and call ontime_update_entry for each.', + 'Apply the same field values to multiple entries by ID. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use for bulk operations like recolouring all keynotes, skipping all breaks, or setting the same custom value on several entries. Custom values must use existing project custom field keys. Do not use for changes where each entry needs a different value, such as time shifts with different timeStart/timeEnd values; compute those per entry and call ontime_update_entry for each.', inputSchema: { type: 'object', required: ['ids', 'data'], properties: { + ...RUNDOWN_TARGET_FIELD, ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to update' }, data: { type: 'object', @@ -243,7 +243,7 @@ export const TOOL_DEFINITIONS = [ { 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.', + 'Make a rundown the active rundown. This resets the runtime and clears playback state. If playback is running, confirm the user accepts interrupting the live rundown before calling. To edit a background rundown without interrupting playback, advise using the cuesheet view.', inputSchema: { type: 'object', required: ['id'], @@ -330,7 +330,7 @@ export const TOOL_DEFINITIONS = [ { 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.', + 'Load a different project file by filename. This swaps the database and reinitialises runtime. If playback is running, confirm the user accepts interrupting the live project before calling.', inputSchema: { type: 'object', required: ['filename'], @@ -341,7 +341,7 @@ export const TOOL_DEFINITIONS = [ { name: 'ontime_create_project', description: - 'Create a new project file and switch to it. This stops playback and swaps the loaded project. Omit the .json extension — Ontime appends it.', + 'Create a new project file and switch to it. This swaps the loaded project. If playback is running, confirm the user accepts interrupting the live project before calling. Omit the .json extension — Ontime appends it.', inputSchema: { type: 'object', required: ['filename'], @@ -393,89 +393,8 @@ export const TOOL_DEFINITIONS = [ type ToolName = (typeof TOOL_DEFINITIONS)[number]['name']; -// ---- Tool argument types ---- -// Arguments arrive as parsed JSON and are cast once, at the trust boundary, to the types below. -// The types are derived (Pick) from the domain types in ontime-types so that a change to the -// domain model or a service signature fails compilation here rather than drifting silently. - -type EventFieldArgs = Partial< - Pick< - OntimeEvent, - | 'cue' - | 'title' - | 'note' - | 'colour' - | 'skip' - | 'flag' - | 'custom' - | 'timerType' - | 'endAction' - | 'linkStart' - | 'countToEnd' - | 'timeWarning' - | 'timeDanger' - | 'timeStart' - | 'timeEnd' - | 'duration' - > ->; -type MilestoneFieldArgs = Partial>; -type DelayFieldArgs = Partial>; -type GroupFieldArgs = Partial>; - -type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs; -type CreateEntryArgs = EntryFieldArgs & InsertOptions & { type?: `${SupportedEntry}` }; -type UpdateEntryArgs = EntryFieldArgs & { id: EntryId }; type ProjectInfoArgs = Partial>; -function assertKnownCustomFields(...customValues: Array) { - const customFields = getProjectCustomFields(); - const knownKeys = new Set(Object.keys(customFields)); - const unknownKeys = new Set(); - - for (const custom of customValues) { - if (!custom) continue; - for (const key of Object.keys(custom)) { - if (!knownKeys.has(key)) { - unknownKeys.add(key); - } - } - } - - if (unknownKeys.size > 0) { - const keys = [...unknownKeys].join(', '); - throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`); - } -} - -/** Translates tool arguments into the payload consumed by rundown.service addEntry */ -function toEntryPayload(args: CreateEntryArgs): EventPostPayload { - const { type = SupportedEntry.Event, after, before } = args; - - switch (type) { - case SupportedEntry.Delay: - return { type: SupportedEntry.Delay, duration: args.duration, after, before }; - case SupportedEntry.Milestone: { - const { cue, title, note, colour, custom } = args; - return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before }; - } - case SupportedEntry.Group: - // group creation currently only accepts a title, see generateEvent in rundown.utils.ts - return { type: SupportedEntry.Group, title: args.title, after, before }; - case SupportedEntry.Event: { - const { type: _type, ...eventFields } = args; - return { type: SupportedEntry.Event, ...eventFields }; - } - default: - throw new Error(`Invalid entry type: ${String(type)}`); - } -} - -/** Formats project rundowns in the standard list payload used by rundown management tools */ -function toRundownList(projectRundowns: Readonly) { - return { loaded: getCurrentRundownId(), rundowns: normalisedToRundownArray(projectRundowns) }; -} - // ---- Response helpers (module-level to avoid re-allocation on every tool call) ---- const text = (data: unknown): string => JSON.stringify(data); @@ -487,130 +406,96 @@ export const err = (e: unknown): CallToolResult => ({ 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 !== Playback.Stop - ? { warning: 'Playback is running — this change takes effect immediately.', result: data } - : data; - return { content: [{ type: 'text', text: text(payload) }] }; -}; - // ---- Tool handlers ---- // Each handler is a thin translation wrapper: it maps the wire arguments to a typed call // into an existing service and formats the response. Business logic belongs in the services. const TOOL_HANDLERS: Record) => Promise> = { - ontime_get_rundown: async () => { - const rundown = getCurrentRundown(); + ontime_get_rundown: async (args) => { + const targetArgs = args as TargetRundownArgs; + const rundown = getRundownById(targetArgs.rundownId); 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_entry. Entry IDs in order: ${rundown.order.join(', ')}`, truncated: true, + rundownId: rundown.id, order: rundown.order, }); } - return ok(data); + return ok({ rundownId: rundown.id, ...data }); }, ontime_get_rundown_metadata: async () => ok(getRundownMetadata()), ontime_get_entry: async (args) => { - const { id, cue } = args as { id?: EntryId; cue?: string }; - const rundown = getCurrentRundown(); - if (id) { - const entry = rundown.entries[id]; - if (!entry) return err(`No entry with id ${id}`); - return ok(entry); - } - if (cue) { - const entry = Object.values(rundown.entries).find((entry) => 'cue' in entry && entry.cue === cue); - if (!entry) return err(`No entry with cue ${cue}`); - return ok(entry); - } + const entryArgs = args as TargetRundownArgs & { id?: EntryId; cue?: string }; + const entry = findEntry(entryArgs); + if (entry) return ok(entry); + if (entryArgs.id) return err(`No entry with id ${entryArgs.id}`); + if (entryArgs.cue) return err(`No entry with cue ${entryArgs.cue}`); return err('Provide id or cue'); }, ontime_create_entry: async (args) => { - const createArgs = args as CreateEntryArgs; - assertKnownCustomFields(createArgs.custom); - const entry = await addEntry(getCurrentRundownId(), toEntryPayload(createArgs)); - return okMutation(entry); + return ok(await createEntryForMcp(args as CreateEntryArgs)); }, ontime_update_entry: async (args) => { - const updateArgs = args as UpdateEntryArgs; - assertKnownCustomFields(updateArgs.custom); - const entry = await editEntry(getCurrentRundownId(), updateArgs as PatchWithId); - return okMutation(entry); + return ok(await updateEntryForMcp(args as UpdateEntryArgs)); }, ontime_delete_entries: async (args) => { - const { ids } = args as { ids: EntryId[] }; - const rundown = await deleteEntries(getCurrentRundownId(), ids); - return okMutation({ deleted: ids, order: rundown.order }); + return ok(await deleteEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[] })); }, ontime_reorder_entry: async (args) => { - const { entryId, destinationId, order } = args as { - entryId: EntryId; - destinationId: EntryId; - order: 'before' | 'after' | 'insert'; - }; - const rundown = await reorderEntry(getCurrentRundownId(), entryId, destinationId, order); - return okMutation({ order: rundown.order }); + return ok( + await reorderEntryForMcp( + args as TargetRundownArgs & { + entryId: EntryId; + destinationId: EntryId; + order: 'before' | 'after' | 'insert'; + }, + ), + ); }, ontime_batch_create_entries: async (args) => { - const { entries = [], after } = args as { entries: CreateEntryArgs[]; after?: EntryId }; - assertKnownCustomFields(...entries.map((entry) => entry.custom)); - const rundownId = getCurrentRundownId(); - let previousId = after; - const created: unknown[] = []; - for (const entryArgs of entries) { - const payload = toEntryPayload(entryArgs); - // eslint-disable-next-line no-await-in-loop -- each entry chains after the previously created one - const entry = await addEntry(rundownId, previousId ? { ...payload, after: previousId } : payload); - created.push(entry); - previousId = entry.id; - } - return okMutation({ created }); + return ok( + await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: CreateEntryArgs[]; after?: EntryId }), + ); }, ontime_batch_update_entries: async (args) => { - const { ids, data } = args as { ids: EntryId[]; data: EntryFieldArgs }; - assertKnownCustomFields(data.custom); - const rundown = await batchEditEntries(getCurrentRundownId(), ids, data); - return okMutation({ updated: ids, order: rundown.order }); + return ok(await batchUpdateEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs })); }, ontime_list_rundowns: async () => ok(toRundownList(getDataProvider().getProjectRundowns())), ontime_create_rundown: async (args) => { const { title } = args as { title: string }; - return okMutation(toRundownList(await createNewRundown(title))); + return ok(toRundownList(await createNewRundown(title))); }, ontime_load_rundown: async (args) => { const { id } = args as { id: string }; - return okMutation(toRundownList(await loadRundown(id))); + return ok(toRundownList(await loadRundown(id))); }, ontime_rename_rundown: async (args) => { const { id, title } = args as { id: string; title: string }; - return okMutation(toRundownList(await renameRundown(id, title))); + return ok(toRundownList(await renameRundown(id, title))); }, ontime_delete_rundown: async (args) => { const { id } = args as { id: string }; - return okMutation(toRundownList(await deleteRundown(id))); + return ok(toRundownList(await deleteRundown(id))); }, ontime_duplicate_rundown: async (args) => { const { id } = args as { id: string }; - return okMutation(toRundownList(await duplicateExistingRundown(id))); + return ok(toRundownList(await duplicateExistingRundown(id))); }, ontime_get_timer_state: async () => { @@ -632,7 +517,7 @@ const TOOL_HANDLERS: Record) => Promise ontime_load_project: async (args) => { const { filename } = args as { filename: string }; await loadProjectFile(filename); - return okMutation(await getProjectList()); + return ok(await getProjectList()); }, ontime_create_project: async (args) => { @@ -647,7 +532,7 @@ const TOOL_HANDLERS: Record) => Promise }; const project: ProjectData = { ...makeNewProject().project, title, description }; const newFileName = await createProjectWithPatch(filename, { project }); - return okMutation({ filename: newFileName }); + return ok({ filename: newFileName }); }, ontime_rename_project: async (args) => {