mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 15:09:10 +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', () => {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
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>.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user