refactor(mcp): guide agents through creating clean rundowns

This commit is contained in:
Claude
2026-07-04 17:41:36 +00:00
committed by Carlos Valente
parent 05aaeddf22
commit 82d5f02ea0
13 changed files with 398 additions and 52 deletions
@@ -1,4 +1,4 @@
import { linkToOtherHost } from '../linkUtils'; import { hostToBaseUrl, linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => { describe('linkToOTherHost', () => {
it('should handle electron links', () => { it('should handle electron links', () => {
@@ -22,3 +22,18 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path'); expect(destination).toBe('https://app.getontime.no/user-hash/path');
}); });
}); });
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
+14
View File
@@ -54,3 +54,17 @@ export function linkToOtherHost(
} }
return destination.toString(); return destination.toString();
} }
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
@@ -2,33 +2,23 @@ import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo'; import { serverURL } from '../../../../externals';
import { isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function McpSection() { export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState(''); const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url // generate url
useEffect(() => { useEffect(() => {
const baseUrl = (() => { // the page origin is reachable wherever the user is browsing from,
if (isOntimeCloud) return serverURL; // and follows the deployment (cloud, reverse proxy, port mappings)
// for local setups we prefer the localhost IP to avoid remote access
if (infoData.networkInterfaces.length > 0) {
return `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`;
}
return serverURL;
})();
// we are reusing the endpoint, so locking config and nav have no effect // we are reusing the endpoint, so locking config and nav have no effect
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false }) generateUrl({ baseUrl: serverURL, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl) .then(setMcpEndpointUrl)
.catch(() => { .catch(() => {
setMcpEndpointUrl(''); setMcpEndpointUrl('');
}); });
}, [infoData]); }, []);
const mcpClientConfig = mcpEndpointUrl const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2) ? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
@@ -40,14 +30,19 @@ export default function McpSection() {
<Panel.SubHeader>MCP Server</Panel.SubHeader> <Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph> <Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider /> <Panel.Divider />
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' /> <Panel.ListGroup>
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>} <Panel.ListItem>
<Panel.Divider /> <Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
<Panel.Field {mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
title='Client configuration snippet' </Panel.ListItem>
description='Paste this into your AI agent settings under "mcpServers"' <Panel.ListItem>
/> <Panel.Field
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>} title='Client configuration snippet'
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
); );
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
import useInfo from '../../common/hooks-query/useInfo'; import useInfo from '../../common/hooks-query/useInfo';
import useUrlPresets from '../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../common/hooks-query/useUrlPresets';
import { hostToBaseUrl } from '../../common/utils/linkUtils';
import GenerateLinkForm from './GenerateLinkForm'; import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps { interface GenerateLinkFormExportProps {
@@ -21,7 +22,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
*/ */
const hostOptions = useMemo(() => { const hostOptions = useMemo(() => {
return infoData.networkInterfaces.map((nif) => ({ return infoData.networkInterfaces.map((nif) => ({
value: `http://${nif.address}:${infoData.serverPort}`, value: hostToBaseUrl(nif.address, infoData.serverPort),
label: `${nif.name} - ${nif.address}`, label: `${nif.name} - ${nif.address}`,
})); }));
}, [infoData.networkInterfaces, infoData.serverPort]); }, [infoData.networkInterfaces, infoData.serverPort]);
+5
View File
@@ -71,6 +71,11 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
configure: logProxyRequests, configure: logProxyRequests,
}, },
'^/mcp': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/ws*': { '^/ws*': {
target: 'http://localhost:4001/', target: 'http://localhost:4001/',
changeOrigin: true, changeOrigin: true,
@@ -6,11 +6,13 @@ const editEntryMock = vi.hoisted(() => vi.fn());
const groupEntriesMock = vi.hoisted(() => vi.fn()); const groupEntriesMock = vi.hoisted(() => vi.fn());
const ungroupEntriesMock = vi.hoisted(() => vi.fn()); const ungroupEntriesMock = vi.hoisted(() => vi.fn());
const getCurrentRundownMock = vi.hoisted(() => vi.fn()); const getCurrentRundownMock = vi.hoisted(() => vi.fn());
const getProjectCustomFieldsMock = vi.hoisted(() => vi.fn());
const createCustomFieldMock = vi.hoisted(() => vi.fn());
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({ vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
getCurrentRundown: getCurrentRundownMock, getCurrentRundown: getCurrentRundownMock,
getCurrentRundownId: vi.fn(() => 'loaded-rundown'), getCurrentRundownId: vi.fn(() => 'loaded-rundown'),
getProjectCustomFields: vi.fn(() => ({})), getProjectCustomFields: getProjectCustomFieldsMock,
})); }));
vi.mock('../../classes/data-provider/DataProvider.js', () => ({ vi.mock('../../classes/data-provider/DataProvider.js', () => ({
@@ -20,6 +22,7 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => ({
vi.mock('../../api-data/rundown/rundown.service.js', () => ({ vi.mock('../../api-data/rundown/rundown.service.js', () => ({
addEntry: addEntryMock, addEntry: addEntryMock,
batchEditEntries: vi.fn(), batchEditEntries: vi.fn(),
createCustomField: createCustomFieldMock,
deleteEntries: vi.fn(), deleteEntries: vi.fn(),
editEntry: editEntryMock, editEntry: editEntryMock,
groupEntries: groupEntriesMock, groupEntries: groupEntriesMock,
@@ -27,7 +30,8 @@ vi.mock('../../api-data/rundown/rundown.service.js', () => ({
ungroupEntries: ungroupEntriesMock, ungroupEntries: ungroupEntriesMock,
})); }));
const { batchCreateEntriesForMcp, groupEntriesForMcp, ungroupEntryForMcp } = await import('../mcp.service.js'); const { batchCreateEntriesForMcp, createCustomFieldForMcp, createEntryForMcp, groupEntriesForMcp, ungroupEntryForMcp } =
await import('../mcp.service.js');
function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown { function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown {
return { return {
@@ -44,6 +48,7 @@ describe('mcp.service', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
getCurrentRundownMock.mockReturnValue(makeRundown({})); getCurrentRundownMock.mockReturnValue(makeRundown({}));
getProjectCustomFieldsMock.mockReturnValue({});
let id = 0; let id = 0;
addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => { addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => {
@@ -252,4 +257,42 @@ describe('mcp.service', () => {
expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group'); expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group');
expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] }); expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] });
}); });
describe('createCustomFieldForMcp', () => {
it('creates a field and returns the derived key', async () => {
createCustomFieldMock.mockResolvedValue({
Camera_Angle: { label: 'Camera Angle', type: 'text', colour: '#3E75E8' },
});
const result = await createCustomFieldForMcp({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
expect(createCustomFieldMock).toHaveBeenCalledWith({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
expect(result.key).toBe('Camera_Angle');
});
it('rejects labels the editor UI would not accept', async () => {
await expect(createCustomFieldForMcp({ label: 'Camera/GFX', type: 'text', colour: '#000000' })).rejects.toThrow(
'Invalid label',
);
expect(createCustomFieldMock).not.toHaveBeenCalled();
});
it('rejects case-insensitive duplicates and points at the existing key', async () => {
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
await expect(createCustomFieldForMcp({ label: 'camera', type: 'text', colour: '#000000' })).rejects.toThrow(
'A custom field with key "Camera" (label "Camera") already exists.',
);
expect(createCustomFieldMock).not.toHaveBeenCalled();
});
});
it('suggests the correctly cased key when custom values use the wrong casing', async () => {
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
await expect(createEntryForMcp({ title: 'Talk', custom: { camera: 'CAM 2' } })).rejects.toThrow(
'Keys are case-sensitive — did you mean: "camera" → "Camera"?',
);
expect(addEntryMock).not.toHaveBeenCalled();
});
}); });
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/project-data/projectData.dao.js', () => ({
getProjectData: vi.fn(() => ({ title: 'Test project' })),
}));
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
getCurrentRundown: vi.fn(() => ({
id: 'rundown-1',
title: 'Test',
order: [],
flatOrder: [],
entries: {},
revision: 0,
})),
getProjectCustomFields: vi.fn(() => ({})),
}));
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
getDataProvider: vi.fn(() => ({ getProjectRundowns: () => ({}) })),
}));
const { PROMPT_DEFINITIONS, handleGetPrompt } = await import('../mcp.prompts.js');
const { RESOURCE_DEFINITIONS, handleReadResource } = await import('../mcp.resources.js');
describe('MCP resource wiring', () => {
it('serves non-empty content for every listed resource', () => {
for (const resource of RESOURCE_DEFINITIONS) {
const result = handleReadResource(resource.uri);
expect(result.contents).toHaveLength(1);
expect(result.contents[0].uri).toBe(resource.uri);
expect(result.contents[0].mimeType).toBe(resource.mimeType);
expect((result.contents[0].text as string).length).toBeGreaterThan(0);
}
});
it('rejects unknown resource URIs', () => {
expect(() => handleReadResource('ontime://nope')).toThrow('Unknown resource URI: ontime://nope');
});
});
describe('MCP prompt wiring', () => {
it('resolves every listed prompt', () => {
for (const prompt of PROMPT_DEFINITIONS) {
const args = Object.fromEntries((prompt.arguments ?? []).map((arg) => [arg.name, 'test value']));
const result = handleGetPrompt(prompt.name, args);
expect(result.messages.length).toBeGreaterThan(0);
const content = result.messages[0].content;
expect(content.type).toBe('text');
}
});
it('rejects unknown prompts', () => {
expect(() => handleGetPrompt('nope', {})).toThrow('Unknown prompt: nope');
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Agent-facing Ontime guidance documents.
*
* These markdown resources teach agents rundown craft (ontime://style-guide)
* and Ontime's view ecosystem (ontime://views). Guidance should be concise and
* clear — verbosity distracts agents and users alike. Keep each document short.
*/
// The palette mirrors the editor colour picker swatches in
// apps/client/src/common/components/input/colour-input/SwatchSelect.tsx — keep in sync.
export const ONTIME_STYLE_GUIDE_MARKDOWN = `# Ontime rundown style guide
Conventions for tidy, scannable rundowns. Read before creating or restyling a rundown. Follow the user's own conventions when they have them; otherwise prefer these. When communicating with the user, be concise — short proposals beat long explanations.
## Structure
- Group events into blocks matching the show's segments; 10+ ungrouped events is a wall — propose groups.
- Set \`targetDuration\` on groups so operators can track time to block completion.
- Use \`milestone\` for non-timed markers (doors open, broadcast start), not zero-duration events.
- Use \`delay\` only to model schedule drift, never as padding.
- Schedule changeovers and buffers as real events (grey; orange when technical), not invisible gaps — 510 min between major segments stops one overrun cascading through the day.
- Model contingency segments as \`skip: true\` events: visible in the rundown, excluded from playback, one edit to activate.
- Before show day, duplicate the rundown for rehearsal so the show copy stays clean.
## Colours
Prefer Ontime's default palette — the editor's swatches — so colours look native. Each hue has a dark/light tone pair:
| Hue | Dark | Light | Use |
| --- | --- | --- | --- |
| Red | \`#ED3333\` | \`#FF7878\` | attention: curfews, hard deadlines, broadcast-critical |
| Orange | \`#FFAB33\` | \`#FFCC78\` | technical changeovers, resets |
| Green | \`#339E4E\` | \`#77C785\` | normal show content |
| Blue | \`#3E75E8\` | \`#779BE7\` | normal show content |
| Violet | \`#8064E1\` | \`#A790F5\` | normal show content |
| Grey | \`#9d9d9d\` | \`#ececec\` | breaks, housekeeping |
- Normal segments use the calm hues (green, blue, violet). Reserve red for what genuinely needs attention and orange for changeovers.
- Use tones for hierarchy: dark tone on a group, the light tone of the same hue on its child events.
- One convention per project, applied consistently.
## Cues and titles
- Cues follow ONE convention per rundown: numeric (\`1, 2, 3\` or \`10, 20, 30\`, leaving room for insertions), semantic (\`Preset\`, \`Key 1\`, \`Lunch\`), or a deliberate mix. Dense technical shows often prefix by department (\`LX1\`, \`SND2\`, \`VID3\`). Match what the rundown already uses; ask if nothing is established.
- Cues are automation handles — external systems (Companion, OSC/HTTP) trigger events by cue name. Keep them unique and stable; warn before renaming cues on an existing show.
- Title = what happens on stage, under ~50 characters. Department/tech data goes in custom fields; \`note\` is for free-text human context.
## Custom fields
- Structured per-department data (camera, graphics, audio, speaker) belongs in custom fields — one per department, coloured to match. This powers the cuesheet and operator views.
- Reuse existing fields; never create near-duplicates (\`Cam\`, \`camera\`, \`Cameras\`).
- Data repeated in titles or notes ("CAM 2") is a sign it belongs in a custom field.
- Write values to be scannable mid-show: "CAM 2 close-up", not sentences.
## Timing
- \`linkStart\` events inside a block so schedule edits propagate.
- \`flag\` sparingly — it marks critical moments and dilutes with overuse.
- Set \`timeWarning\` where a moderator would signal the speaker to wrap (commonly 5 min); \`timeDanger\` for the final push (e.g. 1 min). Scale both down for short items.
- Hard out? Backtime: build the closing items from the end with \`timeEnd\` + \`duration\` (Ontime calculates \`timeStart\`), protect their ends with \`timeStrategy: lock-end\`, and use \`countToEnd\` for true curfew items.
After tidying, read \`ontime://views\` and suggest views that use the new structure.
`;
export const ONTIME_VIEWS_MARKDOWN = `# Ontime views and sharing guide
Ontime renders the rundown into role-specific views at \`http://<host>:<port>/<view>\`. Recommend the right view when the user mentions a role, device, or sharing need. Prefer sharing live view links over exporting static documents — everyone sees the current version, ending the conflicting-copies problem of circulated spreadsheets. The agent cannot create views or presets — give exact URLs and point to the editor settings.
| View | Path | For |
| --- | --- | --- |
| Timer | \`/timer\` | speakers/presenters on stage |
| Backstage | \`/backstage\` | crew following schedule + notes |
| Operator | \`/op\` | department operators on phone/tablet |
| Cuesheet | \`/cuesheet\` | cross-department table editing |
| Timeline | \`/timeline\` | producers, whole-show overview |
| Countdown | \`/countdown\` | following selected events or a group |
| Studio clock | \`/studio\` | on-air studio environments |
| Project info | \`/info\` | sharing event logistics widely |
Most views are configured via URL parameters (editor and cuesheet in-app); the view's cog icon exposes the options and encodes them into the URL.
## Operator view parameters
- \`main\` / \`secondary-src\`: the two text lines — \`title\`, \`note\`, or \`custom-<fieldKey>\`.
- \`subscribe=<fieldKey>\`: highlight a custom field (repeatable).
- \`shouldEdit=true\`: long-press editing of the highlighted field.
- \`hidePast=true\`, \`showStart=true\`: trim and annotate the list.
Example for a camera operator:
\`/op?main=title&secondary-src=custom-Camera&subscribe=Camera&hidePast=true\`
Offer a link like this after creating a department custom field.
## URL presets
Presets bundle a configured view under a short alias (e.g. \`/camera-op\`), optionally shown in the nav menu. Managed in **Editor → Settings → URL presets**. When recommending a configured view, spell out the view and query string and suggest saving it as a preset.
Docs: https://docs.getontime.no/features/url-presets/
## Custom views
For branding or layouts beyond the built-in options: files in Ontime's \`external\` folder are served at \`/external/…\` and can use Ontime's APIs for live data. Recommend for sponsor branding, unusual layouts, or LED wall graphics.
Docs: https://docs.getontime.no/features/custom-views/
`;
+63 -11
View File
@@ -33,6 +33,18 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
}, },
], ],
}, },
{
name: 'tidy_rundown',
description:
'Audit the rundown for readability (colours, grouping, cue naming, custom field usage) and propose improvements',
arguments: [
{
name: 'focus',
description: 'Optional area to focus on, e.g. "colours only" or "grouping"',
required: false,
},
],
},
{ {
name: 'manage_custom_fields', name: 'manage_custom_fields',
description: 'List, create, rename, recolour, or delete project-level custom field definitions', description: 'List, create, rename, recolour, or delete project-level custom field definitions',
@@ -105,15 +117,17 @@ Flags (flag):
- Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals). - Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals).
- The operator view shows a countdown to the next flagged event — use sparingly for maximum impact. - The operator view shows a countdown to the next flagged event — use sparingly for maximum impact.
Colours: Structure and colours:
- Ask the user what colour convention they use before applying any colours. - Read the ontime://style-guide resource before deciding on structure or colours.
- Common pattern: one colour per event type (keynotes, panels, breaks, meals). - Propose a group per agenda section (morning block, keynotes, breaks) rather than a flat list — in ontime_batch_create_entries this is just a group entry with \`children\`.
- Colours are hex strings: \`#RRGGBB\`. - Ask the user what colour convention they use; if they have none, propose one from the default Ontime palette in the style guide: calm hues for normal content, red only for attention items, orange for changeovers, dark tone on a group with the light tone of the same hue on its children. Colours are hex strings: \`#RRGGBB\`.
Custom fields (custom): Custom fields (custom):
- Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker). - Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker).
- Store values per entry at \`custom: { <fieldKey>: <value> }\`only use keys that exist in the project. - Store values per entry at \`custom: { <fieldKey>: <value> }\`keys are case-sensitive.
- If the user's requested field is ambiguous, show the existing field list before choosing a key, so you avoid duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`. - Reuse an existing field when it covers the concept (avoid duplicates such as \`Cam\`, \`camera\`, \`Cameras\`); when the agenda carries structured data with no matching field (speakers, locations), create it with ontime_create_custom_field and use the returned key — no confirmation needed.
After creating the rundown, read ontime://views and suggest how the team can follow it — e.g. a timer view for speakers, a backstage view for crew, or an operator view subscribed to a department custom field.
Agenda: Agenda:
${args.agenda}`, ${args.agenda}`,
@@ -162,6 +176,7 @@ Schedule integrity:
- Events with \`duration\` of 0 or negative - Events with \`duration\` of 0 or negative
- Events with \`gap < 0\`: overlaps the previous timed event and is a conflict - Events with \`gap < 0\`: overlaps the previous timed event and is a conflict
- Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional - Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional
- Long segments scheduled back-to-back with no changeover or buffer between them: one overrun will cascade — suggest a buffer event
- Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional - Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional
- Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional - Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional
@@ -181,6 +196,13 @@ Skipped events:
Totals: Totals:
- Total rundown duration and whether it matches the user's expected show length (ask if unknown) - Total rundown duration and whether it matches the user's expected show length (ask if unknown)
Readability (report at INFO level; see ontime://style-guide for the conventions):
- Colour usage without a consistent convention, or key segments left uncoloured
- Long runs of related ungrouped events that would read better as groups
- Duplicate cues (cues are automation handles and should be unique) or mixed cue naming conventions
- Structured department data in titles/notes that belongs in custom fields
- If several readability issues surface, suggest running the tidy_rundown prompt to fix them systematically
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`, Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
); );
} }
@@ -215,6 +237,34 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
); );
} }
if (name === 'tidy_rundown') {
const focus = args.focus ? `\nFocus specifically on: "${args.focus}".` : '';
return userPrompt(
'Audit and improve rundown readability',
`Audit the current Ontime rundown for readability and tidiness, then propose improvements.${focus}
Steps:
1. Read the ontime://style-guide resource — it defines the conventions to audit against, including the standard colour palette.
2. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
3. Call ontime_get_rundown with the chosen \`rundownId\`, and ontime_get_custom_fields for the project field definitions.
4. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it.
Audit for:
- Colour consistency: entries coloured outside a discernible convention, missing colours on key segments, red/orange used for normal content (the style guide reserves them for attention items and changeovers), or group/children colours that ignore the dark/light tone pairing. Prefer the default Ontime palette.
- Grouping: long runs (roughly 4+) of related consecutive top-level events that would read better as a named group; groups missing \`targetDuration\`.
- Buffers: long segments back-to-back with no changeover event between them; propose short buffer events so one overrun cannot cascade.
- Cue naming: mixed conventions (numeric vs semantic) without apparent intent, duplicate cues, or gaps that suggest mistakes. Remember cues are automation handles — renaming them can break external triggers, so flag renames explicitly.
- Data in the wrong place: structured department data buried in titles or notes (e.g. "CAM 2", "GFX: lower third") that belongs in a custom field; overlong titles (over ~50 chars).
- Entry types: zero-duration events that should be milestones; milestones that carry timing expectations and should be events.
- Flags: \`flag: true\` on many events dilutes its meaning — critical markers only.
Then:
5. Present the findings as a short, scannable proposal (what changes, on which entries, and why) and WAIT for the user to approve before mutating anything. Be concise — a tight list beats prose.
6. Apply approved changes with the cheapest calls: ontime_group_entries to group existing entries, ontime_batch_update_entries when several entries get the same values (e.g. recolouring), ontime_update_entry for per-entry changes, ontime_create_custom_field when moving data into a new field.
7. Finish by reading ontime://views and suggesting views that put the improved structure to work — e.g. an operator view subscribed to a department custom field, or a cuesheet for cross-department editing.`,
);
}
if (name === 'manage_custom_fields') { if (name === 'manage_custom_fields') {
return userPrompt( return userPrompt(
'List, create, rename, recolour, or delete custom field definitions', 'List, create, rename, recolour, or delete custom field definitions',
@@ -223,14 +273,14 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project. Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project.
Steps: Steps:
1. Call ontime_get_custom_fields to see what fields already exist. This is mandatory before creating — to avoid duplicates such as "Cam", "camera", and "Cameras". 1. Call ontime_get_custom_fields to see what fields already exist. Reuse an existing field when it covers the concept — avoid duplicates such as "Cam", "camera", and "Cameras".
2. Carry out the instruction using the tools below. Confirm destructive changes (rename, delete) with the user before calling. 2. Carry out the instruction using the tools below. Creating a field is non-destructive — do it directly. Confirm destructive changes (rename, delete) with the user before calling.
Creating a field: Creating a field:
- Call ontime_create_custom_field with { label, type, colour }. - Call ontime_create_custom_field with { label, type, colour }.
- label: human-readable name (alphanumeric with spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle"). Confirm the derived key with the user before creating. - label: human-readable name (letters, numbers and spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle").
- type: "text" for short string values, "image" for image URLs. Cannot be changed after creation. - type: "text" for short string values, "image" for image URLs. Cannot be changed after creation.
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. Ask the user what colour to use if not specified. - colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. If unspecified, pick one from the default Ontime palette matching the department.
- Returns: { key, customFields } — use the returned key when setting values on entries. - Returns: { key, customFields } — use the returned key when setting values on entries.
Renaming or recolouring a field: Renaming or recolouring a field:
@@ -242,7 +292,9 @@ Deleting a field:
- Call ontime_delete_custom_field with { key }. - Call ontime_delete_custom_field with { key }.
- Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting. - Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting.
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.`, After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.
When a field was created for a department (camera, graphics, audio…), read ontime://views and offer the views that surface it: the cuesheet shows the field as a column for cross-department editing, and the operator view can display or highlight it for that department, e.g. /op?main=title&secondary-src=custom-<key>&subscribe=<key>.`,
); );
} }
+19
View File
@@ -4,6 +4,7 @@ import { getProjectData } from '../api-data/project-data/projectData.dao.js';
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js'; import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js'; import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../classes/data-provider/DataProvider.js';
import { ONTIME_STYLE_GUIDE_MARKDOWN, ONTIME_VIEWS_MARKDOWN } from './mcp.guides.js';
import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js'; import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js';
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
@@ -45,6 +46,22 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.', 'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
mimeType: 'application/json', mimeType: 'application/json',
}, },
{
uri: 'ontime://style-guide',
name: 'ontime-style-guide',
title: 'Rundown style guide',
description:
'Best practices for tidy, easy-to-read rundowns: grouping, colour conventions and the default Ontime palette, cue naming, custom fields vs notes, timing hygiene. Read before creating or restyling a rundown.',
mimeType: 'text/markdown',
},
{
uri: 'ontime://views',
name: 'ontime-views',
title: 'Views and sharing guide',
description:
'What each Ontime view (timer, backstage, operator, cuesheet, timeline, countdown, studio clock, project info) is for, key URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.',
mimeType: 'text/markdown',
},
{ {
uri: 'ontime://docs', uri: 'ontime://docs',
name: 'ontime-docs', name: 'ontime-docs',
@@ -57,6 +74,8 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = { const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = {
'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN }, 'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN },
'ontime://style-guide': { mimeType: 'text/markdown', read: () => ONTIME_STYLE_GUIDE_MARKDOWN },
'ontime://views': { mimeType: 'text/markdown', read: () => ONTIME_VIEWS_MARKDOWN },
'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN }, 'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN },
'ontime://rundown/current': { 'ontime://rundown/current': {
mimeType: 'application/json', mimeType: 'application/json',
+17 -5
View File
@@ -49,7 +49,8 @@ export const EVENT_WRITABLE_FIELDS = {
note: { type: 'string', description: 'Free-text note for production notes or references' }, note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: { colour: {
type: 'string', type: 'string',
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use', description:
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
}, },
skip: { type: 'boolean', description: 'If true, event is skipped during playback' }, skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
flag: { flag: {
@@ -60,7 +61,7 @@ export const EVENT_WRITABLE_FIELDS = {
type: 'object', type: 'object',
additionalProperties: { type: 'string' }, additionalProperties: { type: 'string' },
description: description:
'Custom field values keyed by existing project field key, e.g. { "camera": "CAM 2" }. Get available keys with ontime_get_custom_fields. Adding new custom field definitions is a separate project-level operation.', 'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
}, },
...EVENT_TIMER_FIELDS, ...EVENT_TIMER_FIELDS,
} as const; } as const;
@@ -180,12 +181,11 @@ Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
Managing field definitions: Managing field definitions:
- Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\` - Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\`
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Confirm the derived key before creating. Returns \`{ key, customFields }\`. - Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Returns \`{ key, customFields }\`.
- Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed. - Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed.
- Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first. - Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first.
When setting \`custom\` values on entries, only use existing field keys. If a key does not exist, create it with \`ontime_create_custom_field\` first. Keys are case-sensitive. When setting \`custom\` values, reuse an existing field when it covers the concept; otherwise create the missing field — creation is non-destructive and needs no confirmation. Rename and delete are the destructive operations: confirm those with the user.
Show the existing field list before creating to avoid duplicates such as \`Cam\`, \`camera\`, and \`Cameras\`.
## Targeting rundowns ## Targeting rundowns
Entry read/write tools accept an optional \`rundownId\`. Entry read/write tools accept an optional \`rundownId\`.
@@ -199,6 +199,8 @@ When playback is not \`stop\`, mutations to the loaded rundown affect the live r
## Available resources ## Available resources
- \`ontime://schema\` — this document - \`ontime://schema\` — this document
- \`ontime://style-guide\` — rundown design best practices: grouping, colour palette, cue naming, custom field usage. Read before creating or restyling a rundown.
- \`ontime://views\` — Ontime's role-specific views, URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.
- \`ontime://rundown/current\` — the currently loaded rundown (JSON) - \`ontime://rundown/current\` — the currently loaded rundown (JSON)
- \`ontime://rundowns\` — all rundowns in the project (JSON) - \`ontime://rundowns\` — all rundowns in the project (JSON)
- \`ontime://project/info\` — project metadata (JSON) - \`ontime://project/info\` — project metadata (JSON)
@@ -234,6 +236,16 @@ Main site: https://docs.getontime.no
- OSC Integration: https://docs.getontime.no/api/osc/ - OSC Integration: https://docs.getontime.no/api/osc/
- Make custom views: https://docs.getontime.no/features/custom-views/ - Make custom views: https://docs.getontime.no/features/custom-views/
## Views
- Configuring views: https://docs.getontime.no/quick-tips/configuring-views/
- Operator view: https://docs.getontime.no/interface/production/operator/
- Cuesheet: https://docs.getontime.no/interface/production/cuesheet/
- Render views in third-party apps: https://docs.getontime.no/quick-tips/render-elsewhere/
## Quick tips
- Managing delays: https://docs.getontime.no/quick-tips/managing-delays/
- Smart time entry: https://docs.getontime.no/quick-tips/smart-time-entry/
## Helpful references ## Helpful references
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/ - Ontime Cloud: https://docs.getontime.no/ontime-cloud/
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/ - Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
+31 -5
View File
@@ -12,6 +12,7 @@ import {
Rundown, Rundown,
SupportedEntry, SupportedEntry,
} from 'ontime-types'; } from 'ontime-types';
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js'; import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { import {
@@ -110,11 +111,21 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
const available = Object.keys(customFields); const available = Object.keys(customFields);
const hint = const hint =
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.'; available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
// keys are case-sensitive; a near-miss on casing is the most common mistake
const suggestions = [...unknownKeys]
.map((key) => {
const match = available.find((existing) => existing.toLowerCase() === key.toLowerCase());
return match ? `"${key}" → "${match}"` : null;
})
.filter(Boolean);
const caseHint =
suggestions.length > 0 ? ` Keys are case-sensitive — did you mean: ${suggestions.join(', ')}?` : '';
throw new Error( throw new Error(
`Unknown custom field key(s): ${missing}. ${hint} ` + `Unknown custom field key(s): ${missing}. ${hint}${caseHint} ` +
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` + `Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
`the key is auto-derived from the label (spaces → underscores, e.g. label "Camera" → key "Camera"). ` + `the key is auto-derived from the label (spaces → underscores, e.g. label "Mix Output" → key "Mix_Output").`,
`Call ontime_get_custom_fields to list existing keys.`,
); );
} }
} }
@@ -333,8 +344,23 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids:
} }
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) { export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
const updated = await createCustomField({ label: args.label, type: args.type, colour: args.colour }); const label = args.label?.trim();
const key = Object.keys(updated).find((k) => updated[k].label === args.label) ?? args.label; // same constraint the HTTP route enforces in customFields.validation.ts
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
throw new Error('Invalid label: use letters, numbers and spaces only, e.g. "Camera Angle".');
}
const key = customFieldLabelToKey(label);
const existingFields = getProjectCustomFields();
const clash = Object.keys(existingFields).find((existing) => existing.toLowerCase() === key.toLowerCase());
if (clash) {
throw new Error(
`A custom field with key "${clash}" (label "${existingFields[clash].label}") already exists. ` +
`Reuse it in entry.custom["${clash}"] instead of creating a duplicate, or pick a clearly different label.`,
);
}
const updated = await createCustomField({ label, type: args.type, colour: args.colour });
return { key, customFields: updated }; return { key, customFields: updated };
} }
+9 -4
View File
@@ -186,7 +186,11 @@ export const TOOL_DEFINITIONS = [
ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' }, ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' },
title: { type: 'string', description: 'Group title shown in the rundown and views' }, title: { type: 'string', description: 'Group title shown in the rundown and views' },
note: { type: 'string', description: 'Free-text group note for production notes or references' }, note: { type: 'string', description: 'Free-text group note for production notes or references' },
colour: { type: 'string', description: 'Hex colour (#RRGGBB) for the group' }, colour: {
type: 'string',
description:
'Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide',
},
custom: { custom: {
type: 'object', type: 'object',
additionalProperties: { type: 'string' }, additionalProperties: { type: 'string' },
@@ -377,7 +381,7 @@ export const TOOL_DEFINITIONS = [
{ {
name: 'ontime_create_custom_field', name: 'ontime_create_custom_field',
description: description:
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). After creation, use the key in entry.custom when creating or updating entries.', 'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). Creation is non-destructive — check ontime_get_custom_fields for an existing field covering the concept, and if none exists create directly without asking the user. After creation, use the returned key in entry.custom.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
required: ['label', 'type', 'colour'], required: ['label', 'type', 'colour'],
@@ -385,7 +389,7 @@ export const TOOL_DEFINITIONS = [
label: { label: {
type: 'string', type: 'string',
description: description:
'Human-readable label (alphanumeric with spaces, e.g. "Camera"). Determines the key — ask the user to confirm before creating to avoid duplicates like "Cam", "camera", "Cameras".', 'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".',
}, },
type: { type: {
type: 'string', type: 'string',
@@ -395,7 +399,8 @@ export const TOOL_DEFINITIONS = [
}, },
colour: { colour: {
type: 'string', type: 'string',
description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.', description:
'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet — for department fields, match the department colour convention (see ontime://style-guide).',
}, },
}, },
}, },