From e7d62f44e7f2ace6044769385b49ee32ec68274c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 18:10:27 +0000 Subject: [PATCH] feat(mcp): batch rundown edits, project file management, workflow prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 tools and 3 prompts to cover four priority pre-production flows: build-from-agenda (batch create), bulk edit, validate, restructure. Tools: - create_events_batch, batch_update_events — avoid per-event round trips - list_projects, load_project, create_project, rename_project, duplicate_project, delete_project — project file management Prompts: - bulk_edit_rundown — teaches the agent to use batch_update_events vs per-event updates based on whether fields are shared - validate_rundown — pre-show checks for cue gaps, overlaps, missing fields, duration anomalies - restructure_rundown — teaches reorder_event sequencing - create_rundown_from_agenda — updated to use create_events_batch Safety: - Mutating tools now attach a playback warning to their response when timer state is not stopped, so the agent can relay it to the user - create_automation description mentions the in-app test button https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe --- apps/server/src/api-mcp/mcp.router.ts | 423 ++++++++++++++++++++++++-- 1 file changed, 391 insertions(+), 32 deletions(-) diff --git a/apps/server/src/api-mcp/mcp.router.ts b/apps/server/src/api-mcp/mcp.router.ts index 0d86aa998..a1ce58577 100644 --- a/apps/server/src/api-mcp/mcp.router.ts +++ b/apps/server/src/api-mcp/mcp.router.ts @@ -17,10 +17,19 @@ import { 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 @@ -230,7 +239,8 @@ const TOOL_DEFINITIONS = [ }, { name: 'create_automation', - description: 'Create a new 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'], @@ -247,6 +257,136 @@ const TOOL_DEFINITIONS = [ 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 ---- @@ -261,6 +401,15 @@ async function handleToolCall(name: string, args: Record): Prom 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': { @@ -292,7 +441,7 @@ async function handleToolCall(name: string, args: Record): Prom case 'create_event': { try { const entry = await addEntry({ type: SupportedEntry.Event, ...args } as never); - return ok(entry); + return okMutation(entry); } catch (e) { return err(e); } @@ -301,7 +450,7 @@ async function handleToolCall(name: string, args: Record): Prom case 'update_event': { try { const entry = await editEntry(args as never); - return ok(entry); + return okMutation(entry); } catch (e) { return err(e); } @@ -311,7 +460,7 @@ async function handleToolCall(name: string, args: Record): Prom try { const ids = args.ids as string[]; const rundown = await deleteEntries(ids); - return ok({ deleted: ids, order: rundown.order }); + return okMutation({ deleted: ids, order: rundown.order }); } catch (e) { return err(e); } @@ -321,7 +470,7 @@ async function handleToolCall(name: string, args: Record): Prom try { const { entryId, destinationId, order } = args as { entryId: string; destinationId: string; order: 'before' | 'after' | 'insert' }; const rundown = await reorderEntry(entryId, destinationId, order); - return ok({ order: rundown.order }); + return okMutation({ order: rundown.order }); } catch (e) { return err(e); } @@ -336,7 +485,7 @@ async function handleToolCall(name: string, args: Record): Prom const rundown = makeNewRundown(); rundown.title = args.title as string; await getDataProvider().setRundown(rundown.id, rundown); - return ok(rundownListResponse()); + return okMutation(rundownListResponse()); } catch (e) { return err(e); } @@ -345,7 +494,7 @@ async function handleToolCall(name: string, args: Record): Prom case 'load_rundown': { try { await loadRundown(args.id as string); - return ok(rundownListResponse()); + return okMutation(rundownListResponse()); } catch (e) { return err(e); } @@ -361,7 +510,7 @@ async function handleToolCall(name: string, args: Record): Prom if (id === getCurrentRundown().id) { await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields()); } - return ok(rundownListResponse()); + return okMutation(rundownListResponse()); } catch (e) { return err(e); } @@ -380,7 +529,7 @@ async function handleToolCall(name: string, args: Record): Prom return err('Cannot delete the last rundown'); } await dataProvider.deleteRundown(id); - return ok(rundownListResponse()); + return okMutation(rundownListResponse()); } catch (e) { return err(e); } @@ -393,7 +542,7 @@ async function handleToolCall(name: string, args: Record): Prom const rundown = dataProvider.getRundown(id); const copy = duplicateRundown(rundown as never, `Copy of ${rundown.title}`); await dataProvider.setRundown(copy.id, copy); - return ok(rundownListResponse()); + return okMutation(rundownListResponse()); } catch (e) { return err(e); } @@ -435,7 +584,7 @@ async function handleToolCall(name: string, args: Record): Prom case 'create_automation': { try { const result = await addAutomation(args as never); - return ok(result); + return okMutation(result); } catch (e) { return err(e); } @@ -445,6 +594,101 @@ async function handleToolCall(name: string, args: Record): Prom 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}`); } @@ -479,7 +723,7 @@ function createMcpServer(): Server { prompts: [ { name: 'create_rundown_from_agenda', - description: 'Generate MCP tool calls to build a rundown from a plain-text agenda', + description: 'Build an Ontime rundown from a plain-text agenda', arguments: [ { name: 'agenda', @@ -488,6 +732,33 @@ function createMcpServer(): Server { }, ], }, + { + 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, + }, + ], + }, ], }; }); @@ -495,32 +766,120 @@ function createMcpServer(): Server { // Handle prompts/get server.setRequestHandler(GetPromptRequestSchema, async (request): Promise => { const { name, arguments: args = {} } = request.params; - if (name !== 'create_rundown_from_agenda') { - throw new Error(`Unknown prompt: ${name}`); - } - const agenda = (args as Record).agenda ?? ''; - return { - description: 'Generate MCP tool calls to build a rundown from a plain-text agenda', - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Convert the following agenda into an Ontime rundown using these rules: + const a = args as Record; -- Times are in milliseconds from midnight. 09:00 = 32400000, 10:30 = 37800000, etc. + 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 -- Colours by type: #4A90D9 for keynotes, #7B68EE for panels, #888888 for breaks, #E8A838 for meals -- First call get_rundown to see the current state, then use create_event for each agenda item -- Pass \`after: \` to chain events in sequence +- 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}`); }); return server;