diff --git a/apps/server/src/api-mcp/__tests__/mcp.registry.test.ts b/apps/server/src/api-mcp/__tests__/mcp.registry.test.ts new file mode 100644 index 000000000..05012e106 --- /dev/null +++ b/apps/server/src/api-mcp/__tests__/mcp.registry.test.ts @@ -0,0 +1,107 @@ +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { buildToolList, defineTool, makeToolRegistry, ok } from '../mcp.registry.js'; + +const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const; + +const echo = vi.fn(async (args: unknown) => ok(args)); + +const tools = [ + defineTool( + 'test_echo', + { + description: 'Echoes its arguments', + inputSchema: z.strictObject({ + id: z.string().describe('An id'), + count: z.number().optional(), + filename: z + .string() + .optional() + .transform((filename) => filename?.toUpperCase()), + }), + annotations: READ, + }, + echo, + ), + defineTool( + 'test_throws', + { + description: 'Always fails', + inputSchema: z.strictObject({}), + annotations: READ, + }, + async () => { + throw new Error('the rundown is not loaded'); + }, + ), +]; + +const dispatch = makeToolRegistry(tools); + +describe('dispatchToolCall', () => { + it('passes validated arguments to the handler', async () => { + const result = await dispatch('test_echo', { id: 'entry-1', count: 2 }); + expect(result.isError).toBeUndefined(); + expect(echo).toHaveBeenCalledWith({ id: 'entry-1', count: 2 }); + }); + + it('hands the handler the transformed value, not the raw one', async () => { + await dispatch('test_echo', { id: 'entry-1', filename: 'show.json' }); + expect(echo).toHaveBeenLastCalledWith({ id: 'entry-1', filename: 'SHOW.JSON' }); + }); + + it('rejects malformed arguments as a protocol error, without running the handler', async () => { + echo.mockClear(); + // this is what the SDK itself does on the registerTool path, and what v2 will do + await expect(dispatch('test_echo', { count: 'two' })).rejects.toThrow(McpError); + await expect(dispatch('test_echo', { count: 'two' })).rejects.toMatchObject({ code: ErrorCode.InvalidParams }); + expect(echo).not.toHaveBeenCalled(); + }); + + it('names every invalid field in a single message', async () => { + await expect(dispatch('test_echo', { count: 'two', bogus: true })).rejects.toThrow( + /Invalid arguments for test_echo:.*id.*count.*bogus/s, + ); + }); + + it('rejects an unknown tool as a protocol error', async () => { + await expect(dispatch('test_missing', {})).rejects.toMatchObject({ + code: ErrorCode.MethodNotFound, + message: expect.stringContaining('Unknown tool: test_missing'), + }); + }); + + /** + * Business failures are returned to the agent rather than thrown, so that it can read the + * message and recover. Only malformed calls are protocol errors. + */ + it('returns failures from the services as tool errors', async () => { + const result = await dispatch('test_throws', {}); + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: 'text', text: '{"error":"the rundown is not loaded"}' }]); + }); +}); + +describe('buildToolList', () => { + it('advertises the input shape, before any transform is applied', () => { + const [echoTool] = buildToolList(tools); + expect(echoTool.inputSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string', description: 'An id' }, + count: { type: 'number' }, + filename: { type: 'string' }, + }, + required: ['id'], + additionalProperties: false, + }); + }); + + it('carries the description and annotations through', () => { + const [echoTool] = buildToolList(tools); + expect(echoTool.description).toBe('Echoes its arguments'); + expect(echoTool.annotations).toEqual(READ); + }); +}); diff --git a/apps/server/src/api-mcp/__tests__/mcp.tools.test.ts b/apps/server/src/api-mcp/__tests__/mcp.tools.test.ts new file mode 100644 index 000000000..20f8cb30c --- /dev/null +++ b/apps/server/src/api-mcp/__tests__/mcp.tools.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { TOOLS, TOOL_LIST } from '../mcp.tools.js'; + +describe('tool wiring', () => { + it('advertises every declared tool', () => { + expect(TOOL_LIST).toHaveLength(TOOLS.length); + expect(TOOL_LIST.map((tool) => tool.name)).toEqual(TOOLS.map((tool) => tool.name)); + }); + + it('declares unique tool names', () => { + const names = TOOLS.map((tool) => tool.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('gives every tool a handler, a description and annotations', () => { + const incomplete = TOOLS.filter( + (tool) => typeof tool.handler !== 'function' || !tool.config.description || !tool.config.annotations, + ); + expect(incomplete.map((tool) => tool.name)).toEqual([]); + }); +}); + +describe('generated input schemas', () => { + it('advertises an object schema for every tool', () => { + const notObjects = TOOL_LIST.filter((tool) => tool.inputSchema.type !== 'object'); + expect(notObjects.map((tool) => tool.name)).toEqual([]); + }); + + /** + * Recursive schemas would generate $ref/$defs, which some MCP clients handle poorly. + * Batch creation is deliberately modelled two levels deep to avoid them. + */ + it('generates self contained schemas, without $ref or $defs', () => { + const withReferences = TOOL_LIST.filter((tool) => /\$ref|\$defs|\$schema/.test(JSON.stringify(tool.inputSchema))); + expect(withReferences.map((tool) => tool.name)).toEqual([]); + }); + + it('rejects unknown keys in every tool', () => { + const permissive = TOOL_LIST.filter((tool) => tool.inputSchema.additionalProperties !== false); + expect(permissive.map((tool) => tool.name)).toEqual([]); + }); + + it('describes required arguments', () => { + const byName = new Map(TOOL_LIST.map((tool) => [tool.name, tool.inputSchema])); + + expect(byName.get('ontime_update_entry')?.required).toEqual(['id']); + expect(byName.get('ontime_delete_entries')?.required).toEqual(['ids']); + expect(byName.get('ontime_reorder_entry')?.required).toEqual(['entryId', 'destinationId', 'order']); + expect(byName.get('ontime_batch_update_entries')?.required).toEqual(['ids', 'data']); + expect(byName.get('ontime_create_custom_field')?.required).toEqual(['label', 'type', 'colour']); + expect(byName.get('ontime_rename_project')?.required).toEqual(['filename', 'newFilename']); + // tools which take no arguments advertise an empty object + expect(byName.get('ontime_list_rundowns')).toEqual({ + type: 'object', + properties: {}, + additionalProperties: false, + }); + }); + + it('advertises the pre-sanitisation shape of filenames', () => { + // the agent must be told the shape it should send, not the transformed value + const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_delete_project')?.inputSchema; + expect(schema?.properties?.filename).toEqual({ + type: 'string', + minLength: 1, + description: 'Project filename to delete (with .json extension)', + }); + }); + + it('generates a stable schema for a representative tool', () => { + const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_group_entries')?.inputSchema; + expect(schema).toMatchInlineSnapshot(` + { + "additionalProperties": false, + "properties": { + "colour": { + "description": "Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide", + "type": "string", + }, + "custom": { + "additionalProperties": { + "type": "string", + }, + "description": "Custom field values keyed by existing project field key", + "propertyNames": { + "type": "string", + }, + "type": "object", + }, + "ids": { + "description": "Existing top-level entry IDs to group", + "items": { + "type": "string", + }, + "minItems": 1, + "type": "array", + }, + "note": { + "description": "Free-text group note for production notes or references", + "type": "string", + }, + "rundownId": { + "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.", + "type": "string", + }, + "targetDuration": { + "description": "Planned length of the group in ms", + "type": "number", + }, + "title": { + "description": "Group title shown in the rundown and views", + "type": "string", + }, + }, + "required": [ + "ids", + ], + "type": "object", + } + `); + }); +}); diff --git a/apps/server/src/api-mcp/__tests__/mcp.validation.test.ts b/apps/server/src/api-mcp/__tests__/mcp.validation.test.ts new file mode 100644 index 000000000..d5070d0c7 --- /dev/null +++ b/apps/server/src/api-mcp/__tests__/mcp.validation.test.ts @@ -0,0 +1,186 @@ +import { dirname, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { TOOLS } from '../mcp.tools.js'; + +const PROJECTS_DIR = resolve('/projects'); + +function schemaFor(name: string) { + const tool = TOOLS.find((candidate) => candidate.name === name); + if (!tool?.config.inputSchema) { + throw new Error(`No input schema for ${name}`); + } + return tool.config.inputSchema; +} + +/** Parses and returns the sanitised arguments, failing the test on invalid input */ +function parse(name: string, args: Record): Record { + const result = schemaFor(name).safeParse(args); + if (!result.success) { + throw new Error(`Expected ${name} to accept the arguments: ${issuesOf(result.error)}`); + } + return result.data; +} + +function issuesOf(error: z.ZodError): string { + return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); +} + +/** Returns the issue messages of arguments which must not be accepted */ +function reject(name: string, args: Record): string { + const result = schemaFor(name).safeParse(args); + if (result.success) { + throw new Error(`Expected ${name} to reject ${JSON.stringify(args)}`); + } + return issuesOf(result.error); +} + +describe('required arguments', () => { + it('rejects an update without an entry id', () => { + expect(reject('ontime_update_entry', { title: 'Keynote' })).toContain('id'); + }); + + it('rejects a reorder without a destination', () => { + expect(reject('ontime_reorder_entry', { entryId: 'entry-1', order: 'after' })).toContain('destinationId'); + }); + + it('accepts a well formed update', () => { + expect(parse('ontime_update_entry', { id: 'entry-1', title: 'Keynote', timeStart: 32400000 })).toEqual({ + id: 'entry-1', + title: 'Keynote', + timeStart: 32400000, + }); + }); +}); + +describe('argument types', () => { + it('rejects a stringified number where a time is expected', () => { + expect(reject('ontime_update_entry', { id: 'entry-1', timeStart: '09:00' })).toContain('timeStart'); + }); + + it('rejects a non-array list of ids', () => { + expect(reject('ontime_delete_entries', { ids: 'entry-1' })).toContain('ids'); + }); + + it('rejects an empty list of ids to group', () => { + expect(reject('ontime_group_entries', { ids: [] })).toContain('Provide at least one entry ID to group.'); + }); + + it('rejects an unknown reorder position', () => { + expect(reject('ontime_reorder_entry', { entryId: 'a', destinationId: 'b', order: 'sideways' })).toContain('order'); + }); + + it('rejects an unknown entry type', () => { + expect(reject('ontime_create_entry', { type: 'interlude' })).toContain('type'); + }); + + it('rejects non-string custom field values', () => { + expect(reject('ontime_update_entry', { id: 'entry-1', custom: { Camera: 2 } })).toContain('custom.Camera'); + }); + + it('accepts an insert anchor as an id or as true, but not as false', () => { + expect(parse('ontime_create_entry', { after: 'entry-1' })).toEqual({ after: 'entry-1' }); + expect(parse('ontime_create_entry', { before: true })).toEqual({ before: true }); + // `after: false` has no meaning: the services only check whether the anchor is present + expect(reject('ontime_create_entry', { after: false })).toContain('after'); + }); +}); + +describe('unknown keys', () => { + it('rejects fields which are not part of the tool', () => { + expect(reject('ontime_update_entry', { id: 'entry-1', titel: 'typo' })).toContain('titel'); + }); + + /** + * editCurrentProjectData spreads its argument into the stored project, and reacts to a + * `logo` key by deleting the current logo file. Only declared fields may reach it. + */ + it('rejects undeclared writes to project data', () => { + expect(reject('ontime_update_project_info', { title: 'Show', logo: 'other.png' })).toContain('logo'); + expect(parse('ontime_update_project_info', { title: 'Show' })).toEqual({ title: 'Show' }); + }); + + it('rejects entry internals which the services must own', () => { + expect(reject('ontime_update_entry', { id: 'entry-1', revision: 99 })).toContain('revision'); + expect(reject('ontime_update_entry', { id: 'entry-1', parent: 'group-1' })).toContain('parent'); + }); +}); + +describe('project filenames', () => { + /** + * Filenames reach the filesystem through join(projectsDir, name), so the invariant that + * matters is that nothing which could traverse out of that directory survives parsing. + */ + const directoryOf = (filename: unknown) => dirname(resolve(PROJECTS_DIR, String(filename))); + + it.each(['../../../etc/passwd', '../sibling.json', '..\\windows\\system32\\show.json', '/etc/shadow', 'show.json'])( + 'confines %s to the projects directory', + (filename) => { + expect(directoryOf(parse('ontime_delete_project', { filename }).filename)).toBe(PROJECTS_DIR); + expect(directoryOf(parse('ontime_load_project', { filename }).filename)).toBe(PROJECTS_DIR); + }, + ); + + it('confines both filenames of a rename', () => { + const args = parse('ontime_rename_project', { filename: '../a.json', newFilename: '../../b.json' }); + expect(directoryOf(args.filename)).toBe(PROJECTS_DIR); + expect(directoryOf(args.newFilename)).toBe(PROJECTS_DIR); + }); + + it('appends the extension and trims the name', () => { + expect(parse('ontime_delete_project', { filename: ' show ' })).toEqual({ filename: 'show.json' }); + expect(parse('ontime_delete_project', { filename: 'show.json' })).toEqual({ filename: 'show.json' }); + }); + + it('rejects a filename which is empty once sanitised', () => { + expect(reject('ontime_delete_project', { filename: '..' })).toContain('sanitised'); + expect(reject('ontime_delete_project', { filename: ' ' })).toBeTruthy(); + }); + + it('does not append an extension when creating a project', () => { + // ProjectService appends the extension itself, and de-duplicates the name + expect(parse('ontime_create_project', { filename: ' my-show ' })).toEqual({ filename: 'my-show' }); + expect(directoryOf(parse('ontime_create_project', { filename: '../my-show' }).filename)).toBe(PROJECTS_DIR); + }); +}); + +describe('batch creation', () => { + const entry = { type: 'event', title: 'Talk', duration: 600000 }; + + it('accepts a group with children', () => { + const args = parse('ontime_batch_create_entries', { + entries: [{ type: 'group', title: 'Morning', children: [entry] }], + }); + expect(args.entries).toHaveLength(1); + }); + + it('rejects a group nested inside a group', () => { + const issues = reject('ontime_batch_create_entries', { + entries: [{ type: 'group', title: 'Morning', children: [{ type: 'group', title: 'Nested' }] }], + }); + expect(issues).toContain('type'); + }); + + it('rejects children on entries which are not groups', () => { + expect( + reject('ontime_batch_create_entries', { entries: [{ type: 'event', title: 'Talk', children: [entry] }] }), + ).toContain('Only group entries can have children.'); + }); + + it('rejects unknown keys inside nested children', () => { + expect( + reject('ontime_batch_create_entries', { + entries: [{ type: 'group', title: 'Morning', children: [{ ...entry, titel: 'typo' }] }], + }), + ).toContain('titel'); + }); +}); + +describe('lookups which need one of several arguments', () => { + it('requires an id or a cue', () => { + expect(reject('ontime_get_entry', {})).toContain('Provide id or cue'); + expect(parse('ontime_get_entry', { cue: '1a' })).toEqual({ cue: '1a' }); + }); +}); diff --git a/apps/server/src/api-mcp/mcp.registry.ts b/apps/server/src/api-mcp/mcp.registry.ts new file mode 100644 index 000000000..3193d0f24 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.registry.ts @@ -0,0 +1,135 @@ +/** + * Tool registry for the Ontime MCP server. + * + * This is the only module aware of the low-level `@modelcontextprotocol/sdk` Server API. + * Tools are declared with `defineTool(name, config, handler)` — the same argument shape + * `McpServer.registerTool` takes — so migrating to the SDK v2 `registerTool` API means + * replacing the two functions below with a registration loop, leaving every tool untouched. + * + * Responsibilities kept here (and taken over by the SDK on v2): + * - deriving the advertised JSON Schema from the tool input schema + * - parsing tool arguments before a handler runs + * + * Argument shape is validated here; business rules (does this ID exist, is this custom + * field known) stay in mcp.service.ts. + */ + +import { + ErrorCode, + McpError, + type CallToolResult, + type ListToolsResult, + type ToolAnnotations, +} from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; + +/** Input schemas must describe an object: MCP tool arguments are always a record */ +type ToolInput = z.ZodType, Record>; + +type ToolConfig = { + description: string; + annotations: ToolAnnotations; + /** Tools which take no arguments declare an empty object, so that they too reject unknown keys */ + inputSchema: Schema; +}; + +export type ToolDefinition = { + name: string; + config: ToolConfig; + handler: (args: never) => Promise; +}; + +/** + * Declares a tool, tying the handler argument to the input schema. + * The generic is what keeps each entry of a heterogeneous tool array individually typed. + */ +export function defineTool( + name: string, + config: ToolConfig, + handler: (args: z.output) => Promise, +): ToolDefinition { + return { name, config, handler } as ToolDefinition; +} + +/** + * Converts a tool input schema to the JSON Schema advertised in tools/list. + * + * `io: 'input'` is required: schemas which sanitise their values with `.transform()` + * have an output type the agent must not be shown, and output mode cannot represent them. + */ +function toInputSchema(schema: ToolInput): ListToolsResult['tools'][number]['inputSchema'] { + // `$schema` is metadata about the document, not part of the tool contract + const { $schema: _$schema, ...jsonSchema } = z.toJSONSchema(schema, { + io: 'input', + target: 'draft-7', + unrepresentable: 'any', + reused: 'inline', + }); + + return jsonSchema as ListToolsResult['tools'][number]['inputSchema']; +} + +/** + * Builds the tools/list payload. + * Call once at module scope: a new Server is created for every MCP request. + */ +export function buildToolList(tools: readonly ToolDefinition[]): ListToolsResult['tools'] { + return tools.map(({ name, config }) => ({ + name, + description: config.description, + annotations: config.annotations, + inputSchema: toInputSchema(config.inputSchema), + })); +} + +/** + * Flattens validation issues into a single line an agent can act on, eg. + * `id: Invalid input: expected string, received undefined; timeStart: ...` + */ +function formatIssues(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join('; '); +} + +export function makeToolRegistry(tools: readonly ToolDefinition[]) { + const byName = new Map(tools.map((tool) => [tool.name, tool])); + + /** + * Validates the arguments of a tool call and runs its handler. + * + * Malformed calls are protocol errors (as they are in the SDK's own registerTool path), + * while failures from the services are returned as tool errors so that the agent can recover. + */ + return async function dispatchToolCall(name: string, args: Record): Promise { + const tool = byName.get(name); + if (!tool) { + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); + } + + const result = tool.config.inputSchema.safeParse(args); + if (!result.success) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: ${formatIssues(result.error)}`); + } + + try { + return await tool.handler(result.data as never); + } catch (error) { + return err(error); + } + }; +} + +// ---- Response helpers ---- + +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: e instanceof Error ? e.message : String(e) }) }], + isError: true, +}); diff --git a/apps/server/src/api-mcp/mcp.schema.ts b/apps/server/src/api-mcp/mcp.schema.ts index a3303cfd8..e00ebcbdf 100644 --- a/apps/server/src/api-mcp/mcp.schema.ts +++ b/apps/server/src/api-mcp/mcp.schema.ts @@ -9,70 +9,77 @@ * Keep this file concise and update it when MCP-exposed fields change. */ -// ---- Shared event field JSON schemas ---- -// Imported by mcp.tools.ts and spread into tool inputSchema.properties. +import { EndAction, TimerType, TimeStrategy } from 'ontime-types'; +import { z } from 'zod'; + +// ---- Shared event field schemas ---- +// Imported by mcp.tools.ts and spread into the tool input schemas. +// The JSON Schema advertised to agents is generated from these, so the description +// of a field is written once and cannot drift from what is enforced at runtime. export 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: + timerType: z + .enum(TimerType) + .optional() + .describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'), + endAction: z + .enum(EndAction) + .optional() + .describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'), + linkStart: z + .boolean() + .optional() + .describe( "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: + ), + countToEnd: z + .boolean() + .optional() + .describe( 'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.', - }, - timeStrategy: { - type: 'string', - enum: ['lock-duration', 'lock-end'], - description: + ), + timeStrategy: z + .enum(TimeStrategy) + .optional() + .describe( '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; + ), + timeWarning: z.number().optional().describe('ms before timeEnd to enter warning state (e.g. 300000 = 5 min)'), + timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'), +}; export 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: + cue: z.string().optional().describe('Short free-form cue label — ask the user what naming convention they prefer'), + title: z.string().optional().describe('Event title shown in the rundown and views'), + note: z.string().optional().describe('Free-text note for production notes or references'), + colour: z + .string() + .optional() + .describe( 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches', - }, - skip: { type: 'boolean', description: 'If true, event is skipped during playback' }, - flag: { - type: 'boolean', - description: 'Mark the event as a critical operational marker — use sparingly for maximum impact', - }, - custom: { - type: 'object', - additionalProperties: { type: 'string' }, - description: + ), + skip: z.boolean().optional().describe('If true, event is skipped during playback'), + flag: z + .boolean() + .optional() + .describe('Mark the event as a critical operational marker — use sparingly for maximum impact'), + custom: z + .record(z.string(), z.string()) + .optional() + .describe( 'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.', - }, + ), ...EVENT_TIMER_FIELDS, -} as const; +}; export const RUNDOWN_TARGET_FIELD = { - rundownId: { - type: 'string', - description: + rundownId: z + .string() + .optional() + .describe( '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 diff --git a/apps/server/src/api-mcp/mcp.server.ts b/apps/server/src/api-mcp/mcp.server.ts index 953258d48..d92923e3f 100644 --- a/apps/server/src/api-mcp/mcp.server.ts +++ b/apps/server/src/api-mcp/mcp.server.ts @@ -12,7 +12,7 @@ import { import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js'; import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js'; -import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js'; +import { TOOL_LIST, handleToolCall } from './mcp.tools.js'; export function createMcpServer(): Server { const server = new Server( @@ -20,12 +20,7 @@ export function createMcpServer(): Server { { capabilities: { tools: {}, prompts: {}, resources: {} } }, ); - server.setRequestHandler( - ListToolsRequestSchema, - async (): Promise => ({ - tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'], - }), - ); + server.setRequestHandler(ListToolsRequestSchema, async (): Promise => ({ tools: TOOL_LIST })); server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { const { name, arguments: args = {} } = request.params; diff --git a/apps/server/src/api-mcp/mcp.tools.ts b/apps/server/src/api-mcp/mcp.tools.ts index 7568cc3c8..abdc2d41a 100644 --- a/apps/server/src/api-mcp/mcp.tools.ts +++ b/apps/server/src/api-mcp/mcp.tools.ts @@ -1,5 +1,6 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { EntryId, ProjectData } from 'ontime-types'; +import { ProjectData, SupportedEntry } from 'ontime-types'; +import sanitize from 'sanitize-filename'; +import { z } from 'zod'; import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js'; import { getProjectCustomFields, getRundownMetadata } from '../api-data/rundown/rundown.dao.js'; @@ -21,6 +22,8 @@ import { renameProjectFile, } from '../services/project-service/ProjectService.js'; import { getState } from '../stores/runtimeState.js'; +import { ensureJsonExtension } from '../utils/fileManagement.js'; +import { buildToolList, defineTool, err, makeToolRegistry, ok } from './mcp.registry.js'; import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js'; import { batchCreateEntriesForMcp, @@ -37,13 +40,6 @@ import { ungroupEntryForMcp, updateCustomFieldForMcp, updateEntryForMcp, - type BatchCreateEntryArgs, - type CreateEntryArgs, - type EntryFieldArgs, - type GroupEntriesArgs, - type TargetRundownArgs, - type UngroupEntryArgs, - type UpdateEntryArgs, } from './mcp.service.js'; // Graceful truncation to keep tool responses within typical MCP context windows @@ -61,664 +57,570 @@ const WRITE_DESTRUCTIVE = { openWorldHint: false, } as const; -// ---- Tool definitions ---- -export const TOOL_DEFINITIONS = [ - // --- Rundown read --- - { - name: 'ontime_get_rundown', - description: - '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, - }, - { - 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_entry', - 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' }, - }, - }, - annotations: READ, - }, - // --- Rundown mutations --- - { - name: 'ontime_create_entry', - description: - 'Create a new entry. Omit after/before to append at the end, use after: true to explicitly append, use before: true to prepend, or use before/after with an entry ID to position the 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. 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 plus optional note/colour/custom/targetDuration.', - inputSchema: { - type: 'object', - properties: { - ...RUNDOWN_TARGET_FIELD, - type: { - type: 'string', - enum: ['event', 'delay', 'milestone', 'group'], - description: - 'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries', - }, - timeStart: { type: 'number', description: 'Event start time in ms from midnight (e.g. 09:00 = 32400000)' }, - timeEnd: { type: 'number', description: 'Event end time in ms from midnight' }, - duration: { - type: 'number', - description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)', - }, - targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' }, - after: { - type: ['string', 'boolean'], - description: 'Insert after this entry ID, or true to append', - }, - before: { - type: ['string', 'boolean'], - description: 'Insert before this entry ID, or true to prepend', - }, - ...EVENT_WRITABLE_FIELDS, - }, - }, - annotations: WRITE, - }, - { - name: 'ontime_update_entry', - description: - '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' }, - duration: { type: 'number', description: 'Duration in ms' }, - targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' }, - ...EVENT_WRITABLE_FIELDS, - }, - }, - annotations: WRITE_DESTRUCTIVE, - }, - { - name: 'ontime_delete_entries', - 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' }, - }, - }, - annotations: WRITE_DESTRUCTIVE, - }, - { - name: 'ontime_reorder_entry', - description: - '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 for targeted moves into a group. For grouping several existing top-level entries, prefer ontime_group_entries.', - 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: { - type: 'string', - enum: ['before', 'after', 'insert'], - description: 'before/after: place as sibling; insert: place inside a group', - }, - }, - }, - annotations: WRITE_IDEM, - }, - { - name: 'ontime_group_entries', - description: - 'Create a group from existing top-level 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. Entries must be existing top-level non-group entries; groups cannot be nested. Optional title, note, colour, custom, and targetDuration are applied to the created group.', - inputSchema: { - type: 'object', - required: ['ids'], - properties: { - ...RUNDOWN_TARGET_FIELD, - ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' }, - title: { type: 'string', description: 'Group title shown in the rundown and views' }, - note: { type: 'string', description: 'Free-text group note for production notes or references' }, - colour: { - type: 'string', - description: - 'Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide', - }, - custom: { - type: 'object', - additionalProperties: { type: 'string' }, - description: 'Custom field values keyed by existing project field key', - }, - targetDuration: { type: 'number', description: 'Planned length of the group in ms' }, - }, - }, - annotations: WRITE, - }, - { - name: 'ontime_ungroup_entry', - description: - 'Dissolve a group by moving its children to the top level where the group was. 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.', - inputSchema: { - type: 'object', - required: ['id'], - properties: { - ...RUNDOWN_TARGET_FIELD, - id: { type: 'string', description: 'Group entry ID to dissolve' }, - }, - }, - annotations: WRITE_DESTRUCTIVE, - }, - { - name: 'ontime_batch_create_entries', - description: - 'Create multiple entries, including groups with nested children. 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; omit after/before to append the first entry at the end, use after: true to explicitly append, use before: true to prepend, or use after/before with an entry ID to position the first top-level entry. Subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. 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', 'boolean'], - description: 'Insert the first entry after this entry ID, or true to append', - }, - before: { - type: ['string', 'boolean'], - description: 'Insert the first entry before this entry ID, or true to prepend', - }, - entries: { - type: 'array', - description: 'Array of entries to create, in desired order', - items: { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['event', 'delay', 'milestone', 'group'], - description: 'Entry type, defaults to event', - }, - timeStart: { type: 'number', description: 'Event start time in ms from midnight' }, - timeEnd: { type: 'number', description: 'Event end time in ms from midnight' }, - duration: { type: 'number', description: 'Duration in ms' }, - targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' }, - children: { - type: 'array', - description: - 'For group entries only: child events, milestones, or delays to create inside this group in order. Nested groups are not supported.', - items: { type: 'object' }, - }, - ...EVENT_WRITABLE_FIELDS, - }, - }, - }, - }, - }, - annotations: WRITE, - }, - { - name: 'ontime_batch_update_entries', - description: - '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', - description: 'Partial entry 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' }, - targetDuration: { type: 'number', description: 'Groups only: planned length of the group 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. 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'], - 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 entry.custom[key].', - inputSchema: { type: 'object', properties: {} }, - annotations: READ, - }, - { - name: 'ontime_create_custom_field', - description: - 'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). Creation is non-destructive — check ontime_get_custom_fields for an existing field covering the concept, and if none exists create directly without asking the user. After creation, use the returned key in entry.custom.', - inputSchema: { - type: 'object', - required: ['label', 'type', 'colour'], - properties: { - label: { - type: 'string', - description: - 'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".', - }, - type: { - type: 'string', - enum: ['text', 'image'], - description: - 'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.', - }, - colour: { - type: 'string', - description: - 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet — for department fields, match the department colour convention (see ontime://style-guide).', - }, - }, - }, - annotations: WRITE, - }, - { - name: 'ontime_update_custom_field', - description: - 'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.', - inputSchema: { - type: 'object', - required: ['key'], - properties: { - key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' }, - label: { - type: 'string', - description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.', - }, - colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' }, - }, - }, - annotations: WRITE_IDEM, - }, - { - name: 'ontime_delete_custom_field', - description: - 'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.', - inputSchema: { - type: 'object', - required: ['key'], - properties: { - key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' }, - }, - }, - annotations: WRITE_DESTRUCTIVE, - }, - // --- 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 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'], - properties: { filename: { type: 'string', description: 'Project filename, e.g. "my-show.json"' } }, - }, - annotations: WRITE_DESTRUCTIVE, - }, - { - name: 'ontime_create_project', - description: - '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'], - 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_DESTRUCTIVE, - }, - { - 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; +// ---- Shared field schemas ---- -type ToolName = (typeof TOOL_DEFINITIONS)[number]['name']; +/** Tools which take no arguments still reject anything an agent sends them */ +const NO_ARGUMENTS = z.strictObject({}); -type ProjectInfoArgs = Partial>; - -// ---- 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: e instanceof Error ? e.message : String(e) }) }], - isError: true, -}); - -// ---- 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 (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.`, - truncated: true, - rundownId: rundown.id, - order: rundown.order, - }); - } - return ok({ rundownId: rundown.id, ...data }); - }, - - ontime_get_rundown_metadata: async () => ok(getRundownMetadata()), - - ontime_get_entry: async (args) => { - 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) => { - return ok(await createEntryForMcp(args as CreateEntryArgs)); - }, - - ontime_update_entry: async (args) => { - return ok(await updateEntryForMcp(args as UpdateEntryArgs)); - }, - - ontime_delete_entries: async (args) => { - return ok(await deleteEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[] })); - }, - - ontime_reorder_entry: async (args) => { - return ok( - await reorderEntryForMcp( - args as TargetRundownArgs & { - entryId: EntryId; - destinationId: EntryId; - order: 'before' | 'after' | 'insert'; - }, - ), - ); - }, - - ontime_group_entries: async (args) => { - return ok(await groupEntriesForMcp(args as GroupEntriesArgs)); - }, - - ontime_ungroup_entry: async (args) => { - return ok(await ungroupEntryForMcp(args as UngroupEntryArgs)); - }, - - ontime_batch_create_entries: async (args) => { - return ok( - await batchCreateEntriesForMcp( - args as TargetRundownArgs & { - entries: BatchCreateEntryArgs[]; - after?: EntryId | true; - before?: EntryId | true; - }, - ), - ); - }, - - ontime_batch_update_entries: async (args) => { - 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 ok(toRundownList(await createNewRundown(title))); - }, - - ontime_load_rundown: async (args) => { - const { id } = args as { id: string }; - return ok(toRundownList(await loadRundown(id))); - }, - - ontime_rename_rundown: async (args) => { - const { id, title } = args as { id: string; title: string }; - return ok(toRundownList(await renameRundown(id, title))); - }, - - ontime_delete_rundown: async (args) => { - const { id } = args as { id: string }; - return ok(toRundownList(await deleteRundown(id))); - }, - - ontime_duplicate_rundown: async (args) => { - const { id } = args as { id: string }; - return ok(toRundownList(await duplicateExistingRundown(id))); - }, - - ontime_get_timer_state: async () => { - const { clock, timer, eventNow, eventNext, offset } = getState(); - return ok({ clock, timer, eventNow, eventNext, offset }); - }, - - ontime_get_project_info: async () => ok(getProjectData()), - - ontime_update_project_info: async (args) => { - const updated = await editCurrentProjectData(args as ProjectInfoArgs); - return ok(updated); - }, - - ontime_get_custom_fields: async () => ok(getProjectCustomFields()), - - ontime_create_custom_field: async (args) => { - return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string })); - }, - - ontime_update_custom_field: async (args) => { - return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string })); - }, - - ontime_delete_custom_field: async (args) => { - return ok(await deleteCustomFieldForMcp(args as { key: string })); - }, - - ontime_list_projects: async () => ok(await getProjectList()), - - ontime_load_project: async (args) => { - const { filename } = args as { filename: string }; - await loadProjectFile(filename); - return ok(await getProjectList()); - }, - - ontime_create_project: async (args) => { - const { - filename, - title = '', - description = '', - } = args as { - filename: string; - title?: string; - description?: string; - }; - const project: ProjectData = { ...makeNewProject().project, title, description }; - const newFileName = await createProjectWithPatch(filename, { project }); - return ok({ filename: newFileName }); - }, - - ontime_rename_project: async (args) => { - const { filename, newFilename } = args as { filename: string; newFilename: string }; - await renameProjectFile(filename, newFilename); - return ok(await getProjectList()); - }, - - ontime_duplicate_project: async (args) => { - const { filename, newFilename } = args as { filename: string; newFilename: string }; - await duplicateProjectFile(filename, newFilename); - return ok(await getProjectList()); - }, - - ontime_delete_project: async (args) => { - const { filename } = args as { filename: string }; - await deleteProjectFile(filename); - return ok(await getProjectList()); - }, +const ENTRY_TIME_FIELDS = { + timeStart: z.number().optional().describe('Event start time in ms from midnight (e.g. 09:00 = 32400000)'), + timeEnd: z.number().optional().describe('Event end time in ms from midnight'), + duration: z + .number() + .optional() + .describe('Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)'), + targetDuration: z.number().optional().describe('Groups only: planned length of the group in ms'), }; -// ---- Tool call dispatcher ---- -export async function handleToolCall(name: string, args: Record): Promise { - const handler = TOOL_HANDLERS[name as ToolName]; - if (!handler) { - return err(`Unknown tool: ${name}`); - } - try { - return await handler(args); - } catch (error) { - return err(error); - } -} +const ENTRY_TYPE_FIELD = z + .enum(SupportedEntry) + .optional() + .describe( + 'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries', + ); + +/** Insert anchors accept an entry ID, or `true` to append / prepend */ +const insertAnchor = (description: string) => + z + .union([z.string(), z.literal(true)]) + .optional() + .describe(description); + +const GROUP_FIELDS = { + title: z.string().optional().describe('Group title shown in the rundown and views'), + note: z.string().optional().describe('Free-text group note for production notes or references'), + colour: z + .string() + .optional() + .describe('Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide'), + custom: z + .record(z.string(), z.string()) + .optional() + .describe('Custom field values keyed by existing project field key'), + targetDuration: z.number().optional().describe('Planned length of the group in ms'), +}; + +/** + * Project filenames reach the filesystem through `join(projectsDir, name)`, so they are + * confined here the same way the HTTP routes confine them in db.validation.ts. + */ +const projectFilename = (description: string) => + z + .string() + .trim() + .min(1) + .transform((filename) => ensureJsonExtension(sanitize(filename))) + .refine((filename) => filename.length > 1, 'Filename is empty once sanitised') + .describe(description); + +/** ontime_create_project takes a name without extension: ProjectService appends it */ +const newProjectFilename = z + .string() + .trim() + .min(1) + .transform((filename) => sanitize(filename)) + .refine((filename) => filename.length > 0, 'Filename is empty once sanitised') + .describe('Filename without extension, e.g. "my-show"'); + +// Batch creation is modelled two levels deep rather than recursively: groups cannot be +// nested, so a child can never carry children of its own. This keeps the generated JSON +// Schema free of $ref/$defs, which some MCP clients handle poorly. +const batchChildEntry = z.strictObject({ + type: z + .enum([SupportedEntry.Event, SupportedEntry.Delay, SupportedEntry.Milestone]) + .optional() + .describe('Entry type, defaults to event. Nested groups are not supported'), + ...ENTRY_TIME_FIELDS, + ...EVENT_WRITABLE_FIELDS, +}); + +const batchEntry = z.strictObject({ + type: ENTRY_TYPE_FIELD, + ...ENTRY_TIME_FIELDS, + children: z + .array(batchChildEntry) + .optional() + .describe( + 'For group entries only: child events, milestones, or delays to create inside this group in order. Nested groups are not supported.', + ), + ...EVENT_WRITABLE_FIELDS, +}); + +// ---- Tool definitions ---- +// Each tool declares its input schema next to the handler that consumes it. Handlers are +// thin translation wrappers: they map validated arguments to a call into an existing +// service and format the response. Business logic belongs in the services. +export const TOOLS = [ + // --- Rundown read --- + defineTool( + 'ontime_get_rundown', + { + description: + '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: z.strictObject({ ...RUNDOWN_TARGET_FIELD }), + annotations: READ, + }, + async (args) => { + const rundown = getRundownById(args.rundownId); + const data = { order: rundown.order, entries: rundown.entries }; + const serialised = JSON.stringify(data); + if (serialised.length > CHARACTER_LIMIT) { + return ok({ + warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry.`, + truncated: true, + rundownId: rundown.id, + order: rundown.order, + }); + } + return ok({ rundownId: rundown.id, ...data }); + }, + ), + + defineTool( + '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: NO_ARGUMENTS, + annotations: READ, + }, + async () => ok(getRundownMetadata()), + ), + + defineTool( + 'ontime_get_entry', + { + 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: z + .strictObject({ + ...RUNDOWN_TARGET_FIELD, + id: z.string().optional().describe('Entry ID (from rundown.entries key or entry.id)'), + cue: z.string().optional().describe('Human-facing cue label'), + }) + .refine((args) => args.id !== undefined || args.cue !== undefined, 'Provide id or cue'), + annotations: READ, + }, + async (args) => { + const entry = findEntry(args); + if (entry) return ok(entry); + if (args.id) return err(`No entry with id ${args.id}`); + return err(`No entry with cue ${args.cue}`); + }, + ), + + // --- Rundown mutations --- + defineTool( + 'ontime_create_entry', + { + description: + 'Create a new entry. Omit after/before to append at the end, use after: true to explicitly append, use before: true to prepend, or use before/after with an entry ID to position the 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. 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 plus optional note/colour/custom/targetDuration.', + inputSchema: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + type: ENTRY_TYPE_FIELD, + ...ENTRY_TIME_FIELDS, + after: insertAnchor('Insert after this entry ID, or true to append'), + before: insertAnchor('Insert before this entry ID, or true to prepend'), + ...EVENT_WRITABLE_FIELDS, + }), + annotations: WRITE, + }, + async (args) => ok(await createEntryForMcp(args)), + ), + + defineTool( + 'ontime_update_entry', + { + description: + '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: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + id: z.string().describe('ID of the entry to update'), + ...ENTRY_TIME_FIELDS, + ...EVENT_WRITABLE_FIELDS, + }), + annotations: WRITE_DESTRUCTIVE, + }, + async (args) => ok(await updateEntryForMcp(args)), + ), + + defineTool( + 'ontime_delete_entries', + { + 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: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + ids: z.array(z.string()).describe('Array of entry IDs to delete'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async (args) => ok(await deleteEntriesForMcp(args)), + ), + + defineTool( + 'ontime_reorder_entry', + { + description: + '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 for targeted moves into a group. For grouping several existing top-level entries, prefer ontime_group_entries.', + inputSchema: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + entryId: z.string().describe('ID of the entry to move'), + destinationId: z.string().describe('ID of the target entry (sibling or parent group)'), + order: z + .enum(['before', 'after', 'insert']) + .describe('before/after: place as sibling; insert: place inside a group'), + }), + annotations: WRITE_IDEM, + }, + async (args) => ok(await reorderEntryForMcp(args)), + ), + + defineTool( + 'ontime_group_entries', + { + description: + 'Create a group from existing top-level 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. Entries must be existing top-level non-group entries; groups cannot be nested. Optional title, note, colour, custom, and targetDuration are applied to the created group.', + inputSchema: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + ids: z + .array(z.string()) + .nonempty('Provide at least one entry ID to group.') + .describe('Existing top-level entry IDs to group'), + ...GROUP_FIELDS, + }), + annotations: WRITE, + }, + async (args) => ok(await groupEntriesForMcp(args)), + ), + + defineTool( + 'ontime_ungroup_entry', + { + description: + 'Dissolve a group by moving its children to the top level where the group was. 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.', + inputSchema: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + id: z.string().describe('Group entry ID to dissolve'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async (args) => ok(await ungroupEntryForMcp(args)), + ), + + defineTool( + 'ontime_batch_create_entries', + { + description: + 'Create multiple entries, including groups with nested children. 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; omit after/before to append the first entry at the end, use after: true to explicitly append, use before: true to prepend, or use after/before with an entry ID to position the first top-level entry. Subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. 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: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + after: insertAnchor('Insert the first entry after this entry ID, or true to append'), + before: insertAnchor('Insert the first entry before this entry ID, or true to prepend'), + entries: z + .array( + batchEntry.refine( + (entry) => !entry.children?.length || entry.type === SupportedEntry.Group, + 'Only group entries can have children.', + ), + ) + .describe('Array of entries to create, in desired order'), + }), + annotations: WRITE, + }, + async (args) => ok(await batchCreateEntriesForMcp(args)), + ), + + defineTool( + 'ontime_batch_update_entries', + { + description: + '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: z.strictObject({ + ...RUNDOWN_TARGET_FIELD, + ids: z.array(z.string()).describe('Array of entry IDs to update'), + data: z + .strictObject({ ...ENTRY_TIME_FIELDS, ...EVENT_WRITABLE_FIELDS }) + .describe('Partial entry fields to apply to every ID'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async (args) => ok(await batchUpdateEntriesForMcp(args)), + ), + + // --- Rundown management --- + defineTool( + '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: NO_ARGUMENTS, + annotations: READ, + }, + async () => ok(toRundownList(getDataProvider().getProjectRundowns())), + ), + + defineTool( + '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: z.strictObject({ title: z.string().describe('Title for the new rundown') }), + annotations: WRITE, + }, + async ({ title }) => ok(toRundownList(await createNewRundown(title))), + ), + + defineTool( + 'ontime_load_rundown', + { + description: + '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: z.strictObject({ id: z.string().describe('Rundown ID to load') }), + annotations: WRITE_DESTRUCTIVE, + }, + async ({ id }) => ok(toRundownList(await loadRundown(id))), + ), + + defineTool( + 'ontime_rename_rundown', + { + description: 'Rename an existing rundown', + inputSchema: z.strictObject({ + id: z.string().describe('Rundown ID to rename'), + title: z.string().describe('New title'), + }), + annotations: WRITE_IDEM, + }, + async ({ id, title }) => ok(toRundownList(await renameRundown(id, title))), + ), + + defineTool( + 'ontime_delete_rundown', + { + description: 'Delete a rundown (cannot delete the currently loaded rundown or the last remaining rundown)', + inputSchema: z.strictObject({ id: z.string().describe('Rundown ID to delete') }), + annotations: WRITE_DESTRUCTIVE, + }, + async ({ id }) => ok(toRundownList(await deleteRundown(id))), + ), + + defineTool( + 'ontime_duplicate_rundown', + { + description: 'Duplicate a rundown, creating a copy with a new ID. Does not switch to the copy.', + inputSchema: z.strictObject({ id: z.string().describe('Rundown ID to duplicate') }), + annotations: WRITE, + }, + async ({ id }) => ok(toRundownList(await duplicateExistingRundown(id))), + ), + + // --- Timer & project --- + defineTool( + '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: NO_ARGUMENTS, + annotations: READ, + }, + async () => { + const { clock, timer, eventNow, eventNext, offset } = getState(); + return ok({ clock, timer, eventNow, eventNext, offset }); + }, + ), + + defineTool( + 'ontime_get_project_info', + { + description: + 'Get current project metadata: title, description, url, info, logo, and custom header fields (array of { title, value, url }).', + inputSchema: NO_ARGUMENTS, + annotations: READ, + }, + async () => ok(getProjectData()), + ), + + defineTool( + 'ontime_update_project_info', + { + description: 'Update project metadata fields. All fields are optional — only provided fields are updated.', + inputSchema: z.strictObject({ + title: z.string().optional().describe('Project title'), + description: z.string().optional().describe('Project description'), + url: z.string().optional().describe('URL shown on viewer pages'), + info: z.string().optional().describe('Info text shown on viewer pages'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async (args) => ok(await editCurrentProjectData(args)), + ), + + defineTool( + 'ontime_get_custom_fields', + { + description: + 'Get the project custom field definitions. Returns { [key]: { label, type: "text"|"image", colour } }. Keys are referenced in entry.custom[key].', + inputSchema: NO_ARGUMENTS, + annotations: READ, + }, + async () => ok(getProjectCustomFields()), + ), + + defineTool( + 'ontime_create_custom_field', + { + description: + 'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). Creation is non-destructive — check ontime_get_custom_fields for an existing field covering the concept, and if none exists create directly without asking the user. After creation, use the returned key in entry.custom.', + inputSchema: z.strictObject({ + label: z + .string() + .describe( + 'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".', + ), + type: z + .enum(['text', 'image']) + .describe( + 'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.', + ), + colour: z + .string() + .describe( + 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet — for department fields, match the department colour convention (see ontime://style-guide).', + ), + }), + annotations: WRITE, + }, + async (args) => ok(await createCustomFieldForMcp(args)), + ), + + defineTool( + 'ontime_update_custom_field', + { + description: + 'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.', + inputSchema: z.strictObject({ + key: z.string().describe('Current field key (from ontime_get_custom_fields)'), + label: z + .string() + .optional() + .describe('New human-readable label (optional). Changes the derived key and cascades to all entries.'), + colour: z.string().optional().describe('New hex colour (#RRGGBB) (optional)'), + }), + annotations: WRITE_IDEM, + }, + async (args) => ok(await updateCustomFieldForMcp(args)), + ), + + defineTool( + 'ontime_delete_custom_field', + { + description: + 'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.', + inputSchema: z.strictObject({ + key: z.string().describe('Field key to delete (from ontime_get_custom_fields)'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async (args) => ok(await deleteCustomFieldForMcp(args)), + ), + + // --- Project file management --- + defineTool( + 'ontime_list_projects', + { + description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.', + inputSchema: NO_ARGUMENTS, + annotations: READ, + }, + async () => ok(await getProjectList()), + ), + + defineTool( + 'ontime_load_project', + { + description: + '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: z.strictObject({ + filename: projectFilename('Project filename, e.g. "my-show.json"'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async ({ filename }) => { + await loadProjectFile(filename); + return ok(await getProjectList()); + }, + ), + + defineTool( + 'ontime_create_project', + { + description: + '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: z.strictObject({ + filename: newProjectFilename, + title: z.string().optional().describe('Optional project title'), + description: z.string().optional().describe('Optional project description'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async ({ filename, title = '', description = '' }) => { + const project: ProjectData = { ...makeNewProject().project, title, description }; + const newFileName = await createProjectWithPatch(filename, { project }); + return ok({ filename: newFileName }); + }, + ), + + defineTool( + 'ontime_rename_project', + { + description: + 'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.', + inputSchema: z.strictObject({ + filename: projectFilename('Current filename (with .json extension)'), + newFilename: projectFilename('New filename (with .json extension)'), + }), + annotations: WRITE_IDEM, + }, + async ({ filename, newFilename }) => { + await renameProjectFile(filename, newFilename); + return ok(await getProjectList()); + }, + ), + + defineTool( + 'ontime_duplicate_project', + { + description: 'Duplicate a project file on disk with a new filename. Does not switch to the copy.', + inputSchema: z.strictObject({ + filename: projectFilename('Source filename to copy (with .json extension)'), + newFilename: projectFilename('Filename of the new copy (with .json extension)'), + }), + annotations: WRITE, + }, + async ({ filename, newFilename }) => { + await duplicateProjectFile(filename, newFilename); + return ok(await getProjectList()); + }, + ), + + defineTool( + 'ontime_delete_project', + { + description: 'Delete a project file from disk. Fails if the file is currently loaded.', + inputSchema: z.strictObject({ + filename: projectFilename('Project filename to delete (with .json extension)'), + }), + annotations: WRITE_DESTRUCTIVE, + }, + async ({ filename }) => { + await deleteProjectFile(filename); + return ok(await getProjectList()); + }, + ), +]; + +/** Generated once: a new Server instance is created for every MCP request */ +export const TOOL_LIST = buildToolList(TOOLS); + +export const handleToolCall = makeToolRegistry(TOOLS);