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 f88024a3e..848631389 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 @@ -1,5 +1,10 @@ +import { useEffect, useState } from 'react'; + import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; -import { isOntimeCloud } from '../../../../externals'; +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 GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; @@ -11,6 +16,30 @@ 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); + + 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(`${baseUrl}/mcp`); + }); + }, [infoData]); + + const claudeDesktopConfig = JSON.stringify( + { mcpServers: { ontime: { url: mcpEndpointUrl } } }, + null, + 2, + ); return ( <> @@ -33,6 +62,32 @@ export default function FeaturePanel({ location }: PanelBaseProps) { +
+ + + MCP Server (AI Agent Integration) + + Connect AI agents (e.g. Claude Desktop) to Ontime via the Model Context Protocol endpoint. + + + + {mcpEndpointUrl && ( + {mcpEndpointUrl} + )} + + + {mcpEndpointUrl && ( + {claudeDesktopConfig} + )} + + +
diff --git a/apps/server/package.json b/apps/server/package.json index 3421c4bf5..b44b63689 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,6 +6,7 @@ "exports": "./src/index.js", "dependencies": { "@googleapis/sheets": "^5.0.5", + "@modelcontextprotocol/sdk": "^1.15.0", "cookie": "1.0.2", "cookie-parser": "1.4.7", "cors": "2.8.6", 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 000000000..12ca213ad --- /dev/null +++ b/apps/server/src/api-mcp/mcp.router.ts @@ -0,0 +1,591 @@ +import { randomUUID } from 'node:crypto'; + +import express from 'express'; +import { SupportedEntry } from 'ontime-types'; + +import { getAutomationSettings, addAutomation } from '../api-data/automation/automation.dao.js'; +import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js'; +import { + getCurrentRundown, + getRundownMetadata, + getProjectCustomFields, +} from '../api-data/rundown/rundown.dao.js'; +import { + addEntry, + editEntry, + deleteEntries, + reorderEntry, + loadRundown, + initRundown, +} from '../api-data/rundown/rundown.service.js'; +import { duplicateRundown, normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js'; +import { getDataProvider } from '../classes/data-provider/DataProvider.js'; +import { makeNewRundown } from '../models/dataModel.js'; +import { getState } from '../stores/runtimeState.js'; + +// MCP SDK imports +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + isInitializeRequest, + ListToolsRequestSchema, + CallToolRequestSchema, + ListPromptsRequestSchema, + GetPromptRequestSchema, + CallToolResult, + ListToolsResult, + ListPromptsResult, + GetPromptResult, +} from '@modelcontextprotocol/sdk/types.js'; + +/** Active sessions indexed by session ID */ +const sessions = new Map(); + +// ---- Tool definitions ---- +const TOOL_DEFINITIONS = [ + { + name: 'get_rundown', + description: 'Get the currently loaded rundown including order and entries', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'get_rundown_metadata', + description: 'Get cached metadata for the current rundown', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'get_event', + description: 'Get a single event by id or cue', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Event ID' }, + cue: { type: 'string', description: 'Event cue' }, + }, + }, + }, + { + name: 'create_event', + description: 'Create a new event in the rundown', + inputSchema: { + type: 'object', + required: ['cue', 'title', 'timeStart', 'timeEnd', 'duration'], + properties: { + cue: { type: 'string' }, + title: { type: 'string' }, + 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' }, + after: { type: 'string', description: 'Insert after this event ID' }, + before: { type: 'string', description: 'Insert before this event ID' }, + note: { type: 'string' }, + colour: { type: 'string' }, + skip: { type: 'boolean' }, + timerType: { type: 'string', enum: ['count-down', 'count-up', 'time-to-end', 'clock'] }, + endAction: { type: 'string', enum: ['none', 'stop', 'load-next', 'play-next'] }, + linkStart: { type: 'boolean' }, + countToEnd: { type: 'boolean' }, + timeWarning: { type: 'number' }, + timeDanger: { type: 'number' }, + }, + }, + }, + { + name: 'update_event', + description: 'Update fields of an existing event', + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + cue: { type: 'string' }, + title: { type: 'string' }, + timeStart: { type: 'number' }, + timeEnd: { type: 'number' }, + duration: { type: 'number' }, + note: { type: 'string' }, + colour: { type: 'string' }, + skip: { type: 'boolean' }, + timerType: { type: 'string', enum: ['count-down', 'count-up', 'time-to-end', 'clock'] }, + endAction: { type: 'string', enum: ['none', 'stop', 'load-next', 'play-next'] }, + linkStart: { type: 'boolean' }, + countToEnd: { type: 'boolean' }, + timeWarning: { type: 'number' }, + timeDanger: { type: 'number' }, + }, + }, + }, + { + name: 'delete_event', + description: 'Delete one or more events from the rundown', + inputSchema: { + type: 'object', + required: ['ids'], + properties: { + ids: { type: 'array', items: { type: 'string' }, description: 'Array of event IDs to delete' }, + }, + }, + }, + { + name: 'reorder_event', + description: 'Move an event to a new position relative to another event', + inputSchema: { + type: 'object', + required: ['entryId', 'destinationId', 'order'], + properties: { + entryId: { type: 'string', description: 'ID of the event to move' }, + destinationId: { type: 'string', description: 'ID of the destination event' }, + order: { type: 'string', enum: ['before', 'after', 'insert'], description: 'Position relative to destination' }, + }, + }, + }, + { + name: 'list_rundowns', + description: 'List all available rundowns', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'create_rundown', + description: 'Create a new rundown', + inputSchema: { + type: 'object', + required: ['title'], + properties: { + title: { type: 'string', description: 'Title for the new rundown' }, + }, + }, + }, + { + name: 'load_rundown', + description: 'Load a rundown, making it the active rundown', + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', description: 'Rundown ID to load' }, + }, + }, + }, + { + name: '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' }, + }, + }, + }, + { + name: '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' }, + }, + }, + }, + { + name: 'duplicate_rundown', + description: 'Duplicate a rundown', + inputSchema: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', description: 'Rundown ID to duplicate' }, + }, + }, + }, + { + name: 'get_timer_state', + description: 'Get the current timer/playback state', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'get_project_info', + description: 'Get current project information', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'update_project_info', + description: 'Update project information fields', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + publicUrl: { type: 'string' }, + publicInfo: { type: 'string' }, + }, + }, + }, + { + name: 'list_automations', + description: 'List all automations and triggers', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'create_automation', + description: 'Create a new automation', + inputSchema: { + type: 'object', + required: ['title', 'filterRule', 'filters', 'outputs'], + properties: { + title: { type: 'string' }, + filterRule: { type: 'string', enum: ['all', 'any'] }, + filters: { type: 'array', items: { type: 'object' }, description: 'Array of filter objects' }, + outputs: { type: 'array', items: { type: 'object' }, description: 'Array of output action objects' }, + }, + }, + }, + { + name: 'get_custom_fields', + description: 'Get the project custom fields', + inputSchema: { type: 'object', properties: {} }, + }, +] as const; + +// ---- Helper to build rundown list response ---- +function rundownListResponse() { + const loaded = getCurrentRundown().id; + const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns()); + return { loaded, rundowns }; +} + +// ---- Tool handlers ---- +async function handleToolCall(name: string, args: Record): Promise { + const text = (data: unknown) => JSON.stringify(data); + const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] }); + const err = (e: unknown): CallToolResult => ({ content: [{ type: 'text', text: text({ error: String(e) }) }], isError: true }); + + switch (name) { + case 'get_rundown': { + const rundown = getCurrentRundown(); + return ok({ order: rundown.order, entries: rundown.entries }); + } + + case 'get_rundown_metadata': { + return ok(getRundownMetadata()); + } + + case 'get_event': { + const rundown = getCurrentRundown(); + const id = args.id as string | undefined; + const cue = args.cue as string | undefined; + if (id) { + const entry = rundown.entries[id]; + if (!entry) return err(`No event with id ${id}`); + return ok(entry); + } + if (cue) { + const entry = Object.values(rundown.entries).find((e) => 'cue' in e && (e as { cue: string }).cue === cue); + if (!entry) return err(`No event with cue ${cue}`); + return ok(entry); + } + return err('Provide id or cue'); + } + + case 'create_event': { + try { + const entry = await addEntry({ type: SupportedEntry.Event, ...args } as never); + return ok(entry); + } catch (e) { + return err(e); + } + } + + case 'update_event': { + try { + const entry = await editEntry(args as never); + return ok(entry); + } catch (e) { + return err(e); + } + } + + case 'delete_event': { + try { + const ids = args.ids as string[]; + const rundown = await deleteEntries(ids); + return ok({ deleted: ids, order: rundown.order }); + } catch (e) { + return err(e); + } + } + + case 'reorder_event': { + try { + const { entryId, destinationId, order } = args as { entryId: string; destinationId: string; order: 'before' | 'after' | 'insert' }; + const rundown = await reorderEntry(entryId, destinationId, order); + return ok({ order: rundown.order }); + } catch (e) { + return err(e); + } + } + + case 'list_rundowns': { + return ok(rundownListResponse()); + } + + case 'create_rundown': { + try { + const rundown = makeNewRundown(); + rundown.title = args.title as string; + await getDataProvider().setRundown(rundown.id, rundown); + return ok(rundownListResponse()); + } catch (e) { + return err(e); + } + } + + case 'load_rundown': { + try { + await loadRundown(args.id as string); + return ok(rundownListResponse()); + } catch (e) { + return err(e); + } + } + + case 'rename_rundown': { + try { + const { id, title } = args as { id: string; title: string }; + const dataProvider = getDataProvider(); + const rundown = structuredClone(dataProvider.getRundown(id)) as { id: string; title: string }; + rundown.title = title; + await dataProvider.setRundown(id, rundown as never); + if (id === getCurrentRundown().id) { + const customFields = dataProvider.getCustomFields(); + await initRundown(rundown as never, customFields); + } + return ok(rundownListResponse()); + } catch (e) { + return err(e); + } + } + + case 'delete_rundown': { + try { + const id = args.id as string; + const dataProvider = getDataProvider(); + const currentId = getCurrentRundown().id; + if (id === currentId) { + return err('Cannot delete the currently loaded rundown'); + } + const rundowns = dataProvider.getProjectRundowns(); + if (Object.keys(rundowns).length <= 1) { + return err('Cannot delete the last rundown'); + } + await dataProvider.deleteRundown(id); + return ok(rundownListResponse()); + } catch (e) { + return err(e); + } + } + + case 'duplicate_rundown': { + try { + const id = args.id as string; + const dataProvider = getDataProvider(); + const rundown = dataProvider.getRundown(id); + const copy = duplicateRundown(rundown as never, `Copy of ${rundown.title}`); + await dataProvider.setRundown(copy.id, copy); + return ok(rundownListResponse()); + } catch (e) { + return err(e); + } + } + + case 'get_timer_state': { + const state = getState(); + return ok({ + clock: state.clock, + timer: state.timer, + eventNow: state.eventNow, + eventNext: state.eventNext, + offset: state.offset, + }); + } + + case 'get_project_info': { + return ok(getProjectData()); + } + + case 'update_project_info': { + try { + const updated = await editCurrentProjectData(args as never); + return ok(updated); + } catch (e) { + return err(e); + } + } + + case 'list_automations': { + const settings = getAutomationSettings(); + return ok({ + enabledAutomations: settings.enabledAutomations, + triggers: settings.triggers, + automations: settings.automations, + }); + } + + case 'create_automation': { + try { + const result = await addAutomation(args as never); + return ok(result); + } catch (e) { + return err(e); + } + } + + case 'get_custom_fields': { + return ok(getProjectCustomFields()); + } + + default: + return err(`Unknown tool: ${name}`); + } +} + +/** Build and configure a new MCP Server instance */ +function createMcpServer(): Server { + const server = new Server( + { name: 'ontime', version: '1.0.0' }, + { + capabilities: { + tools: {}, + prompts: {}, + }, + }, + ); + + // Handle tools/list + server.setRequestHandler(ListToolsRequestSchema, async (): Promise => { + return { tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'] }; + }); + + // Handle tools/call + server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { + const { name, arguments: args = {} } = request.params; + return handleToolCall(name, args as Record); + }); + + // Handle prompts/list + server.setRequestHandler(ListPromptsRequestSchema, async (): Promise => { + return { + prompts: [ + { + name: 'create_rundown_from_agenda', + description: 'Generate MCP tool calls to build a rundown from a plain-text agenda', + arguments: [ + { + name: 'agenda', + description: 'Plain-text agenda to convert into an Ontime rundown', + required: true, + }, + ], + }, + ], + }; + }); + + // Handle prompts/get + server.setRequestHandler(GetPromptRequestSchema, async (request): Promise => { + const { name, arguments: args = {} } = request.params; + if (name !== 'create_rundown_from_agenda') { + throw new Error(`Unknown prompt: ${name}`); + } + const agenda = (args as Record).agenda ?? ''; + return { + description: 'Generate MCP tool calls to build a rundown from a plain-text agenda', + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Convert the following agenda into an Ontime rundown using these rules: + +- Times are in milliseconds from midnight. 09:00 = 32400000, 10:30 = 37800000, etc. +- duration = timeEnd - timeStart +- Cue prefixes by type: K01/K02/... for keynotes, P01/P02/... for panels, B01/B02/... for breaks +- Colours by type: #4A90D9 for keynotes, #7B68EE for panels, #888888 for breaks, #E8A838 for meals +- First call get_rundown to see the current state, then use create_event for each agenda item +- Pass \`after: \` to chain events in sequence + +Agenda: +${agenda}`, + }, + }, + ], + }; + }); + + return server; +} + +/** Express router for the MCP endpoint */ +export const mcpRouter = express.Router(); + +// POST / — handle new or existing session +mcpRouter.post('/', async (req, res) => { + const body = req.body as unknown; + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (isInitializeRequest(body)) { + // New session: create server + transport + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + const id = randomUUID(); + sessions.set(id, transport); + + transport.onclose = () => { + sessions.delete(id); + }; + + const mcpServer = createMcpServer(); + await mcpServer.connect(transport); + await transport.handleRequest(req as never, res as never, body); + return; + } + + // Existing session + if (!sessionId || !sessions.has(sessionId)) { + res.status(400).json({ error: 'Invalid or missing mcp-session-id' }); + return; + } + + const transport = sessions.get(sessionId)!; + await transport.handleRequest(req as never, res as never, body); +}); + +// GET / — SSE stream for existing session +mcpRouter.get('/', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !sessions.has(sessionId)) { + res.status(400).json({ error: 'Invalid or missing mcp-session-id' }); + return; + } + + const transport = sessions.get(sessionId)!; + await transport.handleRequest(req as never, res as never); +}); + +// DELETE / — close and remove session +mcpRouter.delete('/', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !sessions.has(sessionId)) { + res.status(400).json({ error: 'Invalid or missing mcp-session-id' }); + return; + } + + const transport = sessions.get(sessionId)!; + sessions.delete(sessionId); + await transport.close(); + res.status(200).json({ ok: true }); +}); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 45e442738..9a47e4f33 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -13,6 +13,7 @@ import { socket } from './adapters/WebsocketAdapter.js'; // Import Routers import { appRouter } from './api-data/index.js'; import { integrationRouter } from './api-integration/integration.router.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 +101,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`, authenticate, mcpRouter); // router for MCP agent integration // serve static external files app.use( diff --git a/apps/server/src/middleware/authenticate.ts b/apps/server/src/middleware/authenticate.ts index 3b28b23cd..9a58462dd 100644 --- a/apps/server/src/middleware/authenticate.ts +++ b/apps/server/src/middleware/authenticate.ts @@ -90,6 +90,14 @@ export function makeAuthenticateMiddleware(prefix: string) { } } + const authHeader = req.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + const bearerToken = authHeader.slice(7); + if (bearerToken === hashedPassword) { + return next(); + } + } + res.status(401).send('Unauthorized'); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 920ff51b0..3d42c8daa 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 @@ -1484,6 +1487,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'} @@ -1539,6 +1548,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==} @@ -2731,6 +2750,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: @@ -2739,6 +2766,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'} @@ -3330,6 +3360,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'} @@ -3337,6 +3375,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.1: resolution: {integrity: sha512-LMeU/3YjFlFUa4vrPX+RoMMRW5mIpF4Iysgs6gX7A59WCY4BzyF3O28mBr4eMlWuW4DU9wVAVuVcfx29ln1N6g==} @@ -3370,6 +3414,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==} @@ -3574,6 +3621,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + hookable@6.1.0: resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} @@ -3642,6 +3693,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'} @@ -3711,6 +3766,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==} @@ -3735,6 +3793,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==} @@ -4166,6 +4230,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'} @@ -4340,6 +4408,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==} @@ -5085,6 +5157,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.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -6266,6 +6346,10 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@hono/node-server@1.19.14(hono@4.12.27)': + dependencies: + hono: 4.12.27 + '@isaacs/cliui@9.0.0': {} '@isaacs/fs-minipass@4.0.1': @@ -6331,6 +6415,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.27) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + 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.27 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + 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 @@ -7251,6 +7357,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 @@ -7262,6 +7372,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: @@ -7823,7 +7940,7 @@ snapshots: '@electron/asar': 3.4.1 debug: 4.4.3 fs-extra: 7.0.1 - lodash: 4.17.23 + lodash: 4.18.1 temp: 0.9.4 optionalDependencies: '@electron/windows-sign': 1.2.2 @@ -7982,10 +8099,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.1: dependencies: mime-types: 3.0.1 @@ -8053,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 @@ -8322,6 +8452,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.12.27: {} + hookable@6.1.0: {} hosted-git-info@4.1.0: @@ -8405,6 +8537,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} @@ -8460,6 +8594,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: @@ -8478,6 +8614,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 @@ -8867,6 +9007,8 @@ snapshots: picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + playwright-core@1.60.0: {} playwright@1.60.0: @@ -9027,6 +9169,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} resedit@1.7.2: @@ -9833,6 +9977,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.14(@types/react@19.1.12)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.1.12