feat(mcp): validate tool arguments with zod

The MCP endpoint was the only route accepting a body without validation. Its
JSON Schema tool declarations were advertisement only: the low-level SDK Server
validates the JSON-RPC envelope but never the arguments of a tool call, and
every handler cast them with `args as SomeType`.

Tools are now declared with `defineTool(name, config, handler)`, the argument
shape `registerTool` takes, and the JSON Schema advertised in tools/list is
generated from the same zod schema that parses incoming arguments, so the two
cannot drift. Handlers receive inferred arguments and pass them to the existing
services, whose argument types are derived from ontime-types — which means a
schema that drifts from the domain model now fails typecheck.

mcp.registry.ts is the only module aware of the SDK internals. Migrating to the
SDK v2 registerTool API means replacing its two functions with a registration
loop, leaving the tool declarations untouched.

Malformed calls are now protocol errors, matching what the SDK does on the
registerTool path and what v2 will do. Failures raised by the services are
still returned as tool errors so agents can read them and recover.

Fixes three bugs the missing validation allowed:

- Project filenames reached `join(projectsDir, name)` unsanitised, so
  `../canary.json` resolved outside the projects directory. They are now
  confined with the same sanitize-filename + ensureJsonExtension pair the HTTP
  routes use in db.validation.ts.
- ontime_update_project_info forwarded its whole argument object into
  setProjectData, which spreads it into the stored project; an undeclared
  `logo` key also caused the current logo file to be deleted. Only declared
  fields reach it now.
- Batch creation declared nested entries as a bare object. They are modelled
  two levels deep, which encodes that groups cannot be nested and keeps the
  generated schema free of $ref/$defs.

Unknown keys are rejected rather than stripped, so an agent gets an actionable
error instead of a write that silently does less than it reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
This commit is contained in:
Claude
2026-08-12 05:28:22 +00:00
parent ae16491599
commit e29a019376
7 changed files with 1179 additions and 724 deletions
@@ -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);
});
});
@@ -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",
}
`);
});
});
@@ -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<string, unknown>): Record<string, unknown> {
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, unknown>): 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' });
});
});
+135
View File
@@ -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<string, unknown>, Record<string, unknown>>;
type ToolConfig<Schema extends ToolInput> = {
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<ToolInput>;
handler: (args: never) => Promise<CallToolResult>;
};
/**
* 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<const Schema extends ToolInput>(
name: string,
config: ToolConfig<Schema>,
handler: (args: z.output<Schema>) => Promise<CallToolResult>,
): 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<string, unknown>): Promise<CallToolResult> {
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,
});
+58 -51
View File
@@ -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
+2 -7
View File
@@ -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<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({ tools: TOOL_LIST }));
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
File diff suppressed because it is too large Load Diff