diff --git a/apps/client/src/common/utils/__tests__/linkUtils.test.ts b/apps/client/src/common/utils/__tests__/linkUtils.test.ts
index 32173dfd5..5c3bded19 100644
--- a/apps/client/src/common/utils/__tests__/linkUtils.test.ts
+++ b/apps/client/src/common/utils/__tests__/linkUtils.test.ts
@@ -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');
+ });
+});
diff --git a/apps/client/src/common/utils/linkUtils.ts b/apps/client/src/common/utils/linkUtils.ts
index d23a21133..dcf2d8077 100644
--- a/apps/client/src/common/utils/linkUtils.ts
+++ b/apps/client/src/common/utils/linkUtils.ts
@@ -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}`;
+}
diff --git a/apps/client/src/features/app-settings/panel/settings-panel/McpSection.tsx b/apps/client/src/features/app-settings/panel/settings-panel/McpSection.tsx
index 0e53d06b7..59c64aa1b 100644
--- a/apps/client/src/features/app-settings/panel/settings-panel/McpSection.tsx
+++ b/apps/client/src/features/app-settings/panel/settings-panel/McpSection.tsx
@@ -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() {
MCP Server
Connect any MCP-compatible AI agent to Ontime using the endpoint below.
-
- {mcpEndpointUrl && {mcpEndpointUrl}}
-
-
- {mcpEndpointUrl && {mcpClientConfig}}
+
+
+
+ {mcpEndpointUrl && {mcpEndpointUrl}}
+
+
+
+ {mcpEndpointUrl && {mcpClientConfig}}
+
+
);
diff --git a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx
index ca0f31050..5b5564c4d 100644
--- a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx
+++ b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx
@@ -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]);
diff --git a/apps/client/vite.config.js b/apps/client/vite.config.js
index fe437ba1a..4bed1dde8 100644
--- a/apps/client/vite.config.js
+++ b/apps/client/vite.config.js
@@ -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,
diff --git a/apps/server/src/api-mcp/__tests__/mcp.service.test.ts b/apps/server/src/api-mcp/__tests__/mcp.service.test.ts
index e62716ce6..387645462 100644
--- a/apps/server/src/api-mcp/__tests__/mcp.service.test.ts
+++ b/apps/server/src/api-mcp/__tests__/mcp.service.test.ts
@@ -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();
+ });
});
diff --git a/apps/server/src/api-mcp/__tests__/mcp.wiring.test.ts b/apps/server/src/api-mcp/__tests__/mcp.wiring.test.ts
new file mode 100644
index 000000000..2031c2a44
--- /dev/null
+++ b/apps/server/src/api-mcp/__tests__/mcp.wiring.test.ts
@@ -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');
+ });
+});
diff --git a/apps/server/src/api-mcp/mcp.guides.ts b/apps/server/src/api-mcp/mcp.guides.ts
new file mode 100644
index 000000000..b9c4aff7e
--- /dev/null
+++ b/apps/server/src/api-mcp/mcp.guides.ts
@@ -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://:/\`. 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-\`.
+- \`subscribe=\`: 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/
+`;
diff --git a/apps/server/src/api-mcp/mcp.prompts.ts b/apps/server/src/api-mcp/mcp.prompts.ts
index 65a1422ce..dd08f19dc 100644
--- a/apps/server/src/api-mcp/mcp.prompts.ts
+++ b/apps/server/src/api-mcp/mcp.prompts.ts
@@ -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: { : }\` — 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: { : }\` — 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-&subscribe=.`,
);
}
diff --git a/apps/server/src/api-mcp/mcp.resources.ts b/apps/server/src/api-mcp/mcp.resources.ts
index 3b2b94410..8c7439c9c 100644
--- a/apps/server/src/api-mcp/mcp.resources.ts
+++ b/apps/server/src/api-mcp/mcp.resources.ts
@@ -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 }> = {
'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',
diff --git a/apps/server/src/api-mcp/mcp.schema.ts b/apps/server/src/api-mcp/mcp.schema.ts
index 2fb001a69..a3303cfd8 100644
--- a/apps/server/src/api-mcp/mcp.schema.ts
+++ b/apps/server/src/api-mcp/mcp.schema.ts
@@ -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/
diff --git a/apps/server/src/api-mcp/mcp.service.ts b/apps/server/src/api-mcp/mcp.service.ts
index 1fd11f0de..a467db90a 100644
--- a/apps/server/src/api-mcp/mcp.service.ts
+++ b/apps/server/src/api-mcp/mcp.service.ts
@@ -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 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 };
}
diff --git a/apps/server/src/api-mcp/mcp.tools.ts b/apps/server/src/api-mcp/mcp.tools.ts
index 333f8eb07..e5cfd2f47 100644
--- a/apps/server/src/api-mcp/mcp.tools.ts
+++ b/apps/server/src/api-mcp/mcp.tools.ts
@@ -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).',
},
},
},