diff --git a/apps/server/src/api-mcp/mcp.prompts.ts b/apps/server/src/api-mcp/mcp.prompts.ts index e82539b29..006c926dd 100644 --- a/apps/server/src/api-mcp/mcp.prompts.ts +++ b/apps/server/src/api-mcp/mcp.prompts.ts @@ -33,6 +33,17 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [ }, ], }, + { + name: 'manage_custom_fields', + description: 'List, create, rename, recolour, or delete project-level custom field definitions', + arguments: [ + { + name: 'instruction', + description: 'What to do, e.g. "add a Speaker field" or "delete the Camera field"', + required: true, + }, + ], + }, ]; function userPrompt(description: string, text: string): GetPromptResult { @@ -203,5 +214,36 @@ Efficiency tip: plan moves in the direction of the target position to minimise r ); } + if (name === 'manage_custom_fields') { + return userPrompt( + 'List, create, rename, recolour, or delete custom field definitions', + `Manage the project-level custom field definitions for this Ontime project: "${args.instruction}" + +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. + +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. +- 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. +- Returns: { key, customFields } — use the returned key when setting values on entries. + +Renaming or recolouring a field: +- Call ontime_update_custom_field with { key, label?, colour? }. +- Warning: changing the label changes the derived key and cascades to all entry references in all rundowns. Confirm with the user before renaming. +- The field type cannot be changed. + +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.`, + ); + } + throw new Error(`Unknown prompt: ${name}`); } diff --git a/apps/server/src/api-mcp/mcp.schema.ts b/apps/server/src/api-mcp/mcp.schema.ts index b5d32b7ee..131c27de6 100644 --- a/apps/server/src/api-mcp/mcp.schema.ts +++ b/apps/server/src/api-mcp/mcp.schema.ts @@ -177,8 +177,15 @@ Examples: ## Custom fields Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`. Events, milestones and groups store values at \`entry.custom[fieldKey]\`. -Only use existing field keys when setting \`custom\` values. Adding, renaming, or deleting custom field definitions is a separate project-level operation. -When the user wants to assign custom values, show the existing custom field list first if there is any ambiguity, so you do not create duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`. + +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 }\`. +- 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\`. ## Targeting rundowns Entry read/write tools accept an optional \`rundownId\`. diff --git a/apps/server/src/api-mcp/mcp.service.ts b/apps/server/src/api-mcp/mcp.service.ts index 9b4a25dd2..892013f80 100644 --- a/apps/server/src/api-mcp/mcp.service.ts +++ b/apps/server/src/api-mcp/mcp.service.ts @@ -17,7 +17,10 @@ import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from ' import { addEntry, batchEditEntries, + createCustomField, + deleteCustomField, deleteEntries, + editCustomField, editEntry, groupEntries, reorderEntry, @@ -103,8 +106,15 @@ export function assertKnownCustomFields(...customValues: Array 0) { - const keys = [...unknownKeys].join(', '); - throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`); + const missing = [...unknownKeys].join(', '); + const available = Object.keys(customFields); + const hint = available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.'; + throw new Error( + `Unknown custom field key(s): ${missing}. ${hint} ` + + `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.`, + ); } } @@ -320,3 +330,21 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: const rundown = await batchEditEntries(rundownId, args.ids, args.data); return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order }; } + +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; + return { key, customFields: updated }; +} + +export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) { + const projectRundowns = getDataProvider().getProjectRundowns(); + const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns); + return { customFields: updated }; +} + +export async function deleteCustomFieldForMcp(args: { key: string }) { + const projectRundowns = getDataProvider().getProjectRundowns(); + const updated = await deleteCustomField(args.key, projectRundowns); + return { customFields: updated }; +} diff --git a/apps/server/src/api-mcp/mcp.tools.ts b/apps/server/src/api-mcp/mcp.tools.ts index 03bd3ea78..e629a5fc7 100644 --- a/apps/server/src/api-mcp/mcp.tools.ts +++ b/apps/server/src/api-mcp/mcp.tools.ts @@ -25,7 +25,9 @@ import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js'; import { batchCreateEntriesForMcp, batchUpdateEntriesForMcp, + createCustomFieldForMcp, createEntryForMcp, + deleteCustomFieldForMcp, deleteEntriesForMcp, findEntry, getRundownById, @@ -33,6 +35,7 @@ import { reorderEntryForMcp, toRundownList, ungroupEntryForMcp, + updateCustomFieldForMcp, updateEntryForMcp, type BatchCreateEntryArgs, type CreateEntryArgs, @@ -371,6 +374,60 @@ export const TOOL_DEFINITIONS = [ inputSchema: { type: 'object', properties: {} }, annotations: READ, }, + { + 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.', + inputSchema: { + type: 'object', + required: ['label', 'type', 'colour'], + properties: { + 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".', + }, + type: { + type: 'string', + enum: ['text', 'image'], + description: 'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.', + }, + colour: { + type: 'string', + description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.', + }, + }, + }, + annotations: WRITE, + }, + { + name: 'ontime_update_custom_field', + description: + 'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.', + inputSchema: { + type: 'object', + required: ['key'], + properties: { + key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' }, + label: { type: 'string', description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.' }, + colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' }, + }, + }, + annotations: WRITE_IDEM, + }, + { + name: 'ontime_delete_custom_field', + description: + 'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.', + inputSchema: { + type: 'object', + required: ['key'], + properties: { + key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' }, + }, + }, + annotations: WRITE_DESTRUCTIVE, + }, // --- Project file management --- { name: 'ontime_list_projects', @@ -571,6 +628,18 @@ const TOOL_HANDLERS: Record) => Promise ontime_get_custom_fields: async () => ok(getProjectCustomFields()), + ontime_create_custom_field: async (args) => { + return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string })); + }, + + ontime_update_custom_field: async (args) => { + return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string })); + }, + + ontime_delete_custom_field: async (args) => { + return ok(await deleteCustomFieldForMcp(args as { key: string })); + }, + ontime_list_projects: async () => ok(await getProjectList()), ontime_load_project: async (args) => {