mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 12:29:10 +00:00
refactor(mcp): address review — split files, fix naming, stateless router
Split the monolithic mcp.router.ts into focused modules: - mcp.service.ts: coordination helpers (rundownListResponse, renameRundown, deleteRundown) - mcp.tools.ts: tool definitions (ontime_ prefix, annotation presets, shared field schemas, CHARACTER_LIMIT truncation) + handleToolCall dispatcher - mcp.prompts.ts: prompt definitions + handleGetPrompt - mcp.resources.ts: resource definitions (schema, live data, docs) + handleReadResource - mcp.server.ts: createMcpServer factory that wires the above modules - mcp.router.ts: thin stateless Express router (POST/GET405/DELETE405) Other changes per review comments: - Rename ontime_delete_event → ontime_delete_entry (entry vs event naming convention) - Remove hardcoded cue/colour prefix conventions from tool descriptions and prompts; replace with "ask the user what convention they prefer" - Remove automations tools (left for a later PR) - Add Bearer auth comment in authenticate.ts explaining the MCP use case - Extract McpSection component from FeaturePanel to isolate URL state re-renders - Update bulk_edit prompt to mention linkStart cascade behaviour - Add 405 comments explaining why GET/DELETE are not supported in stateless mode https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
This commit is contained in:
@@ -1,14 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
import { generateUrl } from '../../../../common/api/session';
|
import { isOntimeCloud } from '../../../../externals';
|
||||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
|
||||||
import { isOntimeCloud, serverURL } from '../../../../externals';
|
|
||||||
import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
|
import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import InfoNif from '../network-panel/NetworkInterfaces';
|
import InfoNif from '../network-panel/NetworkInterfaces';
|
||||||
|
import McpSection from './McpSection';
|
||||||
import ReportSettings from './ReportSettings';
|
import ReportSettings from './ReportSettings';
|
||||||
import URLPresets from './URLPresets';
|
import URLPresets from './URLPresets';
|
||||||
|
|
||||||
@@ -18,29 +14,6 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
|
|||||||
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
|
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
|
||||||
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
|
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
|
||||||
|
|
||||||
const { data: infoData } = useInfo();
|
|
||||||
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const baseUrl = isOntimeCloud
|
|
||||||
? serverURL
|
|
||||||
: infoData.networkInterfaces.length > 0
|
|
||||||
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
|
|
||||||
: serverURL;
|
|
||||||
|
|
||||||
generateUrl({ baseUrl, path: '/mcp', authenticate: true, lockConfig: false, lockNav: false })
|
|
||||||
.then(setMcpEndpointUrl)
|
|
||||||
.catch(() => {
|
|
||||||
setMcpEndpointUrl(`${baseUrl}/mcp`);
|
|
||||||
});
|
|
||||||
}, [infoData]);
|
|
||||||
|
|
||||||
const claudeDesktopConfig = JSON.stringify(
|
|
||||||
{ mcpServers: { ontime: { url: mcpEndpointUrl } } },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Sharing and reporting</Panel.Header>
|
<Panel.Header>Sharing and reporting</Panel.Header>
|
||||||
@@ -63,30 +36,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
|
|||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</div>
|
</div>
|
||||||
<div ref={mcpRef}>
|
<div ref={mcpRef}>
|
||||||
<Panel.Section>
|
<McpSection />
|
||||||
<Panel.Card>
|
|
||||||
<Panel.SubHeader>MCP Server (AI Agent Integration)</Panel.SubHeader>
|
|
||||||
<Panel.Paragraph>
|
|
||||||
Connect AI agents (e.g. Claude Desktop) to Ontime via the Model Context Protocol endpoint.
|
|
||||||
</Panel.Paragraph>
|
|
||||||
<Panel.Divider />
|
|
||||||
<Panel.Field
|
|
||||||
title='MCP Endpoint URL'
|
|
||||||
description='Use this URL in your MCP client configuration'
|
|
||||||
/>
|
|
||||||
{mcpEndpointUrl && (
|
|
||||||
<CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>
|
|
||||||
)}
|
|
||||||
<Panel.Divider />
|
|
||||||
<Panel.Field
|
|
||||||
title='Claude Desktop config'
|
|
||||||
description='Paste this into your claude_desktop_config.json under "mcpServers"'
|
|
||||||
/>
|
|
||||||
{mcpEndpointUrl && (
|
|
||||||
<CopyTag copyValue={claudeDesktopConfig}>{claudeDesktopConfig}</CopyTag>
|
|
||||||
)}
|
|
||||||
</Panel.Card>
|
|
||||||
</Panel.Section>
|
|
||||||
</div>
|
</div>
|
||||||
<div ref={reportRef}>
|
<div ref={reportRef}>
|
||||||
<ReportSettings />
|
<ReportSettings />
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { generateUrl } from '../../../../common/api/session';
|
||||||
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
|
import { isOntimeCloud, serverURL } from '../../../../externals';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
/** MCP endpoint card — isolated so that URL state changes don't re-render the rest of FeaturePanel */
|
||||||
|
export default function McpSection() {
|
||||||
|
const { data: infoData } = useInfo();
|
||||||
|
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const baseUrl = isOntimeCloud
|
||||||
|
? serverURL
|
||||||
|
: infoData.networkInterfaces.length > 0
|
||||||
|
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
|
||||||
|
: serverURL;
|
||||||
|
|
||||||
|
generateUrl({ baseUrl, path: '/mcp', authenticate: true, lockConfig: false, lockNav: false })
|
||||||
|
.then(setMcpEndpointUrl)
|
||||||
|
.catch(() => {
|
||||||
|
setMcpEndpointUrl(`${baseUrl}/mcp`);
|
||||||
|
});
|
||||||
|
}, [infoData]);
|
||||||
|
|
||||||
|
const mcpClientConfig = JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>MCP Server</Panel.SubHeader>
|
||||||
|
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
|
||||||
|
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Field
|
||||||
|
title='Client configuration snippet'
|
||||||
|
description='Paste this into your AI agent settings under "mcpServers"'
|
||||||
|
/>
|
||||||
|
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { GetPromptResult, ListPromptsResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
|
||||||
|
{
|
||||||
|
name: 'create_rundown_from_agenda',
|
||||||
|
description: 'Convert a plain-text agenda into an Ontime rundown using ontime_create_events_batch',
|
||||||
|
arguments: [{ name: 'agenda', description: 'Plain-text agenda to convert', required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bulk_edit_rundown',
|
||||||
|
description: 'Apply a bulk change across the rundown (recolour, reschedule, skip events, etc.)',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'instruction',
|
||||||
|
description: 'What to change, e.g. "colour all keynotes blue"',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'validate_rundown',
|
||||||
|
description: 'Check the current rundown for common issues: missing cues, overlaps, gaps, zero-duration events',
|
||||||
|
arguments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'restructure_rundown',
|
||||||
|
description: 'Reorder events in the rundown according to an instruction',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'instruction',
|
||||||
|
description: 'How to restructure, e.g. "move all breaks to after keynotes"',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function handleGetPrompt(name: string, args: Record<string, string>): GetPromptResult {
|
||||||
|
if (name === 'create_rundown_from_agenda') {
|
||||||
|
const agenda = args.agenda ?? '';
|
||||||
|
return {
|
||||||
|
description: 'Build an Ontime rundown from a plain-text agenda',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Convert the following agenda into an Ontime rundown.
|
||||||
|
|
||||||
|
Data model:
|
||||||
|
- Times are milliseconds from midnight. 09:00 = 32400000, 10:30 = 37800000, etc.
|
||||||
|
- duration = timeEnd - timeStart
|
||||||
|
- endAction: "load-next" for back-to-back sessions, "none" otherwise
|
||||||
|
- Ask the user what cue naming and colour conventions they prefer
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Call ontime_get_rundown to see current state and identify an \`after\` anchor if appending.
|
||||||
|
2. Build an array of events in order and call ontime_create_events_batch ONCE with all of them. This is much faster than calling ontime_create_event per item.
|
||||||
|
3. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
|
||||||
|
|
||||||
|
Agenda:
|
||||||
|
${agenda}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'bulk_edit_rundown') {
|
||||||
|
const instruction = args.instruction ?? '';
|
||||||
|
return {
|
||||||
|
description: 'Apply a bulk change across the rundown',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Apply the following bulk edit to the current Ontime rundown: "${instruction}"
|
||||||
|
|
||||||
|
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_events 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_event for each.
|
||||||
|
5. Time fields are milliseconds from midnight; compute arithmetic before calling the tools.
|
||||||
|
|
||||||
|
Confirm with the user before making destructive changes like setting skip=true on many events.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'validate_rundown') {
|
||||||
|
return {
|
||||||
|
description: 'Check the current rundown for common issues',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Validate the currently loaded Ontime rundown and report issues.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Call ontime_get_rundown to read all events.
|
||||||
|
2. Call ontime_get_rundown_metadata for totals (total duration, first/last times, flagged IDs).
|
||||||
|
3. Check and report:
|
||||||
|
- Events with missing or duplicate \`cue\`
|
||||||
|
- Events with missing \`title\`
|
||||||
|
- Events with \`duration\` of 0 or negative
|
||||||
|
- Events where \`timeEnd\` is before \`timeStart\`
|
||||||
|
- Events whose \`timeStart\` overlaps the previous event's \`timeEnd\` (schedule conflict)
|
||||||
|
- Large unexplained gaps between consecutive events (> 30 min) that may indicate missing breaks
|
||||||
|
- Events flagged \`skip: true\` — confirm with the user these are intentional
|
||||||
|
- Total rundown duration and whether it matches the user's expected show length (ask if unknown)
|
||||||
|
|
||||||
|
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'restructure_rundown') {
|
||||||
|
const instruction = args.instruction ?? '';
|
||||||
|
return {
|
||||||
|
description: 'Reorder events in the rundown',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Restructure the current Ontime rundown: "${instruction}"
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Call ontime_get_rundown to see the current order and event fields.
|
||||||
|
2. Compute the target order as an array of event IDs.
|
||||||
|
3. For each event that needs to move, call ontime_reorder_event with { entryId, destinationId, order: 'before' | 'after' }.
|
||||||
|
4. Call ontime_get_rundown again at the end to confirm the new order.
|
||||||
|
|
||||||
|
Tip: moving items in the "to" direction of the target position minimises reorder calls. Plan the sequence of moves to avoid moving the same event twice.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown prompt: ${name}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// Static Ontime data model reference — agents read this once per session to understand
|
||||||
|
// the rundown structure, time format, and entry types before issuing tool calls.
|
||||||
|
const ONTIME_SCHEMA_MARKDOWN = `# Ontime data model
|
||||||
|
|
||||||
|
A concise reference for how Ontime structures rundowns, events, and related data.
|
||||||
|
|
||||||
|
## Rundown
|
||||||
|
|
||||||
|
A rundown is an ordered list of entries rendered as a show schedule. A project can contain multiple rundowns; one is "loaded" at a time.
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
Rundown {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
order: EntryId[] // top-level entry order
|
||||||
|
flatOrder: EntryId[] // includes entries nested in groups
|
||||||
|
entries: { [id: EntryId]: OntimeEntry }
|
||||||
|
revision: number
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
There are three entry types discriminated by \`type\`:
|
||||||
|
|
||||||
|
### \`event\` — OntimeEvent (a timed show item)
|
||||||
|
\`\`\`
|
||||||
|
{
|
||||||
|
type: 'event'
|
||||||
|
id: EntryId
|
||||||
|
cue: string // human-facing cue label
|
||||||
|
title: string
|
||||||
|
note: string
|
||||||
|
colour: string // hex, e.g. "#4A90D9"
|
||||||
|
timeStart: number // ms from midnight (09:00 = 32400000)
|
||||||
|
timeEnd: number // ms from midnight
|
||||||
|
duration: number // ms (= timeEnd - timeStart)
|
||||||
|
delay: number // accumulated delay in ms
|
||||||
|
timerType: 'count-down' | 'count-up' | 'time-to-end' | 'clock'
|
||||||
|
endAction: 'none' | 'stop' | 'load-next' | 'play-next'
|
||||||
|
linkStart: boolean // chain start to previous event's end
|
||||||
|
countToEnd: boolean // timer counts to planned end time
|
||||||
|
skip: boolean // event is skipped during playback
|
||||||
|
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
|
||||||
|
triggers: AutomationTrigger[]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### \`delay\` — Delay (schedule shift applied to following events)
|
||||||
|
\`\`\`
|
||||||
|
{ type: 'delay', id, duration: number }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### \`group\` — Group (nested container of entries)
|
||||||
|
\`\`\`
|
||||||
|
{ type: 'group', id, title, colour, note, entries: EntryId[], targetDuration?: number }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Time format
|
||||||
|
All time fields are **milliseconds from midnight (local)**. 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 name/type/colour definitions stored at \`ontime://project/custom-fields\`. Each event stores values at \`event.custom[fieldKey]\`.
|
||||||
|
|
||||||
|
## Playback states (runtime only)
|
||||||
|
\`'stop' | 'play' | 'pause' | 'armed' | 'roll'\`. When playback is not \`stop\`, mutating tools warn that changes are visible immediately.
|
||||||
|
|
||||||
|
## Useful resource URIs
|
||||||
|
- \`ontime://schema\` — this document
|
||||||
|
- \`ontime://rundown/current\` — the currently loaded rundown (JSON)
|
||||||
|
- \`ontime://rundowns\` — all rundowns in the project (JSON)
|
||||||
|
- \`ontime://project/info\` — project metadata (JSON)
|
||||||
|
- \`ontime://project/custom-fields\` — custom field definitions (JSON)
|
||||||
|
- \`ontime://docs\` — index of Ontime documentation topics with URLs
|
||||||
|
|
||||||
|
## Further reading
|
||||||
|
Full Ontime documentation: **https://docs.getontime.no**
|
||||||
|
Read \`ontime://docs\` for a topic index with direct links.
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Curated documentation index — agents read this to find official docs on specific topics.
|
||||||
|
const ONTIME_DOCS_MARKDOWN = `# Ontime Documentation Index
|
||||||
|
|
||||||
|
Main site: https://docs.getontime.no
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
- Installation & setup: https://docs.getontime.no/installation/
|
||||||
|
|
||||||
|
## Core concepts
|
||||||
|
- Rundown: https://docs.getontime.no/concepts/rundown/
|
||||||
|
- Timer types (count-down, count-up, time-to-end, clock): https://docs.getontime.no/concepts/timer/
|
||||||
|
- Time entry format: https://docs.getontime.no/concepts/time-entry/
|
||||||
|
- Event actions (end action, link start): https://docs.getontime.no/concepts/event-actions/
|
||||||
|
- Delays and blocks: https://docs.getontime.no/concepts/delays-and-blocks/
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Custom fields: https://docs.getontime.no/features/custom-fields/
|
||||||
|
- Automations (triggers, filters, outputs): https://docs.getontime.no/features/automations/
|
||||||
|
- URL presets / shared views: https://docs.getontime.no/features/url-presets/
|
||||||
|
- HTTP Integration: https://docs.getontime.no/api/http/
|
||||||
|
- OSC Integration: https://docs.getontime.no/api/osc/
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
- REST API overview: https://docs.getontime.no/api/
|
||||||
|
- WebSocket events: https://docs.getontime.no/api/websocket/
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
|
||||||
|
{
|
||||||
|
uri: 'ontime://schema',
|
||||||
|
name: 'ontime-schema',
|
||||||
|
title: 'Ontime data model reference',
|
||||||
|
description:
|
||||||
|
'Markdown reference for rundown structure, event fields, time format, and entry types. Read once per session to ground tool calls in the correct data model.',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ontime://rundown/current',
|
||||||
|
name: 'current-rundown',
|
||||||
|
title: 'Currently loaded rundown',
|
||||||
|
description:
|
||||||
|
'The rundown currently active in Ontime, with its full entries map and order. Re-read after any mutating call to see updated state.',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ontime://rundowns',
|
||||||
|
name: 'project-rundowns',
|
||||||
|
title: 'All rundowns in the project',
|
||||||
|
description: 'List of every rundown stored in the current project file, plus the ID of the one currently loaded.',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ontime://project/info',
|
||||||
|
name: 'project-info',
|
||||||
|
title: 'Project metadata',
|
||||||
|
description: 'Project title, description, URL, info, logo, and custom header fields.',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ontime://project/custom-fields',
|
||||||
|
name: 'project-custom-fields',
|
||||||
|
title: 'Custom field definitions',
|
||||||
|
description:
|
||||||
|
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ontime://docs',
|
||||||
|
name: 'ontime-docs',
|
||||||
|
title: 'Ontime documentation index',
|
||||||
|
description:
|
||||||
|
'Curated index of Ontime documentation topics with direct links to https://docs.getontime.no. Read this when you need to understand a concept in more depth or want to point the user to official documentation.',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function handleReadResource(uri: string): ReadResourceResult {
|
||||||
|
if (uri === 'ontime://schema') {
|
||||||
|
return { contents: [{ uri, mimeType: 'text/markdown', text: ONTIME_SCHEMA_MARKDOWN }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri === 'ontime://rundown/current') {
|
||||||
|
return {
|
||||||
|
contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(getCurrentRundown()) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri === 'ontime://rundowns') {
|
||||||
|
const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns());
|
||||||
|
const loaded = getCurrentRundown().id;
|
||||||
|
return {
|
||||||
|
contents: [{ uri, mimeType: 'application/json', text: JSON.stringify({ loaded, rundowns }) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri === 'ontime://project/info') {
|
||||||
|
return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(getProjectData()) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri === 'ontime://project/custom-fields') {
|
||||||
|
return {
|
||||||
|
contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(getProjectCustomFields()) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri === 'ontime://docs') {
|
||||||
|
return { contents: [{ uri, mimeType: 'text/markdown', text: ONTIME_DOCS_MARKDOWN }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown resource URI: ${uri}`);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
GetPromptRequestSchema,
|
||||||
|
ListPromptsRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
ReadResourceRequestSchema,
|
||||||
|
type CallToolResult,
|
||||||
|
type ListToolsResult,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
|
||||||
|
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
|
||||||
|
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
|
||||||
|
|
||||||
|
export function createMcpServer(): Server {
|
||||||
|
const server = new Server(
|
||||||
|
{ name: 'ontime-mcp-server', version: '1.0.0' },
|
||||||
|
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
return handleToolCall(name, args as Record<string, unknown>);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPT_DEFINITIONS }));
|
||||||
|
|
||||||
|
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args = {} } = request.params;
|
||||||
|
return handleGetPrompt(name, args as Record<string, string>);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DEFINITIONS }));
|
||||||
|
|
||||||
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) =>
|
||||||
|
handleReadResource(request.params.uri),
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { getCurrentRundown } from '../api-data/rundown/rundown.dao.js';
|
||||||
|
import { initRundown } from '../api-data/rundown/rundown.service.js';
|
||||||
|
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
||||||
|
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
|
/** Returns the standard rundown list payload used by rundown management tool responses */
|
||||||
|
export function rundownListResponse() {
|
||||||
|
const loaded = getCurrentRundown().id;
|
||||||
|
const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns());
|
||||||
|
return { loaded, rundowns };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renames a rundown and reinitialises runtime state if it is currently loaded */
|
||||||
|
export async function renameRundown(id: string, title: string) {
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
const rundown = dataProvider.getRundown(id);
|
||||||
|
if (!rundown) throw new Error(`Rundown ${id} not found`);
|
||||||
|
await dataProvider.setRundown(id, { ...rundown, title });
|
||||||
|
if (id === getCurrentRundown().id) {
|
||||||
|
await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
|
||||||
|
}
|
||||||
|
return rundownListResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes a rundown, guarding against deleting the active or the last remaining rundown */
|
||||||
|
export async function deleteRundown(id: string) {
|
||||||
|
if (id === getCurrentRundown().id) {
|
||||||
|
throw new Error('Cannot delete the currently loaded rundown');
|
||||||
|
}
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
if (Object.keys(dataProvider.getProjectRundowns()).length <= 1) {
|
||||||
|
throw new Error('Cannot delete the last rundown');
|
||||||
|
}
|
||||||
|
await dataProvider.deleteRundown(id);
|
||||||
|
return rundownListResponse();
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
import { SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js';
|
||||||
|
import { getCurrentRundown, getRundownMetadata, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
||||||
|
import {
|
||||||
|
addEntry,
|
||||||
|
editEntry,
|
||||||
|
deleteEntries,
|
||||||
|
reorderEntry,
|
||||||
|
loadRundown,
|
||||||
|
batchEditEntries,
|
||||||
|
} from '../api-data/rundown/rundown.service.js';
|
||||||
|
import { duplicateRundown } from '../api-data/rundown/rundown.utils.js';
|
||||||
|
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
|
import { makeNewRundown } from '../models/dataModel.js';
|
||||||
|
import {
|
||||||
|
getProjectList,
|
||||||
|
loadProjectFile,
|
||||||
|
createProjectWithPatch,
|
||||||
|
renameProjectFile,
|
||||||
|
duplicateProjectFile,
|
||||||
|
deleteProjectFile,
|
||||||
|
} from '../services/project-service/ProjectService.js';
|
||||||
|
import { getState } from '../stores/runtimeState.js';
|
||||||
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
import { deleteRundown, renameRundown, rundownListResponse } from './mcp.service.js';
|
||||||
|
|
||||||
|
// Graceful truncation to keep tool responses within typical MCP context windows
|
||||||
|
const CHARACTER_LIMIT = 25_000;
|
||||||
|
|
||||||
|
// ---- Shared event field JSON schemas ----
|
||||||
|
// Reused across create_event, update_event, create_events_batch, batch_update_events to avoid repetition.
|
||||||
|
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:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
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)' },
|
||||||
|
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
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: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
|
||||||
|
},
|
||||||
|
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
||||||
|
...EVENT_TIMER_FIELDS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---- MCP tool annotation presets ----
|
||||||
|
// https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations
|
||||||
|
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
||||||
|
const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const;
|
||||||
|
const WRITE_IDEM = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
||||||
|
const WRITE_DESTRUCTIVE = {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---- Tool definitions ----
|
||||||
|
export const TOOL_DEFINITIONS = [
|
||||||
|
// --- Rundown read ---
|
||||||
|
{
|
||||||
|
name: 'ontime_get_rundown',
|
||||||
|
description:
|
||||||
|
'Get the currently loaded rundown. Returns { order: EntryId[], entries: { [id]: OntimeEntry } }. If the rundown exceeds 25 000 chars, returns only the order array with a warning — fetch individual events with ontime_get_event.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_get_rundown_metadata',
|
||||||
|
description:
|
||||||
|
'Get cached metadata for the current rundown. Returns: totalDelay, totalDuration, totalDays, firstStart, lastEnd, flags (flagged entry IDs), playableEventOrder, timedEventOrder, flatEntryOrder.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_get_event',
|
||||||
|
description: 'Get a single event by id or cue. Provide either id or cue (not both). Returns the full entry object.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Event ID (from rundown.entries key or event.id)' },
|
||||||
|
cue: { type: 'string', description: 'Human-facing cue label' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
// --- Rundown mutations ---
|
||||||
|
{
|
||||||
|
name: 'ontime_create_event',
|
||||||
|
description: 'Create a new event in the rundown. Omit after/before to append at the end.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['cue', 'title', 'timeStart', 'timeEnd', 'duration'],
|
||||||
|
properties: {
|
||||||
|
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' },
|
||||||
|
timeStart: { type: 'number', description: 'Start time in ms from midnight (e.g. 09:00 = 32400000)' },
|
||||||
|
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||||
|
duration: { type: 'number', description: 'Duration in ms (should equal timeEnd - timeStart)' },
|
||||||
|
after: { type: 'string', description: 'Insert after this event ID' },
|
||||||
|
before: { type: 'string', description: 'Insert before this event ID' },
|
||||||
|
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
||||||
|
colour: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
|
||||||
|
},
|
||||||
|
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
||||||
|
...EVENT_TIMER_FIELDS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_update_event',
|
||||||
|
description:
|
||||||
|
'Update fields of an existing event. Only provided fields are changed. Time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'ID of the event to update' },
|
||||||
|
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||||
|
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||||
|
duration: { type: 'number', description: 'Duration in ms' },
|
||||||
|
...EVENT_WRITABLE_FIELDS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_delete_entry',
|
||||||
|
description: 'Delete one or more entries (events, delays, or groups) from the rundown',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['ids'],
|
||||||
|
properties: {
|
||||||
|
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to delete' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_reorder_event',
|
||||||
|
description:
|
||||||
|
'Move an event to a new position relative to another event. Use before/after for sibling reordering; use insert to place an event inside a group.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['entryId', 'destinationId', 'order'],
|
||||||
|
properties: {
|
||||||
|
entryId: { type: 'string', description: 'ID of the event to move' },
|
||||||
|
destinationId: { type: 'string', description: 'ID of the target event (sibling or parent group)' },
|
||||||
|
order: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['before', 'after', 'insert'],
|
||||||
|
description: 'before/after: place as sibling; insert: place inside a group',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE_IDEM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_create_events_batch',
|
||||||
|
description:
|
||||||
|
'Create multiple events in one call. Use this for "build from agenda" flows to avoid many round trips. Events are inserted in array order; if `after` is provided it positions the first event, subsequent events chain from the previous.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['events'],
|
||||||
|
properties: {
|
||||||
|
after: { type: 'string', description: 'Insert the first event after this entry ID' },
|
||||||
|
events: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Array of events to create, in desired order',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['cue', 'title', 'timeStart', 'timeEnd', 'duration'],
|
||||||
|
properties: {
|
||||||
|
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' },
|
||||||
|
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||||
|
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||||
|
duration: { type: 'number', description: 'Duration in ms (should equal timeEnd - timeStart)' },
|
||||||
|
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
||||||
|
colour: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
|
||||||
|
},
|
||||||
|
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
||||||
|
...EVENT_TIMER_FIELDS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_batch_update_events',
|
||||||
|
description:
|
||||||
|
'Apply the same field changes to multiple events by ID. Use for bulk operations like recolouring all keynotes or shifting times by a constant offset (compute new times client-side first).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['ids', 'data'],
|
||||||
|
properties: {
|
||||||
|
ids: { type: 'array', items: { type: 'string' }, description: 'Array of event IDs to update' },
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Partial event fields to apply to every ID',
|
||||||
|
properties: {
|
||||||
|
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||||
|
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||||
|
duration: { type: 'number', description: 'Duration in ms' },
|
||||||
|
...EVENT_WRITABLE_FIELDS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
// --- Rundown management ---
|
||||||
|
{
|
||||||
|
name: 'ontime_list_rundowns',
|
||||||
|
description:
|
||||||
|
'List all rundowns in the current project. Returns rundown IDs and titles, plus the ID of the currently loaded one.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_create_rundown',
|
||||||
|
description:
|
||||||
|
'Create a new empty rundown in the current project. Does not switch to it — use ontime_load_rundown to activate.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['title'],
|
||||||
|
properties: { title: { type: 'string', description: 'Title for the new rundown' } },
|
||||||
|
},
|
||||||
|
annotations: WRITE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_load_rundown',
|
||||||
|
description:
|
||||||
|
'Make a rundown the active rundown. Resets the runtime and clears playback state. Prefer to use when playback is stopped.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: { id: { type: 'string', description: 'Rundown ID to load' } },
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_rename_rundown',
|
||||||
|
description: 'Rename an existing rundown',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'title'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Rundown ID to rename' },
|
||||||
|
title: { type: 'string', description: 'New title' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE_IDEM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_delete_rundown',
|
||||||
|
description: 'Delete a rundown (cannot delete the currently loaded rundown or the last remaining rundown)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: { id: { type: 'string', description: 'Rundown ID to delete' } },
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_duplicate_rundown',
|
||||||
|
description: 'Duplicate a rundown, creating a copy with a new ID. Does not switch to the copy.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: { id: { type: 'string', description: 'Rundown ID to duplicate' } },
|
||||||
|
},
|
||||||
|
annotations: WRITE,
|
||||||
|
},
|
||||||
|
// --- Timer & project ---
|
||||||
|
{
|
||||||
|
name: 'ontime_get_timer_state',
|
||||||
|
description:
|
||||||
|
'Get the current timer/playback state. Returns: clock (time of day), timer ({ playback, current, elapsed, phase, expectedFinish, addedTime, startedAt }), eventNow (full event object or null), eventNext (full event object or null), offset.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_get_project_info',
|
||||||
|
description:
|
||||||
|
'Get current project metadata: title, description, url, info, logo, and custom header fields (array of { title, value, url }).',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_update_project_info',
|
||||||
|
description: 'Update project metadata fields. All fields are optional — only provided fields are updated.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Project title' },
|
||||||
|
description: { type: 'string', description: 'Project description' },
|
||||||
|
url: { type: 'string', description: 'URL shown on viewer pages' },
|
||||||
|
info: { type: 'string', description: 'Info text shown on viewer pages' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_get_custom_fields',
|
||||||
|
description:
|
||||||
|
'Get the project custom field definitions. Returns { [key]: { label, type: "text"|"image", colour } }. Keys are referenced in event.custom[key].',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
// --- Project file management ---
|
||||||
|
{
|
||||||
|
name: 'ontime_list_projects',
|
||||||
|
description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
annotations: READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_load_project',
|
||||||
|
description:
|
||||||
|
'Load a different project file by filename. This stops playback, swaps the database, and reinitialises runtime. Prefer to use when playback is stopped.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['filename'],
|
||||||
|
properties: { filename: { type: 'string', description: 'Project filename, e.g. "my-show.json"' } },
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_create_project',
|
||||||
|
description:
|
||||||
|
'Create a new empty project file and save it to disk. Does not switch to the new project. Omit the .json extension — Ontime appends it.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['filename'],
|
||||||
|
properties: {
|
||||||
|
filename: { type: 'string', description: 'Filename without extension, e.g. "my-show"' },
|
||||||
|
title: { type: 'string', description: 'Optional project title' },
|
||||||
|
description: { type: 'string', description: 'Optional project description' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_rename_project',
|
||||||
|
description:
|
||||||
|
'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['filename', 'newFilename'],
|
||||||
|
properties: {
|
||||||
|
filename: { type: 'string', description: 'Current filename (with .json extension)' },
|
||||||
|
newFilename: { type: 'string', description: 'New filename (with .json extension)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE_IDEM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_duplicate_project',
|
||||||
|
description: 'Duplicate a project file on disk with a new filename. Does not switch to the copy.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['filename', 'newFilename'],
|
||||||
|
properties: {
|
||||||
|
filename: { type: 'string', description: 'Source filename to copy (with .json extension)' },
|
||||||
|
newFilename: { type: 'string', description: 'Filename of the new copy (with .json extension)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: WRITE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ontime_delete_project',
|
||||||
|
description: 'Delete a project file from disk. Fails if the file is currently loaded.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['filename'],
|
||||||
|
properties: { filename: { type: 'string', description: 'Project filename to delete (with .json extension)' } },
|
||||||
|
},
|
||||||
|
annotations: WRITE_DESTRUCTIVE,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ---- Response helpers (module-level to avoid re-allocation on every tool call) ----
|
||||||
|
|
||||||
|
const text = (data: unknown): string => JSON.stringify(data);
|
||||||
|
|
||||||
|
export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] });
|
||||||
|
|
||||||
|
export const err = (e: unknown): CallToolResult => ({
|
||||||
|
content: [{ type: 'text', text: text({ error: String(e) }) }],
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Wraps mutating-tool results with a playback warning when Ontime is not stopped */
|
||||||
|
export const okMutation = (data: unknown): CallToolResult => {
|
||||||
|
const playback = getState().timer.playback;
|
||||||
|
const payload =
|
||||||
|
playback !== 'stop'
|
||||||
|
? { warning: 'Playback is running — this change takes effect immediately.', result: data }
|
||||||
|
: data;
|
||||||
|
return { content: [{ type: 'text', text: text(payload) }] };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Tool call dispatcher ----
|
||||||
|
export async function handleToolCall(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
|
||||||
|
switch (name) {
|
||||||
|
case 'ontime_get_rundown': {
|
||||||
|
const rundown = getCurrentRundown();
|
||||||
|
const data = { order: rundown.order, entries: rundown.entries };
|
||||||
|
const serialised = text(data);
|
||||||
|
if (serialised.length > CHARACTER_LIMIT) {
|
||||||
|
return ok({
|
||||||
|
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_event. Entry IDs in order: ${rundown.order.join(', ')}`,
|
||||||
|
truncated: true,
|
||||||
|
order: rundown.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_get_rundown_metadata':
|
||||||
|
return ok(getRundownMetadata());
|
||||||
|
|
||||||
|
case 'ontime_get_event': {
|
||||||
|
const rundown = getCurrentRundown();
|
||||||
|
const id = args.id as string | undefined;
|
||||||
|
const cue = args.cue as string | undefined;
|
||||||
|
if (id) {
|
||||||
|
const entry = rundown.entries[id];
|
||||||
|
if (!entry) return err(`No event with id ${id}`);
|
||||||
|
return ok(entry);
|
||||||
|
}
|
||||||
|
if (cue) {
|
||||||
|
const entry = Object.values(rundown.entries).find((e) => 'cue' in e && (e as { cue: string }).cue === cue);
|
||||||
|
if (!entry) return err(`No event with cue ${cue}`);
|
||||||
|
return ok(entry);
|
||||||
|
}
|
||||||
|
return err('Provide id or cue');
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_create_event': {
|
||||||
|
try {
|
||||||
|
const entry = await addEntry({ type: SupportedEntry.Event, ...args } as never);
|
||||||
|
return okMutation(entry);
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_update_event': {
|
||||||
|
try {
|
||||||
|
const entry = await editEntry(args as never);
|
||||||
|
return okMutation(entry);
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_delete_entry': {
|
||||||
|
try {
|
||||||
|
const ids = args.ids as string[];
|
||||||
|
const rundown = await deleteEntries(ids);
|
||||||
|
return okMutation({ deleted: ids, order: rundown.order });
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_reorder_event': {
|
||||||
|
try {
|
||||||
|
const { entryId, destinationId, order } = args as {
|
||||||
|
entryId: string;
|
||||||
|
destinationId: string;
|
||||||
|
order: 'before' | 'after' | 'insert';
|
||||||
|
};
|
||||||
|
const rundown = await reorderEntry(entryId, destinationId, order);
|
||||||
|
return okMutation({ order: rundown.order });
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_create_events_batch': {
|
||||||
|
try {
|
||||||
|
const events = (args.events as Array<Record<string, unknown>>) ?? [];
|
||||||
|
let previousId = (args.after as string | undefined) ?? undefined;
|
||||||
|
const created: unknown[] = [];
|
||||||
|
for (const eventArgs of events) {
|
||||||
|
const entry = await addEntry({
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
...eventArgs,
|
||||||
|
...(previousId ? { after: previousId } : {}),
|
||||||
|
} as never);
|
||||||
|
created.push(entry);
|
||||||
|
previousId = (entry as { id: string }).id;
|
||||||
|
}
|
||||||
|
return okMutation({ created });
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_batch_update_events': {
|
||||||
|
try {
|
||||||
|
const ids = args.ids as string[];
|
||||||
|
const data = args.data as Partial<Record<string, unknown>>;
|
||||||
|
const rundown = await batchEditEntries(ids, data as never);
|
||||||
|
return okMutation({ updated: ids, order: rundown.order });
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_list_rundowns':
|
||||||
|
return ok(rundownListResponse());
|
||||||
|
|
||||||
|
case 'ontime_create_rundown': {
|
||||||
|
try {
|
||||||
|
const rundown = makeNewRundown();
|
||||||
|
rundown.title = args.title as string;
|
||||||
|
await getDataProvider().setRundown(rundown.id, rundown);
|
||||||
|
return okMutation(rundownListResponse());
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_load_rundown': {
|
||||||
|
try {
|
||||||
|
await loadRundown(args.id as string);
|
||||||
|
return okMutation(rundownListResponse());
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_rename_rundown': {
|
||||||
|
try {
|
||||||
|
const result = await renameRundown(args.id as string, args.title as string);
|
||||||
|
return okMutation(result);
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_delete_rundown': {
|
||||||
|
try {
|
||||||
|
const result = await deleteRundown(args.id as string);
|
||||||
|
return okMutation(result);
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_duplicate_rundown': {
|
||||||
|
try {
|
||||||
|
const id = args.id as string;
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
const rundown = dataProvider.getRundown(id);
|
||||||
|
if (!rundown) throw new Error(`Rundown ${id} not found`);
|
||||||
|
const copy = duplicateRundown(rundown as never, `Copy of ${rundown.title}`);
|
||||||
|
await dataProvider.setRundown(copy.id, copy);
|
||||||
|
return okMutation(rundownListResponse());
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_get_timer_state': {
|
||||||
|
const state = getState();
|
||||||
|
return ok({
|
||||||
|
clock: state.clock,
|
||||||
|
timer: state.timer,
|
||||||
|
eventNow: state.eventNow,
|
||||||
|
eventNext: state.eventNext,
|
||||||
|
offset: state.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_get_project_info':
|
||||||
|
return ok(getProjectData());
|
||||||
|
|
||||||
|
case 'ontime_update_project_info': {
|
||||||
|
try {
|
||||||
|
const updated = await editCurrentProjectData(args as never);
|
||||||
|
return ok(updated);
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_get_custom_fields':
|
||||||
|
return ok(getProjectCustomFields());
|
||||||
|
|
||||||
|
case 'ontime_list_projects':
|
||||||
|
return ok(await getProjectList());
|
||||||
|
|
||||||
|
case 'ontime_load_project': {
|
||||||
|
try {
|
||||||
|
await loadProjectFile(args.filename as string);
|
||||||
|
return okMutation(await getProjectList());
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_create_project': {
|
||||||
|
try {
|
||||||
|
const { filename, title, description } = args as {
|
||||||
|
filename: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
const patch =
|
||||||
|
title || description
|
||||||
|
? { project: { title: title ?? '', description: description ?? '' } as never }
|
||||||
|
: {};
|
||||||
|
const newFileName = await createProjectWithPatch(filename, patch);
|
||||||
|
return ok({ filename: newFileName });
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_rename_project': {
|
||||||
|
try {
|
||||||
|
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||||
|
await renameProjectFile(filename, newFilename);
|
||||||
|
return ok(await getProjectList());
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_duplicate_project': {
|
||||||
|
try {
|
||||||
|
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||||
|
await duplicateProjectFile(filename, newFilename);
|
||||||
|
return ok(await getProjectList());
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ontime_delete_project': {
|
||||||
|
try {
|
||||||
|
await deleteProjectFile(args.filename as string);
|
||||||
|
return ok(await getProjectList());
|
||||||
|
} catch (e) {
|
||||||
|
return err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return err(`Unknown tool: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP clients send Authorization: Bearer <token> rather than cookies
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
const bearerToken = authHeader.slice(7);
|
const bearerToken = authHeader.slice(7);
|
||||||
|
|||||||
Reference in New Issue
Block a user