diff --git a/.gitignore b/.gitignore index b3e2b74b51..c10f0eff54 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ ontime-data/ # temporary write files **.tmp + +# Claude Code metadata +.claude/ diff --git a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx index f88024a3e0..101b0c2a74 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx @@ -4,6 +4,7 @@ import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import InfoNif from '../network-panel/NetworkInterfaces'; +import McpSection from './McpSection'; import ReportSettings from './ReportSettings'; import URLPresets from './URLPresets'; @@ -11,6 +12,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) { const presetsRef = useScrollIntoView('presets', location); const linkRef = useScrollIntoView('link', location); const reportRef = useScrollIntoView('report', location); + const mcpRef = useScrollIntoView('mcp', location); return ( <> @@ -33,6 +35,9 @@ export default function FeaturePanel({ location }: PanelBaseProps) { +
+ +
diff --git a/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx b/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx new file mode 100644 index 0000000000..37addf1d1e --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/McpSection.tsx @@ -0,0 +1,49 @@ +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 * as Panel from '../../panel-utils/PanelUtils'; + +/** MCP endpoint card — isolated so that URL state changes don't re-render the rest of FeaturePanel */ +export default function McpSection() { + const { data: infoData } = useInfo(); + const [mcpEndpointUrl, setMcpEndpointUrl] = useState(''); + + useEffect(() => { + const baseUrl = isOntimeCloud + ? serverURL + : infoData.networkInterfaces.length > 0 + ? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}` + : serverURL; + + generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false }) + .then(setMcpEndpointUrl) + .catch(() => { + setMcpEndpointUrl(''); + }); + }, [infoData]); + + const mcpClientConfig = mcpEndpointUrl + ? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2) + : ''; + + return ( + + + MCP Server + Connect any MCP-compatible AI agent to Ontime using the endpoint below. + + + {mcpEndpointUrl && {mcpEndpointUrl}} + + + {mcpEndpointUrl && {mcpClientConfig}} + + + ); +} diff --git a/apps/server/package.json b/apps/server/package.json index b6a1c07fb8..e749c43bc0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,6 +5,7 @@ "version": "4.9.0", "exports": "./src/index.js", "dependencies": { + "@modelcontextprotocol/sdk": "^1.15.0", "@googleapis/sheets": "^5.0.5", "cookie": "1.0.2", "cookie-parser": "1.4.7", diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 994b43c70c..0f8ed619f6 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -15,16 +15,18 @@ import { createNewRundown, deleteAllEntries, deleteEntries, + deleteRundown, + duplicateExistingRundown, editEntry, groupEntries, - initRundown, loadRundown, + renameRundown, renumberEntries, reorderEntry, swapEvents, ungroupEntries, } from './rundown.service.js'; -import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js'; +import { normalisedToRundownArray } from './rundown.utils.js'; import { clonePostValidator, entryBatchPutValidator, @@ -104,13 +106,7 @@ router.post( paramsWithId, async (req: Request, res: Response) => { try { - const dataProvider = getDataProvider(); - const rundown = dataProvider.getRundown(req.params.id); - - const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`); - await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown); - - const projectRundowns = getDataProvider().getProjectRundowns(); + const projectRundowns = await duplicateExistingRundown(req.params.id); res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); } catch (error) { const message = getErrorMessage(error); @@ -125,24 +121,9 @@ router.post( */ router.patch('/:id', paramsWithId, async (req: Request, res: Response) => { try { - const dataProvider = getDataProvider(); - const rundown = dataProvider.getRundown(req.params.id); - if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`); if (!req.body.title) throw new Error('No title provided'); - await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title }); - - /** - * If loaded we re-init the rundown - * This is likely over-kill but the simplest way to ensure state consistency - */ - if (req.params.id === getCurrentRundown().id) { - const rundown = dataProvider.getRundown(req.params.id); - const customField = dataProvider.getCustomFields(); - await initRundown(rundown, customField); - } - - const projectRundowns = getDataProvider().getProjectRundowns(); + const projectRundowns = await renameRundown(req.params.id, req.body.title); res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); } catch (error) { const message = getErrorMessage(error); @@ -155,23 +136,8 @@ router.patch('/:id', paramsWithId, async (req: Request, res: Response) => { try { - if (req.params.id === getCurrentRundown().id) { - res.status(400).send({ message: 'Cannot delete loaded rundown' }); - return; - } - - const dataProvider = getDataProvider(); - const projectRundowns = dataProvider.getProjectRundowns(); - - if (Object.keys(projectRundowns).length <= 1) { - // might never hit this as it is likely covered by the case of trying to delete the loaded rundown - res.status(400).send({ message: 'Cannot delete the last rundown' }); - return; - } - - await dataProvider.deleteRundown(req.params.id); - const newProjectRundowns = getDataProvider().getProjectRundowns(); - res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) }); + const projectRundowns = await deleteRundown(req.params.id); + res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) }); } catch (error) { const message = getErrorMessage(error); res.status(400).send({ message }); diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 07c03ab261..1d32c8d35b 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -34,7 +34,7 @@ import { updateBackgroundRundown, } from './rundown.dao.js'; import type { RundownMetadata } from './rundown.types.js'; -import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js'; +import { duplicateRundown, generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js'; /** * creates a new entry with given data @@ -692,3 +692,55 @@ export async function createNewRundown(title: string) { return projectRundowns; } + +/** + * Renames an existing rundown + * @throws if the provided id does not exist + */ +export async function renameRundown(id: string, title: string) { + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(id); + + await dataProvider.setRundown(id, { ...rundown, title }); + + /** + * If loaded we re-init the rundown + * This is likely over-kill but the simplest way to ensure state consistency + */ + if (isCurrentRundown(id)) { + await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields()); + } + + return dataProvider.getProjectRundowns(); +} + +/** + * Duplicates an existing rundown without making it the loaded one + * @throws if the provided id does not exist + */ +export async function duplicateExistingRundown(id: string) { + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(id); + + const duplicatedRundown = duplicateRundown(rundown, `Copy of ${rundown.title}`); + await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown); + + return dataProvider.getProjectRundowns(); +} + +/** + * Deletes a rundown + * @throws if attempting to delete the loaded rundown or the last rundown in the project + */ +export async function deleteRundown(id: string) { + if (isCurrentRundown(id)) { + throw new Error('Cannot delete loaded rundown'); + } + + const dataProvider = getDataProvider(); + if (Object.keys(dataProvider.getProjectRundowns()).length <= 1) { + throw new Error('Cannot delete the last rundown'); + } + + return dataProvider.deleteRundown(id); +} diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 740f56cba9..8641a38a6c 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -34,43 +34,34 @@ import { import { RundownMetadata } from './rundown.types.js'; -type CompleteEntry = - T extends Partial - ? OntimeEvent - : T extends Partial - ? OntimeDelay - : T extends Partial - ? OntimeGroup - : T extends Partial - ? OntimeMilestone - : never; - /** * Generates a fully formed RundownEntry of the patch type */ -export function generateEvent< - T extends Partial | Partial | Partial | Partial, ->(rundown: Rundown, eventData: T, afterId: EntryId | null, parent?: EntryId): CompleteEntry { +export function generateEvent( + rundown: Rundown, + eventData: Partial | Partial | Partial | Partial, + afterId: EntryId | null, + parent?: EntryId, +): OntimeEntry { if (isOntimeEvent(eventData)) { - return createEvent( - eventData, - getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent), - ) as CompleteEntry; + const event = createEvent(eventData, getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent)); + if (!event) throw new Error('Invalid event type'); + return event; } const id = eventData.id || getUniqueId(rundown); if (isOntimeDelay(eventData)) { - return createDelay({ duration: eventData.duration ?? 0, id }) as CompleteEntry; + return createDelay({ duration: eventData.duration ?? 0, id }); } // TODO(v4): allow user to provide a larger patch of the group entry if (isOntimeGroup(eventData)) { - return createGroup({ id, title: eventData.title ?? '' }) as CompleteEntry; + return createGroup({ id, title: eventData.title ?? '' }); } if (isOntimeMilestone(eventData)) { - return createMilestone({ ...eventData, id }) as CompleteEntry; + return createMilestone({ ...eventData, id }); } throw new Error('Invalid event type'); diff --git a/apps/server/src/api-mcp/MCP.md b/apps/server/src/api-mcp/MCP.md new file mode 100644 index 0000000000..9bdf0d3e79 --- /dev/null +++ b/apps/server/src/api-mcp/MCP.md @@ -0,0 +1,65 @@ +# Ontime MCP Server + +Ontime exposes an MCP server over Streamable HTTP. + +## Endpoint + +Default local URL: + +```text +http://localhost:4001/mcp +``` + +If Ontime is running on another host or port, replace `localhost:4001` with that address. +If `ROUTER_PREFIX` is configured, include it before `/mcp`, for example: + +```text +http://localhost:4001/stage/mcp +``` + +The MCP route is stateless. Clients should send MCP requests with `POST`; `GET` and +`DELETE` are not used. + +## Authentication + +If Ontime has no session password configured, no MCP authentication is required. + +If a session password is configured, authenticate with the hashed Ontime token. +The settings UI generates an endpoint URL with the token in the query string: + +```text +http://localhost:4001/mcp?token= +``` + +If your MCP client supports request headers, you can send the same token as a +bearer token instead: + +```http +Authorization: Bearer +``` + +This is the same token used in authenticated Ontime share URLs as the `token` query +parameter. The raw session password is not accepted as the bearer token. + +## Client Configuration + +Use a Streamable HTTP MCP client and point it at the MCP endpoint: + +```json +{ + "mcpServers": { + "ontime": { + "url": "http://localhost:4001/mcp?token=" + } + } +} +``` + +Omit the token when Ontime is not password protected. If you prefer headers, use +`"url": "http://localhost:4001/mcp"` and add an `Authorization` header with the +same bearer token. + +## Quick Check + +The server should respond to MCP initialization requests at `/mcp`. A plain browser +`GET` request will return `405 Method not allowed`, which is expected. diff --git a/apps/server/src/api-mcp/mcp.auth.ts b/apps/server/src/api-mcp/mcp.auth.ts new file mode 100644 index 0000000000..18affc588c --- /dev/null +++ b/apps/server/src/api-mcp/mcp.auth.ts @@ -0,0 +1,21 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +import { hasPassword, hashedPassword } from '../api-data/session/session.service.js'; + +/** + * Wraps the app authenticate middleware with support for the Authorization header. + * MCP clients conventionally authenticate with `Authorization: Bearer ` + * rather than cookies or query params; any other request falls through to the + * app middleware, keeping the behaviour of the shared middleware untouched. + */ +export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler { + return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) { + if (hasPassword) { + const authHeader = req.headers.authorization; + if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) { + return next(); + } + } + return fallback(req, res, next); + }; +} diff --git a/apps/server/src/api-mcp/mcp.prompts.ts b/apps/server/src/api-mcp/mcp.prompts.ts new file mode 100644 index 0000000000..fc6dd6c7da --- /dev/null +++ b/apps/server/src/api-mcp/mcp.prompts.ts @@ -0,0 +1,194 @@ +import type { GetPromptResult, ListPromptsResult } from '@modelcontextprotocol/sdk/types.js'; + +export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [ + { + name: 'create_rundown_from_agenda', + description: 'Convert a plain-text agenda into an Ontime rundown using ontime_batch_create_entries', + arguments: [{ name: 'agenda', description: 'Plain-text agenda to convert', required: true }], + }, + { + name: 'bulk_edit_rundown', + description: 'Apply a bulk change across the rundown (recolour, reschedule, skip events, etc.)', + arguments: [ + { + name: 'instruction', + description: 'What to change, e.g. "colour all keynotes blue"', + required: true, + }, + ], + }, + { + name: 'validate_rundown', + description: 'Check the current rundown for common issues: missing cues, overlaps, gaps, zero-duration events', + arguments: [], + }, + { + name: 'restructure_rundown', + description: 'Reorder events in the rundown according to an instruction', + arguments: [ + { + name: 'instruction', + description: 'How to restructure, e.g. "move all breaks to after keynotes"', + required: true, + }, + ], + }, +]; + +function userPrompt(description: string, text: string): GetPromptResult { + return { description, messages: [{ role: 'user', content: { type: 'text', text } }] }; +} + +export function handleGetPrompt(name: string, args: Record): GetPromptResult { + if (name === 'create_rundown_from_agenda') { + return userPrompt( + 'Build an Ontime rundown from a plain-text agenda', + `Convert the following agenda into an Ontime rundown. +Read the ontime://schema resource if you need a data model reference. + +Steps: +1. 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. +2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending. +3. 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. If the target is a background rundown, it can be edited without interrupting playback. +4. Build an array of events in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item. +5. If the rundown already has events, pass \`after: \` on the batch call so new events chain from the end. + +Entry type guidance: +- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals). +- Use \`milestone\` for non-timed markers that don't advance playback (e.g. "Doors open", "Broadcast start"). +- Use \`delay\` only when the user explicitly wants to model schedule drift that shifts all following events. +- Use \`group\` to collect related events into a named block. Groups are created with a title only — use ontime_update_entry afterwards to set \`colour\`, \`note\`, \`custom\`, or \`targetDuration\`. + +Event timing: +- Provide a title plus enough timing data for Ontime to infer a timing strategy. +- \`timeStart\` + \`duration\`: keeps duration fixed and calculates \`timeEnd\`. +- \`timeStart\` + \`timeEnd\`: keeps end time fixed and calculates \`duration\`. +- \`timeEnd\` + \`duration\`: calculates \`timeStart\`. +- Avoid sending \`timeStart\`, \`timeEnd\`, and \`duration\` together unless you intentionally want Ontime to prioritise duration and recalculate \`timeEnd\`. + +Timer type (timerType): +- \`count-down\` (default): counts down from duration. Use for most timed sessions. +- \`count-up\`: counts elapsed time. Use for open-ended items like Q&A or audience discussion. +- \`clock\`: shows wall-clock time. Use for broadcast-start or house-open markers. +- \`none\`: no timer shown. Use for purely informational or non-timed items. + +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. + +Linking (linkStart): +- \`linkStart\` controls schedule-change propagation through the rundown. +- When an event is linked, it inherits the end time of the previous playable event as its start time. +- The event's \`timeStrategy\` decides how it adapts to the inherited start: lock duration updates the end time; lock end updates the duration. +- Ideal for segments within a block where only the anchor start time and individual durations are managed directly. + +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\`. + +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\`. + +Agenda: +${args.agenda}`, + ); + } + + if (name === 'bulk_edit_rundown') { + return userPrompt( + 'Apply a bulk change across the rundown', + `Apply the following bulk edit to the current Ontime rundown: "${args.instruction}" + +Strategy: +1. 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. +2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current events, their IDs, and field values. +3. 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. If the target is a background rundown, it can be edited without interrupting playback. +4. Determine which event IDs are affected by the instruction. +5. If every affected entry receives the SAME field values (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_entries once with { ids, data, rundownId }. +6. If each event needs DIFFERENT field values (e.g. "shift everything 30 minutes later"): check first if events use linkStart. If they do, changing the anchor event's timeStart or duration can cascade to linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_entry for each. + +Time shift mechanics: +- All time fields are milliseconds from midnight; compute arithmetic before calling the tools. +- timeEnd - timeStart = duration. When shifting times, decide whether to keep duration fixed (timeEnd moves with timeStart) or keep timeEnd fixed (duration shrinks). Provide only the fields you intend to change — the server infers the strategy from which fields are present. +- For "shift everything N minutes later": update timeStart and timeEnd per event (or just timeStart on the anchor event of a linkStart chain). Do not use ontime_batch_update_entries for this unless every target event should receive the exact same timeStart/timeEnd values. + +Automation risks: +- Setting \`endAction: 'play-next'\` on multiple events creates an automatic playback chain that removes operator control between those events. Confirm with the user before applying. +- Bulk-setting \`skip: true\` will hide events from playback. Confirm before applying to many events.`, + ); + } + + if (name === 'validate_rundown') { + return userPrompt( + 'Check the current rundown for common issues', + `Validate the currently loaded Ontime rundown and report issues. + +Steps: +1. Call ontime_get_rundown to read all events and their fields. +2. Call ontime_get_rundown_metadata for totals (totalDuration, totalDelay, totalDays, firstStart, lastEnd, flags). + +Check and report: + +Schedule integrity: +- Events with missing \`cue\` or \`title\`: these are usually worth checking, but not necessarily errors +- 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 +- 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 + +Timing and linking: +- \`metadata.totalDays > 0\`: show spans midnight — confirm this is intentional +- \`metadata.totalDelay !== 0\`: active delay entries are shifting the schedule by this many ms; report the net shift +- Events with \`linkStart: true\` that are first in the rundown (no predecessor to link to) + +Automation: +- Events with \`endAction: 'play-next'\`: these form automatic playback chains. List each chain so the user can confirm the automation is intentional. +- Events with \`flag: true\`: these are the operator's critical markers. List them so the user can verify they are correct and complete. + +Skipped events: +- Events with \`skip: true\` — confirm with the user these are intentional + +Totals: +- Total rundown duration and whether it matches the user's expected show length (ask if unknown) + +Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`, + ); + } + + if (name === 'restructure_rundown') { + return userPrompt( + 'Reorder events in the rundown', + `Restructure the current Ontime rundown: "${args.instruction}" + +Steps: +1. 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. +2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current order, groups, and event fields. +3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP reorders the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback. +4. Note which events are inside groups (check each group's \`entries\` array vs the top-level \`order\` array). +5. Compute the target arrangement as a sequence of moves. +6. For each event that needs to move, call ontime_reorder_entry: + - \`order: 'before'\` or \`'after'\` — places the event as a sibling next to destinationId + - \`order: 'insert'\` — places the event inside a group (destinationId must be the group's ID) +7. Call ontime_get_rundown again to confirm the new order. + +Group awareness: +- Events inside a group appear in the group's \`entries\` array, not in the top-level \`order\`. +- To move an event out of a group, reorder it before/after a top-level entry. +- To move an event into a group, use \`order: 'insert'\` with the group as destinationId. +- A group's \`targetDuration\` is a planning hint only — moving events in or out does not break anything. + +Efficiency tip: plan moves in the direction of the target position to minimise reorder calls. Avoid moving the same event twice — compute the full target sequence before issuing any calls.`, + ); + } + + throw new Error(`Unknown prompt: ${name}`); +} diff --git a/apps/server/src/api-mcp/mcp.resources.ts b/apps/server/src/api-mcp/mcp.resources.ts new file mode 100644 index 0000000000..3b2b944109 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.resources.ts @@ -0,0 +1,87 @@ +import type { ListResourcesResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; + +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_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js'; + +export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [ + { + uri: 'ontime://schema', + name: 'ontime-schema', + title: 'Ontime data model reference', + description: + 'Markdown reference for rundown structure, event fields, time format, and entry types. Read once per session to ground tool calls in the correct data model.', + mimeType: 'text/markdown', + }, + { + uri: 'ontime://rundown/current', + name: 'current-rundown', + title: 'Currently loaded rundown', + description: + 'The rundown currently active in Ontime, with its full entries map and order. Re-read after any mutating call to see updated state.', + mimeType: 'application/json', + }, + { + uri: 'ontime://rundowns', + name: 'project-rundowns', + title: 'All rundowns in the project', + description: 'List of every rundown stored in the current project file, plus the ID of the one currently loaded.', + mimeType: 'application/json', + }, + { + uri: 'ontime://project/info', + name: 'project-info', + title: 'Project metadata', + description: 'Project title, description, URL, info, logo, and custom header fields.', + mimeType: 'application/json', + }, + { + uri: 'ontime://project/custom-fields', + name: 'project-custom-fields', + title: 'Custom field definitions', + description: + 'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.', + mimeType: 'application/json', + }, + { + uri: 'ontime://docs', + name: 'ontime-docs', + title: 'Ontime documentation index', + description: + 'Curated index of Ontime documentation topics with direct links to https://docs.getontime.no. Read this when you need to understand a concept in more depth or want to point the user to official documentation.', + mimeType: 'text/markdown', + }, +]; + +const RESOURCE_READERS: Record string }> = { + 'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN }, + 'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN }, + 'ontime://rundown/current': { + mimeType: 'application/json', + read: () => JSON.stringify(getCurrentRundown()), + }, + 'ontime://rundowns': { + mimeType: 'application/json', + read: () => { + const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns()); + const loaded = getCurrentRundown().id; + return JSON.stringify({ loaded, rundowns }); + }, + }, + 'ontime://project/info': { + mimeType: 'application/json', + read: () => JSON.stringify(getProjectData()), + }, + 'ontime://project/custom-fields': { + mimeType: 'application/json', + read: () => JSON.stringify(getProjectCustomFields()), + }, +}; + +export function handleReadResource(uri: string): ReadResourceResult { + const reader = RESOURCE_READERS[uri]; + if (!reader) throw new Error(`Unknown resource URI: ${uri}`); + return { contents: [{ uri, mimeType: reader.mimeType, text: reader.read() }] }; +} diff --git a/apps/server/src/api-mcp/mcp.router.ts b/apps/server/src/api-mcp/mcp.router.ts new file mode 100644 index 0000000000..b7038921ad --- /dev/null +++ b/apps/server/src/api-mcp/mcp.router.ts @@ -0,0 +1,40 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; +import type { Request, Response, Router } from 'express'; +import { LogOrigin } from 'ontime-types'; + +import { logger } from '../classes/Logger.js'; +import { createMcpServer } from './mcp.server.js'; + +export const mcpRouter: Router = express.Router(); + +mcpRouter.post('/', async (req, res) => { + // A new Server instance is created per request — required for stateless mode where + // each POST is independent and concurrent requests must not share transport state. + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless: no session tracking + enableJsonResponse: true, + }); + res.on('close', () => transport.close()); + try { + const server = createMcpServer(); + await server.connect(transport); + await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(LogOrigin.Server, `MCP request failed: ${message}`); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null }); + } + } +}); + +// Stateless mode: GET (SSE) and DELETE (session teardown) are not applicable. +// All MCP interactions happen via POST in a single request/response cycle. +const methodNotAllowed = (_req: Request, res: Response) => + void res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }); + +mcpRouter.get('/', methodNotAllowed); +mcpRouter.delete('/', methodNotAllowed); diff --git a/apps/server/src/api-mcp/mcp.schema.ts b/apps/server/src/api-mcp/mcp.schema.ts new file mode 100644 index 0000000000..e5282090f0 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.schema.ts @@ -0,0 +1,226 @@ +/** + * Agent-facing Ontime MCP documentation. + * + * Tool field schemas (EVENT_TIMER_FIELDS, EVENT_WRITABLE_FIELDS) and the schema resource + * (ONTIME_SCHEMA_MARKDOWN) are maintained here so tool descriptions, prompt guidance, + * and the agent-readable reference document use the same wording. + * + * Canonical type definitions live in packages/types/src/definitions/core/OntimeEntry.ts. + * Keep this file concise and update it when MCP-exposed fields change. + */ + +// ---- Shared event field JSON schemas ---- +// Imported by mcp.tools.ts and spread into tool inputSchema.properties. + +export const EVENT_TIMER_FIELDS = { + timerType: { + type: 'string', + enum: ['count-down', 'count-up', 'clock', 'none'], + description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown', + }, + 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', + }, + linkStart: { + type: 'boolean', + description: + "Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule", + }, + countToEnd: { type: 'boolean', description: 'Timer counts toward the scheduled end time rather than elapsed time' }, + timeStrategy: { + type: 'string', + enum: ['lock-duration', 'lock-end'], + description: + 'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration', + }, + timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' }, + timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' }, +} as const; + +export const EVENT_WRITABLE_FIELDS = { + cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' }, + title: { type: 'string', description: 'Event title shown in the rundown and views' }, + 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', + }, + skip: { type: 'boolean', description: 'If true, event is skipped during playback' }, + flag: { + type: 'boolean', + description: 'Mark the event as a critical operational marker — use sparingly for maximum impact', + }, + custom: { + 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.', + }, + ...EVENT_TIMER_FIELDS, +} as const; + +export const RUNDOWN_TARGET_FIELD = { + rundownId: { + type: 'string', + description: + 'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.', + }, +} as const; + +// ---- Agent-readable schema document ---- +// Served at ontime://schema. Agents read this once per session to orient themselves +// before issuing tool calls. + +export const ONTIME_SCHEMA_MARKDOWN = `# Ontime data model + +A concise reference for how Ontime structures rundowns, events, and related data. + +## Rundown + +A rundown is an ordered list of entries rendered as a show schedule. A project can contain multiple rundowns; one is "loaded" at a time. + +\`\`\` +Rundown { + id: string + title: string + order: EntryId[] // top-level entry order + flatOrder: EntryId[] // includes entries nested in groups + entries: { [id: EntryId]: OntimeEntry } + revision: number +} +\`\`\` + +## Entries + +There are four entry types discriminated by \`type\`: + +### \`event\` — OntimeEvent (a timed show item) +\`\`\` +{ + type: 'event' + id: EntryId + cue: string // human-facing cue label + title: string + note: string + colour: string // hex, e.g. "#4A90D9" + timeStart: number // ms from midnight (09:00 = 32400000) + timeEnd: number // ms from midnight + duration: number // ms (= timeEnd - timeStart) + delay: number // delay accumulated from rundown delay entries + 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' + linkStart: boolean // chain start to previous event's end + countToEnd: boolean // timer counts to planned end time + skip: boolean // event is skipped during playback + flag: boolean // critical operational marker, highlighted to operators + timeStrategy: 'lock-duration' | 'lock-end' + timeWarning: number // ms before end to trigger 'warning' state + timeDanger: number // ms before end to trigger 'danger' state + custom: { [key: string]: string } // custom field values + triggers: Trigger[] // automation trigger references + parent: EntryId | null // parent group, when nested + revision: number // entry revision +} +\`\`\` + +### \`delay\` — OntimeDelay (schedule shift applied to following events) +\`\`\` +{ type: 'delay', id, duration: number, parent: EntryId | null } +\`\`\` + +### \`group\` — OntimeGroup (nested container of entries) +\`\`\` +{ + type: 'group' + id: EntryId + title: string + colour: string + note: string + entries: EntryId[] + targetDuration: number | null + custom: { [key: string]: string } + timeStart: number | null // calculated from nested entries (runtime) + timeEnd: number | null // calculated from nested entries (runtime) + duration: number // calculated from nested entries (runtime) + isFirstLinked: boolean // whether the first nested event is linked (runtime) + revision: number +} +\`\`\` +Groups are created with a title only — set colour, note, custom values and targetDuration with an update after creation. + +### \`milestone\` — OntimeMilestone (marker with no timer) +\`\`\` +{ type: 'milestone', id, cue, title, note, colour, custom, parent: EntryId | null } +\`\`\` + +## Time format +Ontime stores time values as **milliseconds from midnight (local)**. Convert user-facing times before calling tools: \`10:30\` means \`37800000\`, and a duration like \`45 min\` means \`2700000\`. + +\`timeEnd\` may be lower than \`timeStart\` when an event crosses midnight; duration is calculated across the day boundary. + +Examples: +- 09:00:00 = 32400000 +- 09:30:00 = 34200000 +- 14:15:00 = 51300000 +- Duration of 45 min = 2700000 + +## 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\`. + +## Targeting rundowns +Entry read/write tools accept an optional \`rundownId\`. +- Omit \`rundownId\` to target the currently loaded live rundown. +- Pass a rundown ID from \`ontime_list_rundowns\` to edit a background rundown without loading it. +- Loading a rundown with \`ontime_load_rundown\` switches the live rundown and resets runtime state; do not load a rundown just to edit it in the background. + +## Playback states (runtime only) +\`'stop' | 'play' | 'pause' | 'armed' | 'roll'\` +When playback is not \`stop\`, mutations to the loaded rundown affect the live rundown immediately, so confirm with the user before editing. Mutations to a background rundown using \`rundownId\` do not interrupt playback. + +## Available resources +- \`ontime://schema\` — this document +- \`ontime://rundown/current\` — the currently loaded rundown (JSON) +- \`ontime://rundowns\` — all rundowns in the project (JSON) +- \`ontime://project/info\` — project metadata (JSON) +- \`ontime://project/custom-fields\` — custom field definitions (JSON) +- \`ontime://docs\` — index of Ontime documentation topics with URLs + +## Further reading +Full Ontime documentation: **https://docs.getontime.no** +`; + +// ---- Documentation index ---- +// Served at ontime://docs. Agents read this when they need to point users to official docs. + +export const ONTIME_DOCS_MARKDOWN = `# Ontime Documentation Index + +Main site: https://docs.getontime.no + +## Getting started +- Installation & setup: https://docs.getontime.no/installation/ + +## Core concepts +- Rundown: https://docs.getontime.no/concepts/rundown/ +- Timer types (count-down, count-up, clock, none): https://docs.getontime.no/concepts/timer/ +- Time entry format: https://docs.getontime.no/concepts/time-entry/ +- Event actions (end action, link start): https://docs.getontime.no/concepts/event-actions/ +- Delays and blocks: https://docs.getontime.no/concepts/delays-and-blocks/ + +## Features +- Custom fields: https://docs.getontime.no/features/custom-fields/ +- Automations (triggers, filters, outputs): https://docs.getontime.no/features/automations/ +- URL presets / shared views: https://docs.getontime.no/features/url-presets/ +- HTTP Integration: https://docs.getontime.no/api/http/ +- OSC Integration: https://docs.getontime.no/api/osc/ + +## API reference +- REST API overview: https://docs.getontime.no/api/ +- WebSocket events: https://docs.getontime.no/api/websocket/ +`; diff --git a/apps/server/src/api-mcp/mcp.server.ts b/apps/server/src/api-mcp/mcp.server.ts new file mode 100644 index 0000000000..953258d483 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.server.ts @@ -0,0 +1,47 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + type CallToolResult, + type ListToolsResult, +} from '@modelcontextprotocol/sdk/types.js'; + +import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js'; +import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js'; +import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js'; + +export function createMcpServer(): Server { + const server = new Server( + { name: 'ontime-mcp-server', version: '1.0.0' }, + { capabilities: { tools: {}, prompts: {}, resources: {} } }, + ); + + server.setRequestHandler( + ListToolsRequestSchema, + async (): Promise => ({ + tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'], + }), + ); + + server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { + const { name, arguments: args = {} } = request.params; + return handleToolCall(name, args as Record); + }); + + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPT_DEFINITIONS })); + + server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params; + return handleGetPrompt(name, args as Record); + }); + + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DEFINITIONS })); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => handleReadResource(request.params.uri)); + + return server; +} diff --git a/apps/server/src/api-mcp/mcp.service.ts b/apps/server/src/api-mcp/mcp.service.ts new file mode 100644 index 0000000000..bed1293561 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.service.ts @@ -0,0 +1,183 @@ +import { + EntryId, + EventPostPayload, + InsertOptions, + OntimeDelay, + OntimeEntry, + OntimeEvent, + OntimeGroup, + OntimeMilestone, + PatchWithId, + ProjectRundowns, + Rundown, + SupportedEntry, +} from 'ontime-types'; + +import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js'; +import { + addEntry, + batchEditEntries, + deleteEntries, + editEntry, + reorderEntry, +} from '../api-data/rundown/rundown.service.js'; +import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js'; +import { getDataProvider } from '../classes/data-provider/DataProvider.js'; + +export type EventFieldArgs = Partial< + Pick< + OntimeEvent, + | 'cue' + | 'title' + | 'note' + | 'colour' + | 'skip' + | 'flag' + | 'custom' + | 'timerType' + | 'endAction' + | 'linkStart' + | 'countToEnd' + | 'timeStrategy' + | 'timeWarning' + | 'timeDanger' + | 'timeStart' + | 'timeEnd' + | 'duration' + > +>; +export type MilestoneFieldArgs = Partial>; +export type DelayFieldArgs = Partial>; +export type GroupFieldArgs = Partial>; + +export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs; +export type TargetRundownArgs = { rundownId?: string }; +export type CreateEntryArgs = EntryFieldArgs & InsertOptions & TargetRundownArgs & { type?: `${SupportedEntry}` }; +export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId }; + +export function resolveTargetRundownId(args: TargetRundownArgs): string { + return args.rundownId ?? getCurrentRundownId(); +} + +function getTargetMeta(rundownId: string) { + const loaded = rundownId === getCurrentRundownId(); + return { rundownId, loaded }; +} + +export function getRundownById(rundownId?: string): Readonly { + const targetId = rundownId ?? getCurrentRundownId(); + return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId); +} + +export function findEntry(args: TargetRundownArgs & { id?: EntryId; cue?: string }): OntimeEntry | undefined { + const rundown = getRundownById(args.rundownId); + if (args.id) { + return rundown.entries[args.id]; + } + if (args.cue) { + return Object.values(rundown.entries).find((entry) => 'cue' in entry && entry.cue === args.cue); + } +} + +export function toRundownList(projectRundowns: Readonly) { + return { loaded: getCurrentRundownId(), rundowns: normalisedToRundownArray(projectRundowns) }; +} + +export function assertKnownCustomFields(...customValues: Array) { + const customFields = getProjectCustomFields(); + const knownKeys = new Set(Object.keys(customFields)); + const unknownKeys = new Set(); + + for (const custom of customValues) { + if (!custom) continue; + for (const key of Object.keys(custom)) { + if (!knownKeys.has(key)) { + unknownKeys.add(key); + } + } + } + + 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.`); + } +} + +/** Translates tool arguments into the payload consumed by rundown.service addEntry */ +export function toEntryPayload(args: CreateEntryArgs): EventPostPayload { + const { type = SupportedEntry.Event, after, before } = args; + + switch (type) { + case SupportedEntry.Delay: + return { type: SupportedEntry.Delay, duration: args.duration, after, before }; + case SupportedEntry.Milestone: { + const { cue, title, note, colour, custom } = args; + return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before }; + } + case SupportedEntry.Group: + // group creation currently only accepts a title, see generateEvent in rundown.utils.ts + return { type: SupportedEntry.Group, title: args.title, after, before }; + case SupportedEntry.Event: { + const { type: _type, rundownId: _rundownId, ...eventFields } = args; + return { type: SupportedEntry.Event, ...eventFields }; + } + default: + throw new Error(`Invalid entry type: ${String(type)}`); + } +} + +export async function createEntryForMcp(args: CreateEntryArgs) { + assertKnownCustomFields(args.custom); + const rundownId = resolveTargetRundownId(args); + const entry = await addEntry(rundownId, toEntryPayload(args)); + return { target: getTargetMeta(rundownId), entry }; +} + +export async function updateEntryForMcp(args: UpdateEntryArgs) { + assertKnownCustomFields(args.custom); + const rundownId = resolveTargetRundownId(args); + const { rundownId: _rundownId, ...patch } = args; + const entry = await editEntry(rundownId, patch as PatchWithId); + return { target: getTargetMeta(rundownId), entry }; +} + +export async function deleteEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[] }) { + const rundownId = resolveTargetRundownId(args); + const rundown = await deleteEntries(rundownId, args.ids); + return { target: getTargetMeta(rundownId), deleted: args.ids, order: rundown.order }; +} + +export async function reorderEntryForMcp( + args: TargetRundownArgs & { entryId: EntryId; destinationId: EntryId; order: 'before' | 'after' | 'insert' }, +) { + const rundownId = resolveTargetRundownId(args); + const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order); + return { target: getTargetMeta(rundownId), order: rundown.order }; +} + +export async function batchCreateEntriesForMcp( + args: TargetRundownArgs & { entries: CreateEntryArgs[]; after?: EntryId }, +) { + const { entries = [], after } = args; + assertKnownCustomFields(...entries.map((entry) => entry.custom)); + const rundownId = resolveTargetRundownId(args); + let previousId = after; + const created: OntimeEntry[] = []; + + for (const entryArgs of entries) { + const payload = toEntryPayload(entryArgs); + // eslint-disable-next-line no-await-in-loop -- each entry chains after the previously created one + const entry = await addEntry(rundownId, previousId ? { ...payload, after: previousId } : payload); + created.push(entry); + previousId = entry.id; + } + + return { target: getTargetMeta(rundownId), created }; +} + +export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }) { + assertKnownCustomFields(args.data.custom); + const rundownId = resolveTargetRundownId(args); + const rundown = await batchEditEntries(rundownId, args.ids, args.data); + return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order }; +} diff --git a/apps/server/src/api-mcp/mcp.tools.ts b/apps/server/src/api-mcp/mcp.tools.ts new file mode 100644 index 0000000000..cf8bd363c4 --- /dev/null +++ b/apps/server/src/api-mcp/mcp.tools.ts @@ -0,0 +1,568 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { EntryId, ProjectData } from 'ontime-types'; + +import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js'; +import { getProjectCustomFields, getRundownMetadata } from '../api-data/rundown/rundown.dao.js'; +import { + createNewRundown, + deleteRundown, + duplicateExistingRundown, + loadRundown, + renameRundown, +} from '../api-data/rundown/rundown.service.js'; +import { getDataProvider } from '../classes/data-provider/DataProvider.js'; +import { makeNewProject } from '../models/dataModel.js'; +import { + createProjectWithPatch, + deleteProjectFile, + duplicateProjectFile, + getProjectList, + loadProjectFile, + renameProjectFile, +} from '../services/project-service/ProjectService.js'; +import { getState } from '../stores/runtimeState.js'; +import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js'; +import { + batchCreateEntriesForMcp, + batchUpdateEntriesForMcp, + createEntryForMcp, + deleteEntriesForMcp, + findEntry, + getRundownById, + reorderEntryForMcp, + toRundownList, + updateEntryForMcp, + type CreateEntryArgs, + type EntryFieldArgs, + type TargetRundownArgs, + type UpdateEntryArgs, +} from './mcp.service.js'; + +// Graceful truncation to keep tool responses within typical MCP context windows +const CHARACTER_LIMIT = 25_000; + +// ---- MCP tool annotation presets ---- +// https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations +const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const; +const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const; +const WRITE_IDEM = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const; +const WRITE_DESTRUCTIVE = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, +} as const; + +// ---- Tool definitions ---- +export const TOOL_DEFINITIONS = [ + // --- Rundown read --- + { + name: 'ontime_get_rundown', + description: + 'Get a rundown. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns { order: EntryId[], entries: { [id]: OntimeEntry } }. If the rundown exceeds 25 000 chars, returns only the order array with a warning — fetch individual entries with ontime_get_entry.', + inputSchema: { type: 'object', properties: { ...RUNDOWN_TARGET_FIELD } }, + annotations: READ, + }, + { + name: 'ontime_get_rundown_metadata', + description: + 'Get cached metadata for the current rundown. Returns: totalDelay, totalDuration, totalDays, firstStart, lastEnd, flags (flagged entry IDs), playableEventOrder, timedEventOrder, flatEntryOrder.', + inputSchema: { type: 'object', properties: {} }, + annotations: READ, + }, + { + name: 'ontime_get_entry', + description: + 'Get a single entry by id or cue. Provide either id or cue (not both). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns the full entry object.', + inputSchema: { + type: 'object', + properties: { + ...RUNDOWN_TARGET_FIELD, + id: { type: 'string', description: 'Entry ID (from rundown.entries key or entry.id)' }, + cue: { type: 'string', description: 'Human-facing cue label' }, + }, + }, + annotations: READ, + }, + // --- Rundown mutations --- + { + name: 'ontime_create_entry', + description: + 'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title only — set colour, note, custom, or targetDuration with ontime_update_entry after creation.', + inputSchema: { + type: 'object', + properties: { + ...RUNDOWN_TARGET_FIELD, + type: { + type: 'string', + enum: ['event', 'delay', 'milestone', 'group'], + description: + 'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries', + }, + timeStart: { type: 'number', description: 'Event start time in ms from midnight (e.g. 09:00 = 32400000)' }, + timeEnd: { type: 'number', description: 'Event end time in ms from midnight' }, + duration: { + type: 'number', + description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)', + }, + after: { type: 'string', description: 'Insert after this entry ID' }, + before: { type: 'string', description: 'Insert before this entry ID' }, + ...EVENT_WRITABLE_FIELDS, + }, + }, + annotations: WRITE, + }, + { + name: 'ontime_update_entry', + description: + 'Update fields of an existing entry (event, milestone, delay or group). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group fields: title, note, colour, custom, targetDuration. Delay field: duration. Milestone fields: cue, title, note, colour, custom. Custom values must use existing project custom field keys; adding a new custom field is a separate operation.', + inputSchema: { + type: 'object', + required: ['id'], + properties: { + ...RUNDOWN_TARGET_FIELD, + id: { type: 'string', description: 'ID of the entry to update' }, + timeStart: { type: 'number', description: 'Start time in ms from midnight' }, + timeEnd: { type: 'number', description: 'End time in ms from midnight' }, + duration: { type: 'number', description: 'Duration in ms' }, + targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' }, + ...EVENT_WRITABLE_FIELDS, + }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + { + name: 'ontime_delete_entries', + description: + 'Delete one or more entries (events, milestones, delays, or groups). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it.', + inputSchema: { + type: 'object', + required: ['ids'], + properties: { + ...RUNDOWN_TARGET_FIELD, + ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to delete' }, + }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + { + name: 'ontime_reorder_entry', + description: + 'Move an entry to a new position relative to another entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use before/after for sibling reordering; use insert to place an entry inside a group.', + inputSchema: { + type: 'object', + required: ['entryId', 'destinationId', 'order'], + properties: { + ...RUNDOWN_TARGET_FIELD, + entryId: { type: 'string', description: 'ID of the entry to move' }, + destinationId: { type: 'string', description: 'ID of the target entry (sibling or parent group)' }, + order: { + type: 'string', + enum: ['before', 'after', 'insert'], + description: 'before/after: place as sibling; insert: place inside a group', + }, + }, + }, + annotations: WRITE_IDEM, + }, + { + name: 'ontime_batch_create_entries', + description: + 'Create multiple entries. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first entry, subsequent entries chain from the previous. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.', + inputSchema: { + type: 'object', + required: ['entries'], + properties: { + ...RUNDOWN_TARGET_FIELD, + after: { type: 'string', description: 'Insert the first entry after this entry ID' }, + entries: { + type: 'array', + description: 'Array of entries to create, in desired order', + items: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['event', 'delay', 'milestone', 'group'], + description: 'Entry type, defaults to event', + }, + timeStart: { type: 'number', description: 'Event start time in ms from midnight' }, + timeEnd: { type: 'number', description: 'Event end time in ms from midnight' }, + duration: { type: 'number', description: 'Duration in ms' }, + ...EVENT_WRITABLE_FIELDS, + }, + }, + }, + }, + }, + annotations: WRITE, + }, + { + name: 'ontime_batch_update_entries', + description: + 'Apply the same field values to multiple entries by ID. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use for bulk operations like recolouring all keynotes, skipping all breaks, or setting the same custom value on several entries. Custom values must use existing project custom field keys. Do not use for changes where each entry needs a different value, such as time shifts with different timeStart/timeEnd values; compute those per entry and call ontime_update_entry for each.', + inputSchema: { + type: 'object', + required: ['ids', 'data'], + properties: { + ...RUNDOWN_TARGET_FIELD, + ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to update' }, + data: { + type: 'object', + description: 'Partial entry fields to apply to every ID', + properties: { + timeStart: { type: 'number', description: 'Start time in ms from midnight' }, + timeEnd: { type: 'number', description: 'End time in ms from midnight' }, + duration: { type: 'number', description: 'Duration in ms' }, + ...EVENT_WRITABLE_FIELDS, + }, + }, + }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + // --- Rundown management --- + { + name: 'ontime_list_rundowns', + description: + 'List all rundowns in the current project. Returns rundown IDs and titles, plus the ID of the currently loaded one.', + inputSchema: { type: 'object', properties: {} }, + annotations: READ, + }, + { + name: 'ontime_create_rundown', + description: + 'Create a new empty rundown in the current project. Does not switch to it — use ontime_load_rundown to activate.', + inputSchema: { + type: 'object', + required: ['title'], + properties: { title: { type: 'string', description: 'Title for the new rundown' } }, + }, + annotations: WRITE, + }, + { + name: 'ontime_load_rundown', + description: + 'Make a rundown the active rundown. This resets the runtime and clears playback state. If playback is running, confirm the user accepts interrupting the live rundown before calling. To edit a background rundown without interrupting playback, advise using the cuesheet view.', + inputSchema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', description: 'Rundown ID to load' } }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + { + name: 'ontime_rename_rundown', + description: 'Rename an existing rundown', + inputSchema: { + type: 'object', + required: ['id', 'title'], + properties: { + id: { type: 'string', description: 'Rundown ID to rename' }, + title: { type: 'string', description: 'New title' }, + }, + }, + annotations: WRITE_IDEM, + }, + { + name: 'ontime_delete_rundown', + description: 'Delete a rundown (cannot delete the currently loaded rundown or the last remaining rundown)', + inputSchema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', description: 'Rundown ID to delete' } }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + { + name: 'ontime_duplicate_rundown', + description: 'Duplicate a rundown, creating a copy with a new ID. Does not switch to the copy.', + inputSchema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', description: 'Rundown ID to duplicate' } }, + }, + annotations: WRITE, + }, + // --- Timer & project --- + { + name: 'ontime_get_timer_state', + description: + 'Get the current timer/playback state. Returns: clock (time of day), timer ({ playback, current, elapsed, phase, expectedFinish, addedTime, startedAt }), eventNow (full event object or null), eventNext (full event object or null), offset.', + inputSchema: { type: 'object', properties: {} }, + annotations: READ, + }, + { + name: 'ontime_get_project_info', + description: + 'Get current project metadata: title, description, url, info, logo, and custom header fields (array of { title, value, url }).', + inputSchema: { type: 'object', properties: {} }, + annotations: READ, + }, + { + name: 'ontime_update_project_info', + description: 'Update project metadata fields. All fields are optional — only provided fields are updated.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Project title' }, + description: { type: 'string', description: 'Project description' }, + url: { type: 'string', description: 'URL shown on viewer pages' }, + info: { type: 'string', description: 'Info text shown on viewer pages' }, + }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + { + name: 'ontime_get_custom_fields', + description: + 'Get the project custom field definitions. Returns { [key]: { label, type: "text"|"image", colour } }. Keys are referenced in entry.custom[key].', + inputSchema: { type: 'object', properties: {} }, + annotations: READ, + }, + // --- Project file management --- + { + name: 'ontime_list_projects', + description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.', + inputSchema: { type: 'object', properties: {} }, + annotations: READ, + }, + { + name: 'ontime_load_project', + description: + 'Load a different project file by filename. This swaps the database and reinitialises runtime. If playback is running, confirm the user accepts interrupting the live project before calling.', + inputSchema: { + type: 'object', + required: ['filename'], + properties: { filename: { type: 'string', description: 'Project filename, e.g. "my-show.json"' } }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + { + name: 'ontime_create_project', + description: + 'Create a new project file and switch to it. This swaps the loaded project. If playback is running, confirm the user accepts interrupting the live project before calling. Omit the .json extension — Ontime appends it.', + inputSchema: { + type: 'object', + required: ['filename'], + properties: { + filename: { type: 'string', description: 'Filename without extension, e.g. "my-show"' }, + title: { type: 'string', description: 'Optional project title' }, + description: { type: 'string', description: 'Optional project description' }, + }, + }, + annotations: WRITE_DESTRUCTIVE, + }, + { + name: 'ontime_rename_project', + description: 'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.', + inputSchema: { + type: 'object', + required: ['filename', 'newFilename'], + properties: { + filename: { type: 'string', description: 'Current filename (with .json extension)' }, + newFilename: { type: 'string', description: 'New filename (with .json extension)' }, + }, + }, + annotations: WRITE_IDEM, + }, + { + name: 'ontime_duplicate_project', + description: 'Duplicate a project file on disk with a new filename. Does not switch to the copy.', + inputSchema: { + type: 'object', + required: ['filename', 'newFilename'], + properties: { + filename: { type: 'string', description: 'Source filename to copy (with .json extension)' }, + newFilename: { type: 'string', description: 'Filename of the new copy (with .json extension)' }, + }, + }, + annotations: WRITE, + }, + { + name: 'ontime_delete_project', + description: 'Delete a project file from disk. Fails if the file is currently loaded.', + inputSchema: { + type: 'object', + required: ['filename'], + properties: { filename: { type: 'string', description: 'Project filename to delete (with .json extension)' } }, + }, + annotations: WRITE_DESTRUCTIVE, + }, +] as const; + +type ToolName = (typeof TOOL_DEFINITIONS)[number]['name']; + +type ProjectInfoArgs = Partial>; + +// ---- Response helpers (module-level to avoid re-allocation on every tool call) ---- + +const text = (data: unknown): string => JSON.stringify(data); + +export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] }); + +export const err = (e: unknown): CallToolResult => ({ + content: [{ type: 'text', text: text({ error: e instanceof Error ? e.message : String(e) }) }], + isError: true, +}); + +// ---- Tool handlers ---- +// Each handler is a thin translation wrapper: it maps the wire arguments to a typed call +// into an existing service and formats the response. Business logic belongs in the services. +const TOOL_HANDLERS: Record) => Promise> = { + ontime_get_rundown: async (args) => { + const targetArgs = args as TargetRundownArgs; + const rundown = getRundownById(targetArgs.rundownId); + const data = { order: rundown.order, entries: rundown.entries }; + const serialised = text(data); + if (serialised.length > CHARACTER_LIMIT) { + return ok({ + warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry. Entry IDs in order: ${rundown.order.join(', ')}`, + truncated: true, + rundownId: rundown.id, + order: rundown.order, + }); + } + return ok({ rundownId: rundown.id, ...data }); + }, + + ontime_get_rundown_metadata: async () => ok(getRundownMetadata()), + + ontime_get_entry: async (args) => { + const entryArgs = args as TargetRundownArgs & { id?: EntryId; cue?: string }; + const entry = findEntry(entryArgs); + if (entry) return ok(entry); + if (entryArgs.id) return err(`No entry with id ${entryArgs.id}`); + if (entryArgs.cue) return err(`No entry with cue ${entryArgs.cue}`); + return err('Provide id or cue'); + }, + + ontime_create_entry: async (args) => { + return ok(await createEntryForMcp(args as CreateEntryArgs)); + }, + + ontime_update_entry: async (args) => { + return ok(await updateEntryForMcp(args as UpdateEntryArgs)); + }, + + ontime_delete_entries: async (args) => { + return ok(await deleteEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[] })); + }, + + ontime_reorder_entry: async (args) => { + return ok( + await reorderEntryForMcp( + args as TargetRundownArgs & { + entryId: EntryId; + destinationId: EntryId; + order: 'before' | 'after' | 'insert'; + }, + ), + ); + }, + + ontime_batch_create_entries: async (args) => { + return ok( + await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: CreateEntryArgs[]; after?: EntryId }), + ); + }, + + ontime_batch_update_entries: async (args) => { + return ok(await batchUpdateEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs })); + }, + + ontime_list_rundowns: async () => ok(toRundownList(getDataProvider().getProjectRundowns())), + + ontime_create_rundown: async (args) => { + const { title } = args as { title: string }; + return ok(toRundownList(await createNewRundown(title))); + }, + + ontime_load_rundown: async (args) => { + const { id } = args as { id: string }; + return ok(toRundownList(await loadRundown(id))); + }, + + ontime_rename_rundown: async (args) => { + const { id, title } = args as { id: string; title: string }; + return ok(toRundownList(await renameRundown(id, title))); + }, + + ontime_delete_rundown: async (args) => { + const { id } = args as { id: string }; + return ok(toRundownList(await deleteRundown(id))); + }, + + ontime_duplicate_rundown: async (args) => { + const { id } = args as { id: string }; + return ok(toRundownList(await duplicateExistingRundown(id))); + }, + + ontime_get_timer_state: async () => { + const { clock, timer, eventNow, eventNext, offset } = getState(); + return ok({ clock, timer, eventNow, eventNext, offset }); + }, + + ontime_get_project_info: async () => ok(getProjectData()), + + ontime_update_project_info: async (args) => { + const updated = await editCurrentProjectData(args as ProjectInfoArgs); + return ok(updated); + }, + + ontime_get_custom_fields: async () => ok(getProjectCustomFields()), + + ontime_list_projects: async () => ok(await getProjectList()), + + ontime_load_project: async (args) => { + const { filename } = args as { filename: string }; + await loadProjectFile(filename); + return ok(await getProjectList()); + }, + + ontime_create_project: async (args) => { + const { + filename, + title = '', + description = '', + } = args as { + filename: string; + title?: string; + description?: string; + }; + const project: ProjectData = { ...makeNewProject().project, title, description }; + const newFileName = await createProjectWithPatch(filename, { project }); + return ok({ filename: newFileName }); + }, + + ontime_rename_project: async (args) => { + const { filename, newFilename } = args as { filename: string; newFilename: string }; + await renameProjectFile(filename, newFilename); + return ok(await getProjectList()); + }, + + ontime_duplicate_project: async (args) => { + const { filename, newFilename } = args as { filename: string; newFilename: string }; + await duplicateProjectFile(filename, newFilename); + return ok(await getProjectList()); + }, + + ontime_delete_project: async (args) => { + const { filename } = args as { filename: string }; + await deleteProjectFile(filename); + return ok(await getProjectList()); + }, +}; + +// ---- Tool call dispatcher ---- +export async function handleToolCall(name: string, args: Record): Promise { + const handler = TOOL_HANDLERS[name as ToolName]; + if (!handler) { + return err(`Unknown tool: ${name}`); + } + try { + return await handler(args); + } catch (error) { + return err(error); + } +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 45e4427386..3d2d1d2294 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -13,6 +13,8 @@ import { socket } from './adapters/WebsocketAdapter.js'; // Import Routers import { appRouter } from './api-data/index.js'; import { integrationRouter } from './api-integration/integration.router.js'; +import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js'; +import { mcpRouter } from './api-mcp/mcp.router.js'; import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js'; // Services import { logger } from './classes/Logger.js'; @@ -100,6 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => { app.use(`${prefix}/login`, loginRouter); // router for login flow app.use(`${prefix}/data`, authenticate, appRouter); // router for application data app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations +app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration // serve static external files app.use( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e3ec0331c..2fe48e04fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: '@googleapis/sheets': specifier: ^5.0.5 version: 5.0.5 + '@modelcontextprotocol/sdk': + specifier: ^1.15.0 + version: 1.29.0(zod@4.4.3) cookie: specifier: 1.0.2 version: 1.0.2 @@ -1478,6 +1481,12 @@ packages: '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1533,6 +1542,16 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -2716,6 +2735,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -2724,6 +2751,9 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2827,6 +2857,10 @@ packages: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -3305,6 +3339,14 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3312,6 +3354,12 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express-static-gzip@3.0.0: resolution: {integrity: sha512-36O10S0asHl3QojOBQQ0ZjXNtElmhgPS6erSUCCZymXkB/CK1mnGqOj4BTJN+FYRDIzVFnzo3wLFCZJvAk6rQQ==} @@ -3323,6 +3371,10 @@ packages: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3345,6 +3397,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -3543,6 +3598,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + engines: {node: '>=16.9.0'} + hookable@6.1.0: resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} @@ -3557,6 +3616,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3582,6 +3645,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3603,6 +3670,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3672,6 +3743,9 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3696,6 +3770,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -4124,6 +4204,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} @@ -4189,6 +4273,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -4204,6 +4292,10 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-colorful@5.6.1: resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} peerDependencies: @@ -4293,6 +4385,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -4519,6 +4615,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -5036,6 +5136,14 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.9: resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} engines: {node: '>=12.20.0'} @@ -6218,6 +6326,10 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@hono/node-server@1.19.14(hono@4.12.25)': + dependencies: + hono: 4.12.25 + '@isaacs/cliui@9.0.0': {} '@isaacs/fs-minipass@4.0.1': @@ -6283,6 +6395,28 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.25) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.25 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.9.1 @@ -7191,6 +7325,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-keywords@3.5.2(ajv@6.14.0): dependencies: ajv: 6.14.0 @@ -7202,6 +7340,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -7343,6 +7488,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.0 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -7908,10 +8067,21 @@ snapshots: etag@1.8.1: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express-static-gzip@3.0.0: dependencies: mime-types: 3.0.1 @@ -7957,6 +8127,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} extract-zip@2.0.1: @@ -7978,6 +8181,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-uri@3.1.2: {} + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -8249,6 +8454,8 @@ snapshots: dependencies: react-is: 16.13.1 + hono@4.12.25: {} + hookable@6.1.0: {} hosted-git-info@4.1.0: @@ -8265,6 +8472,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -8301,6 +8516,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: optional: true @@ -8320,6 +8539,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} @@ -8375,6 +8596,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@6.2.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -8393,6 +8616,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stringify-safe@5.0.1: optional: true @@ -8780,6 +9007,8 @@ snapshots: picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + playwright-core@1.60.0: {} playwright@1.60.0: @@ -8846,6 +9075,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + quansync@1.0.0: {} quick-lru@5.1.1: {} @@ -8859,6 +9092,13 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-colorful@5.6.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -8937,6 +9177,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} resedit@1.7.2: @@ -9238,6 +9480,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} steno@4.0.2: {} @@ -9739,6 +9983,12 @@ snapshots: yocto-queue@0.1.0: {} + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + zustand@5.0.9(@types/react@19.1.12)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: '@types/react': 19.1.12