mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
refactor(mcp): guide agents through creating clean rundowns
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { linkToOtherHost } from '../linkUtils';
|
||||
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils';
|
||||
|
||||
describe('linkToOTherHost', () => {
|
||||
it('should handle electron links', () => {
|
||||
@@ -22,3 +22,18 @@ describe('linkToOTherHost', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,3 +54,17 @@ export function linkToOtherHost(
|
||||
}
|
||||
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 CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import { serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function McpSection() {
|
||||
const { data: infoData } = useInfo();
|
||||
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
||||
|
||||
// generate url
|
||||
useEffect(() => {
|
||||
const baseUrl = (() => {
|
||||
if (isOntimeCloud) return serverURL;
|
||||
|
||||
// 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;
|
||||
})();
|
||||
|
||||
// the page origin is reachable wherever the user is browsing from,
|
||||
// and follows the deployment (cloud, reverse proxy, port mappings)
|
||||
// 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)
|
||||
.catch(() => {
|
||||
setMcpEndpointUrl('');
|
||||
});
|
||||
}, [infoData]);
|
||||
}, []);
|
||||
|
||||
const mcpClientConfig = mcpEndpointUrl
|
||||
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
|
||||
@@ -40,14 +30,19 @@ export default function McpSection() {
|
||||
<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.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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.Section>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import useInfo from '../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../common/hooks-query/useUrlPresets';
|
||||
import { hostToBaseUrl } from '../../common/utils/linkUtils';
|
||||
import GenerateLinkForm from './GenerateLinkForm';
|
||||
|
||||
interface GenerateLinkFormExportProps {
|
||||
@@ -21,7 +22,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
||||
*/
|
||||
const hostOptions = useMemo(() => {
|
||||
return infoData.networkInterfaces.map((nif) => ({
|
||||
value: `http://${nif.address}:${infoData.serverPort}`,
|
||||
value: hostToBaseUrl(nif.address, infoData.serverPort),
|
||||
label: `${nif.name} - ${nif.address}`,
|
||||
}));
|
||||
}, [infoData.networkInterfaces, infoData.serverPort]);
|
||||
|
||||
@@ -71,6 +71,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
configure: logProxyRequests,
|
||||
},
|
||||
'^/mcp': {
|
||||
target: 'http://localhost:4001/',
|
||||
changeOrigin: true,
|
||||
configure: logProxyRequests,
|
||||
},
|
||||
'^/ws*': {
|
||||
target: 'http://localhost:4001/',
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -6,11 +6,13 @@ const editEntryMock = vi.hoisted(() => vi.fn());
|
||||
const groupEntriesMock = vi.hoisted(() => vi.fn());
|
||||
const ungroupEntriesMock = 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', () => ({
|
||||
getCurrentRundown: getCurrentRundownMock,
|
||||
getCurrentRundownId: vi.fn(() => 'loaded-rundown'),
|
||||
getProjectCustomFields: vi.fn(() => ({})),
|
||||
getProjectCustomFields: getProjectCustomFieldsMock,
|
||||
}));
|
||||
|
||||
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', () => ({
|
||||
addEntry: addEntryMock,
|
||||
batchEditEntries: vi.fn(),
|
||||
createCustomField: createCustomFieldMock,
|
||||
deleteEntries: vi.fn(),
|
||||
editEntry: editEntryMock,
|
||||
groupEntries: groupEntriesMock,
|
||||
@@ -27,7 +30,8 @@ vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||
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 {
|
||||
return {
|
||||
@@ -44,6 +48,7 @@ describe('mcp.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCurrentRundownMock.mockReturnValue(makeRundown({}));
|
||||
getProjectCustomFieldsMock.mockReturnValue({});
|
||||
|
||||
let id = 0;
|
||||
addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => {
|
||||
@@ -252,4 +257,42 @@ describe('mcp.service', () => {
|
||||
expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 — 5–10 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/
|
||||
`;
|
||||
@@ -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',
|
||||
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).
|
||||
- The operator view shows a countdown to the next flagged event — use sparingly for maximum impact.
|
||||
|
||||
Colours:
|
||||
- Ask the user what colour convention they use before applying any colours.
|
||||
- Common pattern: one colour per event type (keynotes, panels, breaks, meals).
|
||||
- Colours are hex strings: \`#RRGGBB\`.
|
||||
Structure and colours:
|
||||
- Read the ontime://style-guide resource before deciding on structure or colours.
|
||||
- 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\`.
|
||||
- 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):
|
||||
- Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker).
|
||||
- Store values per entry at \`custom: { <fieldKey>: <value> }\` — only use keys that exist in the project.
|
||||
- If the user's requested field is ambiguous, show the existing field list before choosing a key, so you avoid duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
||||
- Store values per entry at \`custom: { <fieldKey>: <value> }\` — keys are case-sensitive.
|
||||
- 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:
|
||||
${args.agenda}`,
|
||||
@@ -162,6 +176,7 @@ Schedule integrity:
|
||||
- Events with \`duration\` of 0 or negative
|
||||
- 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
|
||||
- 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 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:
|
||||
- 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).`,
|
||||
);
|
||||
}
|
||||
@@ -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') {
|
||||
return userPrompt(
|
||||
'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.
|
||||
|
||||
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".
|
||||
2. Carry out the instruction using the tools below. Confirm destructive changes (rename, delete) with the user before calling.
|
||||
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. Creating a field is non-destructive — do it directly. Confirm destructive changes (rename, delete) with the user before calling.
|
||||
|
||||
Creating a field:
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Renaming or recolouring a field:
|
||||
@@ -242,7 +292,9 @@ Deleting a field:
|
||||
- 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.
|
||||
|
||||
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>.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.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';
|
||||
|
||||
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.',
|
||||
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',
|
||||
name: 'ontime-docs',
|
||||
@@ -57,6 +74,8 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
|
||||
|
||||
const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = {
|
||||
'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://rundown/current': {
|
||||
mimeType: 'application/json',
|
||||
|
||||
@@ -49,7 +49,8 @@ export const EVENT_WRITABLE_FIELDS = {
|
||||
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',
|
||||
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' },
|
||||
flag: {
|
||||
@@ -60,7 +61,7 @@ export const EVENT_WRITABLE_FIELDS = {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
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,
|
||||
} as const;
|
||||
@@ -180,12 +181,11 @@ Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
|
||||
|
||||
Managing field definitions:
|
||||
- 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.
|
||||
- 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.
|
||||
Show the existing field list before creating to avoid duplicates such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
||||
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.
|
||||
|
||||
## Targeting rundowns
|
||||
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
|
||||
- \`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://rundowns\` — all rundowns in the project (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/
|
||||
- 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
|
||||
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/
|
||||
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
||||
import {
|
||||
@@ -110,11 +111,21 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
|
||||
const available = Object.keys(customFields);
|
||||
const hint =
|
||||
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(
|
||||
`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 — ` +
|
||||
`the key is auto-derived from the label (spaces → underscores, e.g. label "Camera" → key "Camera"). ` +
|
||||
`Call ontime_get_custom_fields to list existing keys.`,
|
||||
`the key is auto-derived from the label (spaces → underscores, e.g. label "Mix Output" → key "Mix_Output").`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -333,8 +344,23 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids:
|
||||
}
|
||||
|
||||
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 key = Object.keys(updated).find((k) => updated[k].label === args.label) ?? args.label;
|
||||
const label = args.label?.trim();
|
||||
// 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,11 @@ export const TOOL_DEFINITIONS = [
|
||||
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' },
|
||||
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: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
@@ -377,7 +381,7 @@ export const TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: 'ontime_create_custom_field',
|
||||
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: {
|
||||
type: 'object',
|
||||
required: ['label', 'type', 'colour'],
|
||||
@@ -385,7 +389,7 @@ export const TOOL_DEFINITIONS = [
|
||||
label: {
|
||||
type: 'string',
|
||||
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: 'string',
|
||||
@@ -395,7 +399,8 @@ export const TOOL_DEFINITIONS = [
|
||||
},
|
||||
colour: {
|
||||
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).',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user