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).', }, }, },