mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 10:23:54 +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 { 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 { isOntimeCloud } from '../../../../externals';
|
||||
import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import InfoNif from '../network-panel/NetworkInterfaces';
|
||||
import McpSection from './McpSection';
|
||||
import ReportSettings from './ReportSettings';
|
||||
import URLPresets from './URLPresets';
|
||||
|
||||
@@ -18,29 +14,6 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
const reportRef = useScrollIntoView<HTMLDivElement>('report', 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 (
|
||||
<>
|
||||
<Panel.Header>Sharing and reporting</Panel.Header>
|
||||
@@ -63,30 +36,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
</Panel.Section>
|
||||
</div>
|
||||
<div ref={mcpRef}>
|
||||
<Panel.Section>
|
||||
<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>
|
||||
<McpSection />
|
||||
</div>
|
||||
<div ref={reportRef}>
|
||||
<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;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const bearerToken = authHeader.slice(7);
|
||||
|
||||
Reference in New Issue
Block a user