Compare commits

..

2 Commits

Author SHA1 Message Date
Claude 3134e5027a fix(mcp): do not set event end actions unless explicitly requested
End actions (load-next, play-next) create playback automations that remove
operator control between events and can surprise users. The agenda-building
prompt previously encouraged play-next for back-to-back segments, causing the
agent to add end actions proactively.

Update the endAction field description and the create_rundown_from_agenda
prompt so end actions are only applied when the user explicitly asks for
automatic cueing or chaining; a seamless schedule no longer implies automation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u3XRF6QwHZNB5o69xJ7UB
2026-07-01 09:16:32 +00:00
Claude 94024a7cdf feat(mcp): add custom field management tools
Adds three new MCP tools for managing project-level custom field definitions:
- ontime_create_custom_field: create a field with label, type, colour; key is auto-derived from label
- ontime_update_custom_field: rename or recolour an existing field (cascades key renames to all entries)
- ontime_delete_custom_field: remove a field and its values from all entries across all rundowns

Also improves the assertKnownCustomFields error to guide the agent toward creating the
missing field rather than just reporting it as unknown, and adds a manage_custom_fields
prompt for guided field management workflows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u3XRF6QwHZNB5o69xJ7UB
2026-07-01 09:24:00 +02:00
4 changed files with 158 additions and 9 deletions
+47 -3
View File
@@ -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 {
@@ -79,9 +90,11 @@ Count to end (countToEnd):
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
End action (endAction):
- \`none\` (default): stops at end; operator must manually start the next event.
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
- Leave \`endAction\` unset unless the user explicitly asks for automatic cueing or chaining. It defaults to \`none\`.
- \`load-next\` and \`play-next\` create playback automations that remove operator control between events and can surprise operators. Do NOT add them just because events are scheduled back-to-back — a seamless schedule does not imply automated playback.
- \`none\` (default): stops at end; operator manually starts the next event. Use this for essentially every event unless told otherwise.
- \`load-next\`: pre-arms the next event; operator triggers start. Only set when the user explicitly asks for it.
- \`play-next\`: automatically starts the next event. Only set when the user explicitly asks for an automatic playback chain; confirm they understand it removes operator control between those events.
Linking (linkStart):
- \`linkStart\` controls schedule-change propagation through the rundown.
@@ -203,5 +216,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}`);
}
+12 -4
View File
@@ -21,7 +21,8 @@ export const EVENT_TIMER_FIELDS = {
endAction: {
type: 'string',
enum: ['none', 'load-next', 'play-next'],
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
description:
'Action when the event ends. Defaults to none. load-next and play-next create playback automations (load-next arms the next event, play-next auto-starts it) that remove operator control between events and can surprise operators. Only set load-next or play-next when the user explicitly asks for automatic cueing or chaining; otherwise omit this field and leave it as none. Do not add end actions just because events are back-to-back.',
},
linkStart: {
type: 'boolean',
@@ -116,7 +117,7 @@ There are four entry types discriminated by \`type\`:
dayOffset: number // runtime calculated day offset from the rundown start schedule, increments when the rundown crosses midnight
gap: number // schedule gap between sequential playable events; negative gap means overlap
timerType: 'count-down' | 'count-up' | 'clock' | 'none'
endAction: 'none' | 'load-next' | 'play-next'
endAction: 'none' | 'load-next' | 'play-next' // load-next/play-next create playback automations; leave as none unless the user explicitly asks for cueing/chaining
linkStart: boolean // chain start to previous event's end
countToEnd: boolean // advanced mode: counts to scheduled timeEnd instead of duration; confirm before enabling
skip: boolean // event is skipped during playback
@@ -177,8 +178,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\`.
+30 -2
View File
@@ -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<EntryFieldArgs['c
}
if (unknownKeys.size > 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 };
}
+69
View File
@@ -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<ToolName, (args: Record<string, unknown>) => 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) => {