mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 19:03:47 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e29a019376 | |||
| ae16491599 |
@@ -22,7 +22,8 @@
|
||||
"osc-min": "2.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
Generated
+3
@@ -275,6 +275,9 @@ importers:
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@types/cookie-parser':
|
||||
specifier: 1.4.10
|
||||
|
||||
Reference in New Issue
Block a user