improve linting and documentation

This commit is contained in:
Carlos Valente
2026-06-13 12:54:22 +02:00
parent 1e851ae40d
commit 9f40d962ea
6 changed files with 100 additions and 42 deletions
+21 -12
View File
@@ -55,7 +55,14 @@ Entry type guidance:
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
- Use \`milestone\` for non-timed markers that don't advance playback (e.g. "Doors open", "Broadcast start").
- Use \`delay\` only when the user explicitly wants to model schedule drift that shifts all following events.
- Use \`group\` to collect related events into a named block. Groups are created with a title only — use ontime_update_entry afterwards to set \`targetDuration\` to the block's planned length.
- Use \`group\` to collect related events into a named block. Groups are created with a title only — use ontime_update_entry afterwards to set \`colour\`, \`note\`, \`custom\`, or \`targetDuration\`.
Event timing:
- Provide a title plus enough timing data for Ontime to infer a timing strategy.
- \`timeStart\` + \`duration\`: keeps duration fixed and calculates \`timeEnd\`.
- \`timeStart\` + \`timeEnd\`: keeps end time fixed and calculates \`duration\`.
- \`timeEnd\` + \`duration\`: calculates \`timeStart\`.
- Avoid sending \`timeStart\`, \`timeEnd\`, and \`duration\` together unless you intentionally want Ontime to prioritise duration and recalculate \`timeEnd\`.
Timer type (timerType):
- \`count-down\` (default): counts down from duration. Use for most timed sessions.
@@ -69,9 +76,10 @@ End action (endAction):
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
Linking (linkStart):
- Set \`linkStart: true\` on events that must always follow directly after the previous event's end.
- Changing the first linked event's timeStart or duration cascades to all linked followers.
- Ideal for segments within a block where only the block's start time is managed directly.
- \`linkStart\` controls schedule-change propagation through the rundown.
- When an event is linked, it inherits the end time of the previous playable event as its start time.
- The event's \`timeStrategy\` decides how it adapts to the inherited start: lock duration updates the end time; lock end updates the duration.
- Ideal for segments within a block where only the anchor start time and individual durations are managed directly.
Flags (flag):
- Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals).
@@ -85,6 +93,7 @@ Colours:
Custom fields (custom):
- Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker).
- Store values per entry at \`custom: { <fieldKey>: <value> }\` — only use keys that exist in the project.
- If the user's requested field is ambiguous, show the existing field list before choosing a key, so you avoid duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
Agenda:
${args.agenda}`,
@@ -99,13 +108,13 @@ ${args.agenda}`,
Strategy:
1. Call ontime_get_rundown to see the current events, their IDs, and field values.
2. Determine which event IDs are affected by the instruction.
3. If every affected event receives the SAME field changes (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_entries once with { ids, data }.
4. If each event needs DIFFERENT values (e.g. "shift everything 30 minutes"): check first if events use linkStart. If they do, changing the first linked event's timeStart cascades to all linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_entry for each.
3. If every affected entry receives the SAME field values (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_entries once with { ids, data }.
4. If each event needs DIFFERENT field values (e.g. "shift everything 30 minutes later"): check first if events use linkStart. If they do, changing the anchor event's timeStart or duration can cascade to linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_entry for each.
Time shift mechanics:
- All time fields are milliseconds from midnight; compute arithmetic before calling the tools.
- timeEnd - timeStart = duration. When shifting times, decide whether to keep duration fixed (timeEnd moves with timeStart) or keep timeEnd fixed (duration shrinks). Provide only the fields you intend to change — the server infers the strategy from which fields are present.
- For "shift everything N minutes later": update timeStart and timeEnd per event (or just timeStart on the first event of a linkStart chain).
- For "shift everything N minutes later": update timeStart and timeEnd per event (or just timeStart on the anchor event of a linkStart chain). Do not use ontime_batch_update_entries for this unless every target event should receive the exact same timeStart/timeEnd values.
Automation risks:
- Setting \`endAction: 'play-next'\` on multiple events creates an automatic playback chain that removes operator control between those events. Confirm with the user before applying.
@@ -125,12 +134,12 @@ Steps:
Check and report:
Schedule integrity:
- Events with missing or duplicate \`cue\`
- Events with missing \`title\`
- Events with missing \`cue\` or \`title\`: these are usually worth checking, but not necessarily errors
- Events with \`duration\` of 0 or negative
- Events where \`timeEnd\` < \`timeStart\`
- Events whose \`timeStart\` overlaps the previous event's \`timeEnd\` (gap < 0 means a conflict)
- Large unexplained gaps between consecutive events (> 30 min) that may indicate a missing break
- Events with \`gap < 0\`: overlaps the previous timed event and is a conflict
- Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional
- Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional
- Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional
Timing and linking:
- \`metadata.totalDays > 0\`: show spans midnight — confirm this is intentional
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ListResourcesResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import { getProjectData } from '../api-data/project-data/projectData.dao.js';
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
import type { ListResourcesResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js';
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
+3 -2
View File
@@ -1,11 +1,12 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import express, { type Request, type Response } from 'express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import type { Request, Response, Router } from 'express';
import { createMcpServer } from './mcp.server.js';
export const mcpRouter = express.Router();
export const mcpRouter: Router = express.Router();
mcpRouter.post('/', async (req, res) => {
// A new Server instance is created per request — required for stateless mode where
+33 -9
View File
@@ -26,7 +26,7 @@ export const EVENT_TIMER_FIELDS = {
linkStart: {
type: 'boolean',
description:
"Chain this event's start time to the previous event's end time — changing the first linked event propagates schedule changes to all linked followers",
"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: 'Timer counts toward the scheduled end time rather than elapsed time' },
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
@@ -50,7 +50,7 @@ export const EVENT_WRITABLE_FIELDS = {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Custom field values keyed by field key, e.g. { "camera": "CAM 2" }. Get available keys with ontime_get_custom_fields',
'Custom field values keyed by existing project field key, e.g. { "camera": "CAM 2" }. Get available keys with ontime_get_custom_fields. Adding new custom field definitions is a separate project-level operation.',
},
...EVENT_TIMER_FIELDS,
} as const;
@@ -94,7 +94,9 @@ There are four entry types discriminated by \`type\`:
timeStart: number // ms from midnight (09:00 = 32400000)
timeEnd: number // ms from midnight
duration: number // ms (= timeEnd - timeStart)
delay: number // accumulated delay in ms (runtime)
delay: number // delay accumulated from rundown delay entries
dayOffset: number // runtime calculated day offset from the rundown start schedule, increments when the rundown crosses midnight
gap: number // schedule gap between sequential playable events; negative gap means overlap
timerType: 'count-down' | 'count-up' | 'clock' | 'none'
endAction: 'none' | 'load-next' | 'play-next'
linkStart: boolean // chain start to previous event's end
@@ -104,35 +106,57 @@ There are four entry types discriminated by \`type\`:
timeWarning: number // ms before end to trigger 'warning' state
timeDanger: number // ms before end to trigger 'danger' state
custom: { [key: string]: string } // custom field values
parent: EntryId | null // parent group, when nested
revision: number // entry revision
}
\`\`\`
### \`delay\` — OntimeDelay (schedule shift applied to following events)
\`\`\`
{ type: 'delay', id, duration: number }
{ type: 'delay', id, duration: number, parent: EntryId | null }
\`\`\`
### \`group\` — OntimeGroup (nested container of entries)
\`\`\`
{ type: 'group', id, title, colour, note, entries: EntryId[], targetDuration?: number }
{
type: 'group'
id: EntryId
title: string
colour: string
note: string
entries: EntryId[]
targetDuration: number | null
custom: { [key: string]: string }
timeStart: number | null // calculated from nested entries (runtime)
timeEnd: number | null // calculated from nested entries (runtime)
duration: number // calculated from nested entries (runtime)
isFirstLinked: boolean // whether the first nested event is linked (runtime)
revision: number
}
\`\`\`
Groups are created with a title only — set colour, note and targetDuration with an update after creation.
Groups are created with a title only — set colour, note, custom values and targetDuration with an update after creation.
### \`milestone\` — OntimeMilestone (marker with no timer)
\`\`\`
{ type: 'milestone', id, cue, title, note, colour, custom }
{ type: 'milestone', id, cue, title, note, colour, custom, parent: EntryId | null }
\`\`\`
## Time format
All time fields are **milliseconds from midnight (local)**. Examples:
Ontime stores time values as **milliseconds from midnight (local)**. Convert user-facing times before calling tools: \`10:30\` means \`37800000\`, and a duration like \`45 min\` means \`2700000\`.
\`timeEnd\` may be lower than \`timeStart\` when an event crosses midnight; duration is calculated across the day boundary.
Examples:
- 09:00:00 = 32400000
- 09:30:00 = 34200000
- 14:15:00 = 51300000
- Duration of 45 min = 2700000
## Custom fields
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\`.
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
Only use existing field keys when setting \`custom\` values. Adding, renaming, or deleting custom field definitions is a separate project-level operation.
When the user wants to assign custom values, show the existing custom field list first if there is any ambiguity, so you do not create duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
## Playback states (runtime only)
\`'stop' | 'play' | 'pause' | 'armed' | 'roll'\`
+7 -6
View File
@@ -20,9 +20,12 @@ 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_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
@@ -38,9 +41,7 @@ export function createMcpServer(): Server {
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DEFINITIONS }));
server.setRequestHandler(ReadResourceRequestSchema, async (request) =>
handleReadResource(request.params.uri),
);
server.setRequestHandler(ReadResourceRequestSchema, async (request) => handleReadResource(request.params.uri));
return server;
}
+34 -11
View File
@@ -1,3 +1,4 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import {
EntryId,
EventPostPayload,
@@ -44,8 +45,6 @@ import {
renameProjectFile,
} from '../services/project-service/ProjectService.js';
import { getState } from '../stores/runtimeState.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { EVENT_WRITABLE_FIELDS } from './mcp.schema.js';
// Graceful truncation to keep tool responses within typical MCP context windows
@@ -96,7 +95,7 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_create_entry',
description:
'Create a new entry in the current rundown. Omit after/before to append at the end. For type "event" provide title, timeStart, timeEnd and duration (cue is auto-numbered if omitted). For "milestone" provide cue/title/note/colour. For "delay" provide duration. For "group" provide title only — set other group fields with ontime_update_entry after creation.',
'Create a new entry in the current rundown. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration, and timeEnd+duration calculates timeStart. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title only — set colour, note, custom, or targetDuration with ontime_update_entry after creation.',
inputSchema: {
type: 'object',
properties: {
@@ -122,7 +121,7 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_update_entry',
description:
'Update fields of an existing entry (event, milestone, delay or group). Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group-only field: targetDuration.',
'Update fields of an existing entry (event, milestone, delay or group). Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group fields: title, note, colour, custom, targetDuration. Delay field: duration. Milestone fields: cue, title, note, colour, custom. Custom values must use existing project custom field keys; adding a new custom field is a separate operation.',
inputSchema: {
type: 'object',
required: ['id'],
@@ -171,7 +170,7 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_batch_create_entries',
description:
'Create multiple entries in one call. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first entry, subsequent entries chain from the previous.',
'Create multiple entries in one call. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first entry, subsequent entries chain from the previous. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration, and timeEnd+duration calculates timeStart.',
inputSchema: {
type: 'object',
required: ['entries'],
@@ -202,7 +201,7 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_batch_update_entries',
description:
'Apply the same field changes to multiple entries by ID. Use for bulk operations like recolouring all keynotes or shifting times by a constant offset (compute new times client-side first).',
'Apply the same field values to multiple entries by ID. Use for bulk operations like recolouring all keynotes, skipping all breaks, or setting the same custom value on several entries. Custom values must use existing project custom field keys. Do not use for changes where each entry needs a different value, such as time shifts with different timeStart/timeEnd values; compute those per entry and call ontime_update_entry for each.',
inputSchema: {
type: 'object',
required: ['ids', 'data'],
@@ -356,8 +355,7 @@ export const TOOL_DEFINITIONS = [
},
{
name: 'ontime_rename_project',
description:
'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.',
description: 'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.',
inputSchema: {
type: 'object',
required: ['filename', 'newFilename'],
@@ -430,6 +428,26 @@ type CreateEntryArgs = EntryFieldArgs & InsertOptions & { type?: `${SupportedEnt
type UpdateEntryArgs = EntryFieldArgs & { id: EntryId };
type ProjectInfoArgs = Partial<Pick<ProjectData, 'title' | 'description' | 'url' | 'info'>>;
function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['custom'] | undefined>) {
const customFields = getProjectCustomFields();
const knownKeys = new Set(Object.keys(customFields));
const unknownKeys = new Set<string>();
for (const custom of customValues) {
if (!custom) continue;
for (const key of Object.keys(custom)) {
if (!knownKeys.has(key)) {
unknownKeys.add(key);
}
}
}
if (unknownKeys.size > 0) {
const keys = [...unknownKeys].join(', ');
throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`);
}
}
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
const { type = SupportedEntry.Event, after, before } = args;
@@ -516,13 +534,16 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
},
ontime_create_entry: async (args) => {
const entry = await addEntry(getCurrentRundownId(), toEntryPayload(args as CreateEntryArgs));
const createArgs = args as CreateEntryArgs;
assertKnownCustomFields(createArgs.custom);
const entry = await addEntry(getCurrentRundownId(), toEntryPayload(createArgs));
return okMutation(entry);
},
ontime_update_entry: async (args) => {
const patch: PatchWithId = args as UpdateEntryArgs;
const entry = await editEntry(getCurrentRundownId(), patch);
const updateArgs = args as UpdateEntryArgs;
assertKnownCustomFields(updateArgs.custom);
const entry = await editEntry(getCurrentRundownId(), updateArgs as PatchWithId);
return okMutation(entry);
},
@@ -544,6 +565,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
ontime_batch_create_entries: async (args) => {
const { entries = [], after } = args as { entries: CreateEntryArgs[]; after?: EntryId };
assertKnownCustomFields(...entries.map((entry) => entry.custom));
const rundownId = getCurrentRundownId();
let previousId = after;
const created: unknown[] = [];
@@ -559,6 +581,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
ontime_batch_update_entries: async (args) => {
const { ids, data } = args as { ids: EntryId[]; data: EntryFieldArgs };
assertKnownCustomFields(data.custom);
const rundown = await batchEditEntries(getCurrentRundownId(), ids, data);
return okMutation({ updated: ids, order: rundown.order });
},