Compare commits

..

5 Commits

Author SHA1 Message Date
Claude e29a019376 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
2026-08-12 05:28:22 +00:00
Claude ae16491599 chore(server): add zod as a direct dependency
Zod 4.4.3 is already resolved in the lockfile as a transitive dependency of
@modelcontextprotocol/sdk, but pnpm's strict node_modules will not resolve it
from apps/server without an explicit entry. Pinned to the resolved version so
the server and the SDK share a single instance.

Version >= 4.2.0 is also what the SDK v2 upgrade will require, since v2 takes
Standard Schema objects for tool schemas and drops zod v3 support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
2026-08-12 05:28:08 +00:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
19 changed files with 1240 additions and 853 deletions
@@ -200,8 +200,7 @@ $card-padding: 2rem;
.overlay {
position: absolute;
z-index: $zindex-backdrop;
width: 100%;
height: 100%;
inset: 0;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() {
const { data, isError } = useAppVersion();
@@ -18,7 +20,12 @@ export default function AppVersion() {
return (
<Panel.ListItem>
<Panel.Field
title={`Ontime ${appVersion}`}
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
@@ -26,7 +33,7 @@ export default function AppVersion() {
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)}
</Panel.ListItem>
);
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)}
+13 -1
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [
{
label: 'New project...',
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
click: () => redirectWindow('/editor?settings=project__create'),
},
{
label: 'Load...',
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
label: 'View settings',
click: () => redirectWindow('/editor?settings=settings__view'),
},
{
label: 'Custom views',
click: () => redirectWindow('/editor?settings=settings__custom-views'),
},
{
label: 'MCP Server',
click: () => redirectWindow('/editor?settings=settings__mcp'),
},
{
label: 'Server port',
click: () => redirectWindow('/editor?settings=settings__port'),
},
],
},
{
+2 -1
View File
@@ -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' });
});
});
+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
@@ -1,7 +1,6 @@
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
@@ -54,12 +53,11 @@ describe('getElapsed()', () => {
it('uses the current pause start while paused', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
@@ -977,40 +975,6 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25);
});
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
dayOffset: 0,
},
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down
addedTime: 0,
},
_timer: {
pausedAt: timeCore.toInstant(
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
(timeCore.now() - dayInMs) as Instant,
), // 23:58, before midnight
pausedDuration: 0,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
});
it('offset doesnt exist if we havent started', () => {
const state = {
clock: 78480789,
+5 -7
View File
@@ -1,7 +1,6 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js';
import type { RuntimeState } from '../stores/runtimeState.js';
/**
@@ -97,18 +96,17 @@ export function getCurrent(state: RuntimeState): number {
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock, _now } = state;
const { clock } = state;
const { startedAt } = state.timer;
const { pausedDuration, pausedAt } = state._timer;
const { pausedAt, pausedDuration } = state._timer;
if (startedAt === null) {
return null;
}
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed);
}
@@ -1,11 +1,10 @@
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0 as TimeOfDay,
_now: 0 as Instant,
eventNow: null,
eventNext: null,
eventFlag: null,
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
playback: Playback.Pause,
addedTime: 0,
});
expect(newState._timer.pausedAt).toEqual(newState._now);
expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause();
expect(success).toBe(false);
@@ -248,59 +248,6 @@ describe('mutation on runtimeState', () => {
state = getState();
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
});
test('elapsed excludes a pause that spans midnight', async () => {
clearState();
// an event that runs over midnight (23:00 -> 01:00)
const event = {
...mockEvent,
id: 'elapsed-pause-midnight',
timeStart: 23 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
};
const mockRundown = makeRundown({
entries: { [event.id]: event },
order: [event.id],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start before midnight
vi.setSystemTime('jan 1 23:50');
load(event, rundown, metadata);
start();
// 8 minutes of active running before we pause
vi.setSystemTime('jan 1 23:58');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
pause();
// elapsed is active time since start, so it must not advance while paused,
// not even when the pause itself crosses midnight
vi.setSystemTime('jan 2 00:01');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
vi.setSystemTime('jan 2 00:03');
start();
let state = getState();
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
// and elapsed still reflects only the 8 active minutes
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// 2 more active minutes after resume -> 10 minutes elapsed
vi.setSystemTime('jan 2 00:05');
update();
state = getState();
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
});
});
test('runtime offset', async () => {
+16 -21
View File
@@ -63,9 +63,7 @@ export type RuntimeState = {
// private properties of the timer calculations
_timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<Instant>;
/** Accumulate pause duration but dose not include the current pause */
pausedAt: Maybe<TimeOfDay>;
pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
@@ -78,12 +76,10 @@ export type RuntimeState = {
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
_now: Instant;
};
const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(),
_now: timeCore.now(),
groupNow: null,
eventNow: null,
eventNext: null,
@@ -108,12 +104,6 @@ const runtimeState: RuntimeState = {
_startDayOffset: null,
};
/** set the current clock to ensure parity between _now and clock */
function setClock(state: RuntimeState) {
state._now = timeCore.now();
state.clock = timeCore.toTimeOfDay(state._now);
}
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
@@ -146,7 +136,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState);
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -179,7 +169,7 @@ export function clearState() {
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState);
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -432,12 +422,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false;
}
setClock(state);
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null;
// add paused time if it exists
if (state._timer.pausedAt) {
const timeToAdd = state._now - state._timer.pausedAt;
const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null;
@@ -454,7 +447,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = state._now;
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
}
@@ -488,8 +481,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
}
state.timer.playback = Playback.Pause;
setClock(state);
state._timer.pausedAt = state._now;
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
return true;
}
@@ -554,7 +547,9 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
setClock(runtimeState); // we update the clock on every update call
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -563,13 +558,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
}
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}
+3
View File
@@ -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