diff --git a/docs/content/docs/mcp.mdx b/docs/content/docs/mcp.mdx index 168c5a0cd..0a667b2d7 100644 --- a/docs/content/docs/mcp.mdx +++ b/docs/content/docs/mcp.mdx @@ -82,3 +82,58 @@ Each catalog entry includes a link to the server's documentation for setup detai - You can sync a single MCP server to multiple agents at once — no need to configure each one separately. - Environment variables with credential keys are highlighted in the modal so you know what to fill in. - Click **Refresh** to re-detect installed agents if you've installed a new CLI since opening Emdash. + +--- + +## Emdash as an MCP Server + +Emdash can also act as an MCP server itself, letting external AI agents (like Claude Code running in your terminal) create tasks inside Emdash. + +### Enabling the built-in MCP endpoint + +1. Open **Settings → Integrations** +2. Find the **MCP Server** section and toggle it on +3. The MCP URL appears below the toggle — copy it from there + +The server only listens on `127.0.0.1` and is restarted automatically when Emdash starts (if enabled). It tries port 17823 first; if that port is in use, it falls back to 17824–17827 and then an ephemeral port. The exact URL is always shown in Settings after the server starts. + +### Connecting Claude Code + +Copy the MCP URL from **Settings → Integrations → MCP Server**, then add it to your Claude Code config (`.claude/mcp.json` in your project or `~/.claude/mcp.json` globally): + +```json +{ + "mcpServers": { + "emdash": { + "type": "http", + "url": "" + } + } +} +``` + +### Available tools + +| Tool | Description | +| --------------- | ------------------------------------------------------------------ | +| `list_projects` | Returns all projects in Emdash (id, name, path) | +| `list_tasks` | Lists active tasks for a project | +| `create_task` | Queues a new task with a prompt, optional name, and optional agent | + +Tasks created via MCP run exactly like tasks you create manually — Emdash opens a git worktree and starts the agent automatically. + +### Standalone MCP package + +A lightweight stdio bridge is available in the `mcp/` directory of the Emdash repository. It reads the port and token written by the desktop app and proxies calls over the REST API. **Emdash must still be running** — the bridge is just an alternative MCP transport, not a standalone server. + +```json +{ + "mcpServers": { + "emdash": { + "type": "stdio", + "command": "npx", + "args": ["tsx", "/path/to/emdash/mcp/src/index.ts"] + } + } +} +``` diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 000000000..7a00c042b --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,21 @@ +{ + "name": "@emdash/mcp", + "version": "0.1.0", + "description": "Standalone MCP server for Emdash (alternative to the built-in HTTP endpoint)", + "type": "module", + "bin": { + "emdash-mcp": "./src/index.ts" + }, + "scripts": { + "start": "tsx src/index.ts", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "tsx": "^4.19.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.3.3" + } +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 000000000..5a6afd734 --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,376 @@ +#!/usr/bin/env tsx +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import http from 'http'; + +// --------------------------------------------------------------------------- +// Config file resolution +// --------------------------------------------------------------------------- + +function getEmdashUserDataPath(): string { + const platform = process.platform; + if (platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Emdash'); + } else if (platform === 'win32') { + return join(process.env['APPDATA'] ?? join(homedir(), 'AppData', 'Roaming'), 'Emdash'); + } else { + // Linux / other + return join(process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config'), 'Emdash'); + } +} + +interface McpTaskServerConfig { + port: number; + token: string; +} + +function loadConfig(): McpTaskServerConfig { + const configPath = join(getEmdashUserDataPath(), 'mcp-task-server.json'); + try { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as Record).port === 'number' && + typeof (parsed as Record).token === 'string' + ) { + return parsed as McpTaskServerConfig; + } + throw new Error('Invalid config format'); + } catch (err) { + throw new Error( + `Failed to load Emdash MCP config from ${configPath}. ` + + `Make sure the Emdash desktop app is running. Error: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +// --------------------------------------------------------------------------- +// HTTP client helpers +// --------------------------------------------------------------------------- + +function httpRequest( + options: http.RequestOptions, + body?: string +): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode ?? 0, body: data }); + }); + }); + req.setTimeout(15_000, () => { + req.destroy(new Error('Request timed out after 15 s')); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +async function getProjects( + config: McpTaskServerConfig +): Promise> { + const result = await httpRequest({ + hostname: '127.0.0.1', + port: config.port, + path: '/api/projects', + method: 'GET', + headers: { 'x-emdash-token': config.token }, + }); + + if (result.statusCode !== 200) { + throw new Error(`Failed to list projects: HTTP ${result.statusCode}`); + } + + const parsed = JSON.parse(result.body) as { + projects: Array<{ id: string; name: string; path: string; isRemote: boolean }>; + }; + return parsed.projects; +} + +async function listTasks( + config: McpTaskServerConfig, + projectId: string +): Promise> { + const result = await httpRequest({ + hostname: '127.0.0.1', + port: config.port, + path: `/api/tasks?project_id=${encodeURIComponent(projectId)}`, + method: 'GET', + headers: { 'x-emdash-token': config.token }, + }); + + if (result.statusCode !== 200) { + throw new Error(`Failed to list tasks: HTTP ${result.statusCode}`); + } + + const parsed = JSON.parse(result.body) as { + tasks: Array<{ id: string; name: string; status: string; agentId?: string; branch?: string }>; + }; + return parsed.tasks; +} + +async function createTask( + config: McpTaskServerConfig, + params: { projectId: string; prompt: string; taskName?: string; agentId?: string } +): Promise<{ taskRequestId: string }> { + const body = JSON.stringify(params); + const result = await httpRequest( + { + hostname: '127.0.0.1', + port: config.port, + path: '/api/tasks', + method: 'POST', + headers: { + 'x-emdash-token': config.token, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, + body + ); + + if (result.statusCode !== 202) { + let errorMsg = `HTTP ${result.statusCode}`; + try { + const parsed = JSON.parse(result.body) as { error?: string }; + if (parsed.error) errorMsg = parsed.error; + } catch {} + throw new Error(`Failed to create task: ${errorMsg}`); + } + + return JSON.parse(result.body) as { taskRequestId: string }; +} + +// --------------------------------------------------------------------------- +// MCP server +// --------------------------------------------------------------------------- + +const server = new Server( + { name: 'emdash', version: '0.1.0' }, + { + capabilities: { tools: {} }, + instructions: + 'Use list_projects to discover available project IDs, then create_task to queue ' + + 'an AI agent task in a project. Tasks run asynchronously inside the Emdash desktop app.', + } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'list_projects', + description: + 'List all projects configured in the local Emdash desktop app. ' + + 'Call this first to get valid project IDs before calling create_task. ' + + "Returns each project's id, name, path, and whether it is remote.", + inputSchema: { + type: 'object' as const, + properties: {}, + additionalProperties: false, + }, + }, + { + name: 'list_tasks', + description: + 'List active (non-archived) tasks for a project. ' + + 'Use this to confirm a task was created or to check which tasks are currently running. ' + + 'Returns each task\'s id, name, status ("idle" | "running" | "active"), agent, and branch.', + inputSchema: { + type: 'object' as const, + properties: { + project_id: { + type: 'string', + description: 'ID of the project to list tasks for. Obtain from list_projects.', + }, + }, + required: ['project_id'], + additionalProperties: false, + }, + }, + { + name: 'create_task', + description: + 'Queue a new task in an existing Emdash project. Emdash will create a git worktree, ' + + 'save the task, and start the AI agent automatically — the call returns as soon as the ' + + 'task is queued, before the agent begins. Use list_projects first to find the project_id.', + inputSchema: { + type: 'object' as const, + properties: { + project_id: { + type: 'string', + description: 'ID of the project to run the task in. Obtain from list_projects.', + }, + prompt: { + type: 'string', + description: 'Instructions for the AI agent.', + }, + task_name: { + type: 'string', + description: + 'Human-readable task name shown in the Emdash UI. Auto-generated if omitted.', + }, + agent_id: { + type: 'string', + description: + 'Agent to use, e.g. "claude" or "codex". Defaults to "claude" when omitted.', + }, + }, + required: ['project_id', 'prompt'], + additionalProperties: false, + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + let config: McpTaskServerConfig; + try { + config = loadConfig(); + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + + if (name === 'list_projects') { + try { + const projects = await getProjects(config); + if (projects.length === 0) { + return { + content: [{ type: 'text' as const, text: 'No projects found in Emdash.' }], + }; + } + const lines = projects.map( + (p) => `• ${p.name} (id: ${p.id})${p.isRemote ? ' [remote]' : ''}\n path: ${p.path}` + ); + return { + content: [{ type: 'text' as const, text: lines.join('\n') }], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Error listing projects: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'list_tasks') { + const typedArgs = args as Record; + const projectId = typedArgs['project_id']; + if (typeof projectId !== 'string' || !projectId) { + return { + content: [{ type: 'text' as const, text: 'Error: project_id is required' }], + isError: true, + }; + } + try { + const tasks = await listTasks(config, projectId); + if (tasks.length === 0) { + return { + content: [{ type: 'text' as const, text: 'No active tasks found for this project.' }], + }; + } + const lines = tasks.map( + (t) => + `• ${t.name} (id: ${t.id})\n status: ${t.status} agent: ${t.agentId ?? 'unknown'} branch: ${t.branch ?? 'unknown'}` + ); + return { + content: [{ type: 'text' as const, text: lines.join('\n') }], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Error listing tasks: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + if (name === 'create_task') { + const typedArgs = args as Record; + const projectId = typedArgs['project_id']; + const prompt = typedArgs['prompt']; + const taskName = typedArgs['task_name']; + const agentId = typedArgs['agent_id']; + + if (typeof projectId !== 'string' || !projectId) { + return { + content: [{ type: 'text' as const, text: 'Error: project_id is required' }], + isError: true, + }; + } + if (typeof prompt !== 'string' || !prompt) { + return { + content: [{ type: 'text' as const, text: 'Error: prompt is required' }], + isError: true, + }; + } + + try { + const result = await createTask(config, { + projectId, + prompt, + taskName: typeof taskName === 'string' ? taskName : undefined, + agentId: typeof agentId === 'string' ? agentId : undefined, + }); + return { + content: [ + { + type: 'text' as const, + text: `Task queued successfully (request ID: ${result.taskRequestId}). The Emdash app will start the agent shortly.`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Error creating task: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + return { + content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }], + isError: true, + }; +}); + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 000000000..920058a29 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba45657b..49469c887 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,7 +217,7 @@ importers: version: 2.6.1 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.2)) + version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) tsconfig-paths: specifier: ^3.15.0 version: 3.15.0 @@ -305,7 +305,7 @@ importers: version: 0.6.14(prettier@3.6.2) tailwindcss: specifier: ^3.3.6 - version: 3.4.19(yaml@2.8.2) + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -316,6 +316,22 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.32)(jsdom@29.0.1)(lightningcss@1.31.1) + mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) + tsx: + specifier: ^4.19.0 + version: 4.21.0 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + packages: 7zip-bin@5.2.0: @@ -562,6 +578,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -580,6 +602,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -598,6 +626,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -616,6 +650,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -634,6 +674,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -652,6 +698,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -670,6 +722,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -688,6 +746,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -706,6 +770,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -724,6 +794,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -742,6 +818,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -760,6 +842,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -778,6 +866,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -796,6 +890,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -814,6 +914,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -832,6 +938,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -850,6 +962,18 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -868,6 +992,18 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -886,6 +1022,18 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -904,6 +1052,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -922,6 +1076,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -940,6 +1100,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -958,6 +1124,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1003,6 +1175,12 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@hono/node-server@1.19.12': + resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -1069,6 +1247,16 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@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 + '@monaco-editor/loader@1.7.0': resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} @@ -2129,6 +2317,9 @@ packages: '@types/node@20.19.32': resolution: {integrity: sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==} + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/pidusage@2.0.5': resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} @@ -2304,6 +2495,10 @@ packages: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2330,6 +2525,14 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} + 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: @@ -2338,6 +2541,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -2541,6 +2747,10 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + 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. @@ -2595,6 +2805,10 @@ packages: builder-util@26.8.1: resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2833,9 +3047,25 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js@3.48.0: resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} @@ -2845,6 +3075,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -3132,6 +3366,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3313,6 +3551,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + efrt@2.7.0: resolution: {integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==} engines: {node: '>=12.0.0'} @@ -3356,6 +3597,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -3431,10 +3676,18 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3523,9 +3776,21 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3541,6 +3806,16 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3566,6 +3841,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3601,6 +3879,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3632,6 +3914,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -3649,6 +3935,10 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -3897,6 +4187,10 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -3914,6 +4208,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@4.0.1: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} @@ -3963,6 +4261,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==} @@ -4013,6 +4315,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@1.0.4: resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} @@ -4135,6 +4441,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4216,6 +4525,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4246,6 +4558,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-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4571,6 +4889,14 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4705,10 +5031,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -4969,6 +5303,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5040,6 +5378,10 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -5062,6 +5404,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -5103,6 +5448,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -5296,6 +5645,10 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -5303,6 +5656,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} @@ -5313,6 +5670,14 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -5559,6 +5924,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5619,10 +5988,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -5638,6 +6015,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5777,6 +6157,10 @@ packages: state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -5995,6 +6379,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -6035,6 +6423,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -6053,6 +6446,10 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -6137,6 +6534,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -6176,6 +6577,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -6404,6 +6809,11 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -6751,6 +7161,9 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.27.4': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -6760,6 +7173,9 @@ snapshots: '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.27.4': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -6769,6 +7185,9 @@ snapshots: '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.27.4': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -6778,6 +7197,9 @@ snapshots: '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.27.4': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -6787,6 +7209,9 @@ snapshots: '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.27.4': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -6796,6 +7221,9 @@ snapshots: '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.27.4': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -6805,6 +7233,9 @@ snapshots: '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.27.4': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -6814,6 +7245,9 @@ snapshots: '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.27.4': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -6823,6 +7257,9 @@ snapshots: '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.27.4': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -6832,6 +7269,9 @@ snapshots: '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.27.4': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -6841,6 +7281,9 @@ snapshots: '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.27.4': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -6850,6 +7293,9 @@ snapshots: '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.27.4': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -6859,6 +7305,9 @@ snapshots: '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.27.4': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -6868,6 +7317,9 @@ snapshots: '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.27.4': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -6877,6 +7329,9 @@ snapshots: '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.27.4': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -6886,6 +7341,9 @@ snapshots: '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.27.4': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -6895,6 +7353,12 @@ snapshots: '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -6904,6 +7368,12 @@ snapshots: '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -6913,6 +7383,12 @@ snapshots: '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -6922,6 +7398,9 @@ snapshots: '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.4': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -6931,6 +7410,9 @@ snapshots: '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.4': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -6940,6 +7422,9 @@ snapshots: '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.4': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -6949,6 +7434,9 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.4': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -6994,6 +7482,10 @@ snapshots: '@gar/promisify@1.1.3': optional: true + '@hono/node-server@1.19.12(hono@4.12.9)': + dependencies: + hono: 4.12.9 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -7073,6 +7565,28 @@ snapshots: dependencies: langium: 3.3.1 + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.12(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 @@ -8138,6 +8652,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + '@types/pidusage@2.0.5': {} '@types/plist@3.0.5': @@ -8355,6 +8873,11 @@ snapshots: abbrev@3.0.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -8380,6 +8903,10 @@ snapshots: indent-string: 4.0.0 optional: true + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -8391,6 +8918,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -8682,6 +9216,20 @@ snapshots: bluebird@3.7.2: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -8780,6 +9328,8 @@ snapshots: transitivePeerDependencies: - supports-color + bytes@3.1.2: {} + cac@6.7.14: {} cacache@15.3.0: @@ -9049,8 +9599,16 @@ snapshots: console-control-strings@1.1.0: optional: true + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-js@3.48.0: {} core-util-is@1.0.2: @@ -9058,6 +9616,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -9369,6 +9932,8 @@ snapshots: delegates@1.0.0: optional: true + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -9482,6 +10047,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + efrt@2.7.0: {} ejs@3.1.10: @@ -9568,6 +10135,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -9756,8 +10325,39 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -9894,8 +10494,16 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -9914,6 +10522,44 @@ snapshots: exponential-backoff@3.1.3: {} + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + 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.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} extract-zip@2.0.1: @@ -9943,6 +10589,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -9975,6 +10623,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -10011,6 +10670,8 @@ snapshots: format@0.2.2: {} + forwarded@0.2.0: {} + fraction.js@5.3.4: {} framer-motion@12.33.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -10022,6 +10683,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -10386,6 +11049,8 @@ snapshots: highlightjs-vue@1.0.0: {} + hono@4.12.9: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 @@ -10402,6 +11067,14 @@ snapshots: http-cache-semantics@4.2.0: {} + 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@4.0.1: dependencies: '@tootallnate/once': 1.1.2 @@ -10466,6 +11139,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -10505,6 +11182,8 @@ snapshots: ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} + is-alphabetical@1.0.4: {} is-alphabetical@2.0.1: {} @@ -10621,6 +11300,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10692,6 +11373,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -10732,6 +11415,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -11167,6 +11854,10 @@ snapshots: mdn-data@2.27.1: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -11434,10 +12125,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -11705,6 +12402,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -11804,6 +12505,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -11819,6 +12522,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@8.4.0: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -11843,6 +12548,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -11876,12 +12583,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + tsx: 4.21.0 yaml: 2.8.2 postcss-nested@6.2.0(postcss@8.5.6): @@ -11994,6 +12702,11 @@ snapshots: '@types/node': 20.19.32 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -12001,12 +12714,25 @@ snapshots: punycode@2.3.1: {} + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + query-selector-shadow-dom@1.0.1: {} queue-microtask@1.2.3: {} quick-lru@5.1.1: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -12365,6 +13091,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.0 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -12423,11 +13159,36 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 optional: true + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: optional: true @@ -12453,6 +13214,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -12621,6 +13384,8 @@ snapshots: state-local@1.0.7: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -12791,11 +13556,11 @@ snapshots: tailwind-merge@3.4.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.2)): + tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(yaml@2.8.2) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) - tailwindcss@3.4.19(yaml@2.8.2): + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -12814,7 +13579,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -12909,6 +13674,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.27 @@ -12944,6 +13711,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -12959,6 +13733,12 @@ snapshots: type-fest@0.20.2: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -13074,6 +13854,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -13105,6 +13887,8 @@ snapshots: uuid@11.1.0: {} + vary@1.1.2: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -13360,6 +14144,10 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c9885dc8b..28e27a670 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - '.' + - 'mcp' onlyBuiltDependencies: - better-sqlite3 diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 2ef8028a7..b8fd26066 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -34,6 +34,7 @@ import { changelogController } from './changelogIpc'; import { registerAutomationsIpc } from './automationsIpc'; import { registerIntegrationsIpc } from './integrationsIpc'; import { registerPerformanceIpc } from './performanceIpc'; +import { registerMcpTaskIpc } from './mcpTaskIpc'; export const rpcRouter = createRPCRouter({ db: databaseController, @@ -82,4 +83,5 @@ export function registerAllIpc() { registerAutomationsIpc(); registerIntegrationsIpc(); registerPerformanceIpc(); + registerMcpTaskIpc(); } diff --git a/src/main/ipc/mcpTaskIpc.ts b/src/main/ipc/mcpTaskIpc.ts new file mode 100644 index 000000000..62f3c1d4e --- /dev/null +++ b/src/main/ipc/mcpTaskIpc.ts @@ -0,0 +1,34 @@ +import { app, BrowserWindow, ipcMain } from 'electron'; +import { mcpTaskServer } from '../services/McpTaskServer'; +import { log } from '../lib/logger'; + +export function registerMcpTaskIpc(): void { + // Hint a new window to drain queued tasks once it's ready + app.on('browser-window-created', (_, window) => { + window.webContents.once('did-finish-load', () => { + if (mcpTaskServer.hasPendingTasks()) { + window.webContents.send('mcp:taskAvailable'); + } + }); + }); + + // Pull-based drain — renderer calls this when its listener is ready + ipcMain.handle('mcp:drainTaskQueue', () => { + const tasks = mcpTaskServer.drainQueue(); + if (tasks.length > 0) { + log.info(`[MCP] Draining ${tasks.length} queued task request(s)`); + } + return { success: true, data: tasks }; + }); + + // Expose server connection info to the renderer (for the settings UI) + ipcMain.handle('mcp:getServerInfo', () => { + const port = mcpTaskServer.getPort(); + if (!port) return { running: false }; + return { + running: true, + port, + mcpUrl: `http://127.0.0.1:${port}/mcp`, + }; + }); +} diff --git a/src/main/ipc/settingsIpc.ts b/src/main/ipc/settingsIpc.ts index 26d22a89b..b8c0b4367 100644 --- a/src/main/ipc/settingsIpc.ts +++ b/src/main/ipc/settingsIpc.ts @@ -1,7 +1,45 @@ import { AppSettingsUpdate, getAppSettings, updateAppSettings } from '../settings'; import { createRPCController } from '../../shared/ipc/rpc'; +import { mcpTaskServer } from '../services/McpTaskServer'; +import { log } from '../lib/logger'; export const appSettingsController = createRPCController({ get: async () => getAppSettings(), - update: (partial: AppSettingsUpdate) => updateAppSettings(partial || {}), + update: async (partial: AppSettingsUpdate) => { + const before = getAppSettings().mcp; + const result = updateAppSettings(partial || {}); + const after = getAppSettings().mcp; + + const wasEnabled = before?.enabled ?? false; + const isEnabled = after?.enabled ?? false; + const portChanged = (before?.port ?? undefined) !== (after?.port ?? undefined); + + if (!wasEnabled && isEnabled) { + try { + await mcpTaskServer.start(after?.port); + } catch (err) { + log.warn('[settingsIpc] Failed to start MCP server', { error: String(err) }); + } + } else if (wasEnabled && !isEnabled) { + mcpTaskServer.stop(); + } else if (isEnabled && portChanged) { + const prevPort = mcpTaskServer.getPort() || undefined; + mcpTaskServer.stop(); + try { + await mcpTaskServer.start(after?.port); + } catch (err) { + log.warn('[settingsIpc] Failed to restart MCP server on port change', { + error: String(err), + }); + // Best-effort: try to bring the server back up on the previous port + try { + await mcpTaskServer.start(prevPort); + } catch { + // ignore — server stays down + } + } + } + + return result; + }, }); diff --git a/src/main/main.ts b/src/main/main.ts index 58ef49f70..9257964c0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -128,6 +128,8 @@ import { workspaceProviderService } from './services/WorkspaceProviderService'; import { sshService } from './services/ssh/SshService'; import { taskLifecycleService } from './services/TaskLifecycleService'; import { agentEventService } from './services/AgentEventService'; +import { mcpTaskServer } from './services/McpTaskServer'; +import { getAppSettings } from './settings'; import * as telemetry from './telemetry'; import { errorTracking } from './errorTracking'; import { join } from 'path'; @@ -312,6 +314,15 @@ app.whenReady().then(async () => { console.warn('Failed to start agent event service:', error); } + // Start MCP task server only when enabled in settings + if (getAppSettings().mcp?.enabled) { + try { + await mcpTaskServer.start(getAppSettings().mcp?.port); + } catch (error) { + console.warn('Failed to start MCP task server:', error); + } + } + // Register IPC handlers registerAllIpc(); @@ -368,6 +379,8 @@ app.on('before-quit', () => { autoUpdateService.shutdown(); // Stop agent event HTTP server agentEventService.stop(); + // Stop MCP task server + mcpTaskServer.stop(); // Stop any lifecycle run scripts so they do not outlive the app process. taskLifecycleService.shutdown(); diff --git a/src/main/preload.ts b/src/main/preload.ts index f50116e83..803e3a297 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -939,6 +939,15 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.removeListener('automation:trigger-available', wrapped); }; }, + mcpDrainTaskQueue: () => ipcRenderer.invoke('mcp:drainTaskQueue'), + mcpGetServerInfo: () => ipcRenderer.invoke('mcp:getServerInfo'), + onMcpTaskAvailable: (listener: () => void) => { + const wrapped = (_: Electron.IpcRendererEvent) => listener(); + ipcRenderer.on('mcp:taskAvailable', wrapped); + return () => { + ipcRenderer.removeListener('mcp:taskAvailable', wrapped); + }; + }, // Integrations integrationsStatusMap: () => ipcRenderer.invoke('integrations:statusMap'), diff --git a/src/main/services/McpTaskServer.ts b/src/main/services/McpTaskServer.ts new file mode 100644 index 000000000..a869b93e6 --- /dev/null +++ b/src/main/services/McpTaskServer.ts @@ -0,0 +1,550 @@ +import http from 'http'; +import crypto from 'crypto'; +import { BrowserWindow, app } from 'electron'; +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { databaseService } from './DatabaseService'; +import { log } from '../lib/logger'; + +export interface McpTaskRequest { + id: string; + projectId: string; + prompt: string; + taskName?: string; + agentId?: string; +} + +// --------------------------------------------------------------------------- +// MCP tool definitions (reused for both /mcp and /api routes) +// --------------------------------------------------------------------------- + +const MCP_TOOLS = [ + { + name: 'list_projects', + description: + 'List all projects configured in the local Emdash desktop app. ' + + 'Call this first to get valid project IDs before calling create_task. ' + + "Returns each project's id, name, path, and whether it is remote.", + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'list_tasks', + description: + 'List active (non-archived) tasks for a project. ' + + 'Use this to confirm a task was created or to check which tasks are currently running. ' + + 'Returns each task\'s id, name, status ("idle" | "running" | "active"), agent, and branch.', + inputSchema: { + type: 'object', + properties: { + project_id: { + type: 'string', + description: 'ID of the project to list tasks for. Obtain from list_projects.', + }, + }, + required: ['project_id'], + additionalProperties: false, + }, + }, + { + name: 'create_task', + description: + 'Queue a new task in an existing Emdash project. Emdash will create a git worktree, ' + + 'save the task, and start the AI agent automatically — the call returns as soon as the ' + + 'task is queued, before the agent begins. Use list_projects first to find the project_id.', + inputSchema: { + type: 'object', + properties: { + project_id: { + type: 'string', + description: 'ID of the project to run the task in. Obtain from list_projects.', + }, + prompt: { + type: 'string', + description: 'Instructions for the AI agent.', + }, + task_name: { + type: 'string', + description: + 'Human-readable task name shown in the Emdash UI. Auto-generated if omitted.', + }, + agent_id: { + type: 'string', + description: 'Agent to use, e.g. "claude" or "codex". Defaults to "claude" when omitted.', + }, + }, + required: ['project_id', 'prompt'], + additionalProperties: false, + }, + }, +]; + +interface McpToolResult { + content: Array<{ type: string; text: string }>; + isError?: boolean; +} + +// --------------------------------------------------------------------------- +// Server class +// --------------------------------------------------------------------------- + +class McpTaskServer { + private server: http.Server | null = null; + private port = 0; + private token = ''; + private taskQueue: McpTaskRequest[] = []; + + drainQueue(): McpTaskRequest[] { + return this.taskQueue.splice(0); + } + + hasPendingTasks(): boolean { + return this.taskQueue.length > 0; + } + + /** + * Start the server. + * + * Tries each candidate port by attempting to listen directly — no probe step, + * which avoids a probe-then-listen TOCTOU race. Falls back to an ephemeral + * port (0) as a last resort. + */ + async start(preferredPort?: number): Promise { + if (this.server) return; + + this.token = crypto.randomUUID(); + const candidates = preferredPort + ? [preferredPort, 17823, 17824, 17825, 17826, 17827].filter((p, i, a) => a.indexOf(p) === i) + : [17823, 17824, 17825, 17826, 17827]; + // Append 0 as final fallback — OS assigns an ephemeral port + candidates.push(0); + + const server = http.createServer((req, res) => { + this.handleRequest(req, res).catch((err) => { + log.error('[McpTaskServer] unhandled request error', { error: String(err) }); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + }); + }); + + for (const port of candidates) { + try { + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + server.removeListener('error', onError); + reject(err); + }; + server.once('error', onError); + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', onError); + resolve(); + }); + }); + // Bind succeeded — commit + const addr = server.address(); + if (addr && typeof addr === 'object') this.port = addr.port; + this.server = server; + this.persistConfig(); + log.info('[McpTaskServer] started', { port: this.port }); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE' && port !== 0) { + continue; // Try the next candidate + } + server.close(); + log.error('[McpTaskServer] failed to start', { error: String(err) }); + throw err; + } + } + + server.close(); + throw new Error('No available port found for MCP task server'); + } + + stop(): void { + if (this.server) { + this.server.close(); + this.server = null; + this.port = 0; + } + } + + getPort(): number { + return this.port; + } + + // --------------------------------------------------------------------------- + // Config persistence + // --------------------------------------------------------------------------- + + private persistConfig(): void { + try { + const dir = app.getPath('userData'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const configPath = join(dir, 'mcp-task-server.json'); + writeFileSync( + configPath, + JSON.stringify({ + port: this.port, + token: this.token, + mcpUrl: `http://127.0.0.1:${this.port}/mcp`, + }), + 'utf-8' + ); + } catch (err) { + log.warn('[McpTaskServer] failed to persist config', { error: String(err) }); + } + } + + // --------------------------------------------------------------------------- + // Request routing + // --------------------------------------------------------------------------- + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + // MCP endpoint — no token required (localhost-only security model) + if (req.url === '/mcp') { + await this.handleMcpRequest(req, res); + return; + } + + // Legacy REST API — requires token (used by the standalone mcp/ package) + res.setHeader('Content-Type', 'application/json'); + const authToken = req.headers['x-emdash-token']; + if (authToken !== this.token) { + res.writeHead(403); + res.end(JSON.stringify({ error: 'Forbidden' })); + return; + } + + if (req.method === 'GET' && req.url === '/api/projects') { + await this.handleApiProjects(res); + return; + } + + if (req.method === 'GET' && req.url?.startsWith('/api/tasks')) { + await this.handleApiGetTasks(req, res); + return; + } + + if (req.method === 'POST' && req.url === '/api/tasks') { + await this.handleApiTasks(req, res); + return; + } + + res.writeHead(404); + res.end(JSON.stringify({ error: 'Not found' })); + } + + // --------------------------------------------------------------------------- + // MCP Streamable-HTTP transport (JSON-RPC 2.0 over POST) + // Spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports + // --------------------------------------------------------------------------- + + private async handleMcpRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + if (req.method !== 'POST') { + // SSE (GET) not supported — this server only handles request/response + res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Only POST is supported on /mcp' })); + return; + } + + const body = await readBody(req); + let rpc: Record; + try { + rpc = JSON.parse(body) as Record; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }) + ); + return; + } + + const { method, params, id } = rpc; + + // Notifications have no id — acknowledge with 202, no body + if (id === undefined || id === null) { + res.writeHead(202); + res.end(''); + return; + } + + const ok = (result: unknown) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id, result })); + }; + const fail = (code: number, message: string) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })); + }; + + if (method === 'initialize') { + // Negotiate protocol version: use the client's requested version if we support it, + // otherwise fall back to our preferred version. + const SUPPORTED = ['2025-03-26', '2024-11-05', '2024-10-07']; + const PREFERRED = '2025-03-26'; + const requested = ((params as Record)?.['protocolVersion'] as string) ?? ''; + const negotiated = SUPPORTED.includes(requested) ? requested : PREFERRED; + + ok({ + protocolVersion: negotiated, + capabilities: { tools: {} }, + serverInfo: { name: 'emdash', version: '0.1.0' }, + instructions: + 'Use list_projects to discover available project IDs, then create_task to queue ' + + 'an AI agent task in a project. Tasks run asynchronously inside the Emdash desktop app.', + }); + return; + } + + if (method === 'tools/list') { + ok({ tools: MCP_TOOLS }); + return; + } + + if (method === 'tools/call') { + const p = (params ?? {}) as Record; + const toolName = p['name'] as string; + const args = (p['arguments'] ?? {}) as Record; + const result = await this.callTool(toolName, args); + ok(result); + return; + } + + fail(-32601, 'Method not found'); + } + + private async callTool(name: string, args: Record): Promise { + if (name === 'list_projects') { + try { + const projects = await databaseService.getProjects(); + if (projects.length === 0) { + return { content: [{ type: 'text', text: 'No projects found in Emdash.' }] }; + } + const lines = projects.map((p) => { + const remote = (p as unknown as Record).isRemote ? ' [remote]' : ''; + return `• ${p.name} (id: ${p.id})${remote}\n path: ${p.path}`; + }); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error listing projects: ${String(err)}` }], + isError: true, + }; + } + } + + if (name === 'list_tasks') { + const projectId = args['project_id']; + if (typeof projectId !== 'string' || !projectId) { + return { + content: [{ type: 'text', text: 'Error: project_id is required' }], + isError: true, + }; + } + try { + const tasks = await databaseService.getTasks(projectId); + if (tasks.length === 0) { + return { content: [{ type: 'text', text: 'No active tasks found for this project.' }] }; + } + const lines = tasks.map( + (t) => + `• ${t.name} (id: ${t.id})\n status: ${t.status} agent: ${t.agentId ?? 'unknown'} branch: ${t.branch}` + ); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error listing tasks: ${String(err)}` }], + isError: true, + }; + } + } + + if (name === 'create_task') { + const projectId = args['project_id']; + const prompt = args['prompt']; + const taskName = args['task_name']; + const agentId = args['agent_id']; + + if (typeof projectId !== 'string' || !projectId) { + return { + content: [{ type: 'text', text: 'Error: project_id is required' }], + isError: true, + }; + } + if (typeof prompt !== 'string' || !prompt) { + return { content: [{ type: 'text', text: 'Error: prompt is required' }], isError: true }; + } + + try { + const projects = await databaseService.getProjects(); + if (!projects.find((p) => p.id === projectId)) { + return { + content: [{ type: 'text', text: `Error: project not found: ${projectId}` }], + isError: true, + }; + } + + const taskRequest: McpTaskRequest = { + id: crypto.randomUUID(), + projectId, + prompt, + taskName: typeof taskName === 'string' ? taskName : undefined, + agentId: typeof agentId === 'string' ? agentId : undefined, + }; + this.taskQueue.push(taskRequest); + this.notifyRenderer(); + + return { + content: [ + { + type: 'text', + text: `Task queued (id: ${taskRequest.id}). Emdash will start the agent shortly.`, + }, + ], + }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error creating task: ${String(err)}` }], + isError: true, + }; + } + } + + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; + } + + // --------------------------------------------------------------------------- + // Legacy REST API handlers + // --------------------------------------------------------------------------- + + private async handleApiProjects(res: http.ServerResponse): Promise { + try { + const projects = await databaseService.getProjects(); + const sanitized = projects.map((p) => ({ + id: p.id, + name: p.name, + path: p.path, + isRemote: (p as unknown as Record).isRemote ?? false, + })); + res.writeHead(200); + res.end(JSON.stringify({ projects: sanitized })); + } catch { + res.writeHead(500); + res.end(JSON.stringify({ error: 'Failed to list projects' })); + } + } + + private async handleApiGetTasks( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { + const url = new URL(req.url!, 'http://127.0.0.1'); + const projectId = url.searchParams.get('project_id'); + if (!projectId) { + res.writeHead(400); + res.end(JSON.stringify({ error: 'project_id is required' })); + return; + } + try { + const tasks = await databaseService.getTasks(projectId); + const sanitized = tasks.map((t) => ({ + id: t.id, + name: t.name, + status: t.status, + agentId: t.agentId, + branch: t.branch, + })); + res.writeHead(200); + res.end(JSON.stringify({ tasks: sanitized })); + } catch { + res.writeHead(500); + res.end(JSON.stringify({ error: 'Failed to list tasks' })); + } + } + + private async handleApiTasks(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const body = await readBody(req, 100_000); + try { + const data = JSON.parse(body) as Record; + const { projectId, prompt, taskName, agentId } = data; + + if (!projectId || typeof projectId !== 'string') { + res.writeHead(400); + res.end(JSON.stringify({ error: 'projectId is required' })); + return; + } + if (!prompt || typeof prompt !== 'string') { + res.writeHead(400); + res.end(JSON.stringify({ error: 'prompt is required' })); + return; + } + + const projects = await databaseService.getProjects(); + if (!projects.find((p) => p.id === projectId)) { + res.writeHead(404); + res.end(JSON.stringify({ error: `Project not found: ${projectId}` })); + return; + } + + const taskRequest: McpTaskRequest = { + id: crypto.randomUUID(), + projectId, + prompt, + taskName: typeof taskName === 'string' ? taskName : undefined, + agentId: typeof agentId === 'string' ? agentId : undefined, + }; + this.taskQueue.push(taskRequest); + this.notifyRenderer(); + + res.writeHead(202); + res.end(JSON.stringify({ taskRequestId: taskRequest.id })); + } catch { + res.writeHead(400); + res.end(JSON.stringify({ error: 'Invalid JSON body' })); + } + } + + // --------------------------------------------------------------------------- + // Renderer notification + // --------------------------------------------------------------------------- + + private notifyRenderer(): void { + const target = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + if (target && !target.isDestroyed()) { + target.webContents.send('mcp:taskAvailable'); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readBody(req: http.IncomingMessage, maxBytes = 1_000_000): Promise { + return new Promise((resolve, reject) => { + let body = ''; + let destroyed = false; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + if (body.length > maxBytes) { + destroyed = true; + req.destroy(new Error('Request body too large')); + } + }); + req.on('end', () => { + if (!destroyed) resolve(body); + }); + req.on('error', reject); + }); +} + +export const mcpTaskServer = new McpTaskServer(); diff --git a/src/main/settings.ts b/src/main/settings.ts index 82cd0cc3d..76c4c2b20 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -131,6 +131,10 @@ export interface AppSettings { changelog?: { dismissedVersions: string[]; }; + mcp?: { + enabled: boolean; + port?: number; + }; } function getPlatformTaskSwitchDefaults(): { next: ShortcutBinding; prev: ShortcutBinding } { @@ -218,6 +222,9 @@ const DEFAULT_SETTINGS: AppSettings = { changelog: { dismissedVersions: [], }, + mcp: { + enabled: false, + }, }; function getSettingsPath(): string { @@ -629,6 +636,23 @@ export function normalizeSettings(input: AppSettings): AppSettings { : [], }; + // MCP + const mcp = (input as any)?.mcp || {}; + const rawMcpPort = mcp?.port; + let mcpPort: number | undefined; + if ( + typeof rawMcpPort === 'number' && + Number.isInteger(rawMcpPort) && + rawMcpPort >= 1024 && + rawMcpPort <= 65535 + ) { + mcpPort = rawMcpPort; + } + out.mcp = { + enabled: Boolean(mcp?.enabled ?? DEFAULT_SETTINGS.mcp!.enabled), + ...(mcpPort !== undefined ? { port: mcpPort } : {}), + }; + return out; } diff --git a/src/renderer/components/McpSettingsCard.tsx b/src/renderer/components/McpSettingsCard.tsx new file mode 100644 index 000000000..c1d9945ee --- /dev/null +++ b/src/renderer/components/McpSettingsCard.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from 'react'; +import { ExternalLink } from 'lucide-react'; +import { Switch } from './ui/switch'; +import { Input } from './ui/input'; +import { useAppSettings } from '@/contexts/AppSettingsProvider'; + +const DEFAULT_PORT = 17823; +const DOCS_URL = 'https://emdash.ai/docs/mcp'; + +const McpSettingsCard: React.FC = () => { + const { settings, updateSettings, isLoading: loading } = useAppSettings(); + const [serverInfo, setServerInfo] = useState<{ + running: boolean; + port?: number; + mcpUrl?: string; + }>({ + running: false, + }); + const [portInput, setPortInput] = useState(''); + + const enabled = settings?.mcp?.enabled ?? false; + const configuredPort = settings?.mcp?.port; + + useEffect(() => { + setPortInput(String(configuredPort ?? DEFAULT_PORT)); + }, [configuredPort]); + + useEffect(() => { + if (!enabled) { + setServerInfo({ running: false }); + return; + } + let cancelled = false; + // Short delay so a port-change restart has time to complete before we query. + const timer = setTimeout(() => { + window.electronAPI + .mcpGetServerInfo() + .then((info) => { + if (!cancelled) { + setServerInfo( + info.running + ? { running: true, port: info.port, mcpUrl: info.mcpUrl } + : { running: false } + ); + } + }) + .catch(() => { + if (!cancelled) setServerInfo({ running: false }); + }); + }, 300); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [enabled, configuredPort]); + + const handlePortBlur = () => { + const trimmed = portInput.trim(); + const isDigitsOnly = /^\d+$/.test(trimmed); + const parsed = isDigitsOnly ? parseInt(trimmed, 10) : NaN; + if ( + !isNaN(parsed) && + parsed >= 1024 && + parsed <= 65535 && + parsed !== (configuredPort ?? DEFAULT_PORT) + ) { + updateSettings({ mcp: { port: parsed } }); + } else { + setPortInput(String(configuredPort ?? DEFAULT_PORT)); + } + }; + + return ( +
+
+
+

MCP Server

+

+ Expose an MCP endpoint so AI agents (e.g. Claude Code) can create tasks in Emdash.{' '} + +

+
+ updateSettings({ mcp: { enabled: next } })} + /> +
+ + {enabled && ( +
+
+
+

Port

+

+ Preferred port (1024–65535). Falls back to the next available if taken. +

+
+ setPortInput(e.target.value)} + onBlur={handlePortBlur} + onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} + /> +
+ + {serverInfo.running && serverInfo.mcpUrl && ( +
+

MCP URL

+

+ {serverInfo.mcpUrl} +

+
+ )} +
+ )} +
+ ); +}; + +export default McpSettingsCard; diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx index 293cb280d..dc210dd38 100644 --- a/src/renderer/components/SettingsPage.tsx +++ b/src/renderer/components/SettingsPage.tsx @@ -18,6 +18,7 @@ import { AutoTrustWorktreesRow, } from './TaskSettingsRows'; import IntegrationsCard from './IntegrationsCard'; +import McpSettingsCard from './McpSettingsCard'; import RepositorySettingsCard from './RepositorySettingsCard'; import ThemeCard from './ThemeCard'; import KeyboardSettingsCard from './KeyboardSettingsCard'; @@ -245,6 +246,7 @@ export const SettingsPage: React.FC = ({ initialTab, onClose description: 'Connect external services and tools.', sections: [ { title: 'Integrations', component: }, + { component: }, { component: }, ], }, diff --git a/src/renderer/hooks/useMcpTaskTrigger.ts b/src/renderer/hooks/useMcpTaskTrigger.ts new file mode 100644 index 000000000..c5ae68536 --- /dev/null +++ b/src/renderer/hooks/useMcpTaskTrigger.ts @@ -0,0 +1,190 @@ +import { useEffect, useCallback, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useProjectManagementContext } from '../contexts/ProjectManagementProvider'; +import { useToast } from './use-toast'; +import { rpc } from '../lib/rpc'; +import { makePtyId } from '@shared/ptyId'; +import { isValidProviderId } from '@shared/providers/registry'; + +const DEFAULT_AGENT_ID = 'claude'; + +interface McpTaskRequest { + id: string; + projectId: string; + prompt: string; + taskName?: string; + agentId?: string; +} + +/** + * Global listener for MCP task requests from the main process. + * Creates a task and starts the agent fully in the background — + * no view switching, no navigation away from the current screen. + * + * Uses a pull-based model: the main process queues requests and + * sends a hint event. This hook drains the queue when ready. + */ +export function useMcpTaskTrigger(): void { + const { projects, isInitialLoadComplete } = useProjectManagementContext(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const ptyExitUnsubs = useRef void>>(new Map()); + + const runMcpTaskInBackground = useCallback( + async (request: McpTaskRequest) => { + const project = projects.find((p) => p.id === request.projectId); + if (!project) { + toast({ + title: 'MCP task failed', + description: `Project not found: ${request.projectId}`, + variant: 'destructive', + }); + return; + } + + const now = new Date(); + const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const taskName = request.taskName || `MCP task — ${timeStr}`; + const agentId = isValidProviderId(request.agentId) ? request.agentId : DEFAULT_AGENT_ID; + + let taskId = ''; + let taskPath = ''; + let branch = ''; + let worktreeCreated = false; + let taskSaved = false; + + try { + // --------------------------------------------------------------- + // 1. Create worktree + // --------------------------------------------------------------- + const worktreeResult = await window.electronAPI.worktreeCreate({ + projectPath: project.path, + taskName, + projectId: project.id, + }); + if (!worktreeResult?.success || !worktreeResult.worktree) { + throw new Error(worktreeResult?.error || 'Failed to create worktree'); + } + branch = worktreeResult.worktree.branch; + taskPath = worktreeResult.worktree.path; + taskId = worktreeResult.worktree.id; + worktreeCreated = true; + + // --------------------------------------------------------------- + // 2. Save the task to the database + // --------------------------------------------------------------- + await rpc.db.saveTask({ + id: taskId, + projectId: project.id, + name: taskName, + branch, + path: taskPath, + status: 'idle', + agentId, + metadata: { + initialPrompt: request.prompt, + autoApprove: true, + nameGenerated: !request.taskName, + }, + useWorktree: true, + }); + + taskSaved = true; + void queryClient.invalidateQueries({ queryKey: ['tasks', project.id] }); + + // --------------------------------------------------------------- + // 3. Start the agent PTY in the background + // --------------------------------------------------------------- + const ptyId = makePtyId(agentId, 'main', taskId); + const ptyResult = await window.electronAPI.ptyStartDirect({ + id: ptyId, + providerId: agentId, + cwd: taskPath, + autoApprove: true, + initialPrompt: request.prompt, + remote: + project.isRemote && project.sshConnectionId + ? { connectionId: project.sshConnectionId } + : undefined, + }); + + if (ptyResult && !ptyResult.ok) { + throw new Error(ptyResult.error || 'PTY failed to start'); + } + + const unsubExit = window.electronAPI.onPtyExit(ptyId, () => { + ptyExitUnsubs.current.delete(ptyId); + unsubExit(); + }); + ptyExitUnsubs.current.set(ptyId, unsubExit); + + toast({ + title: 'MCP task started', + description: `"${taskName}" started in ${project.name}`, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to start MCP task'; + toast({ + title: 'MCP task failed', + description: errorMessage, + variant: 'destructive', + }); + + // Best-effort rollback: remove the saved task and its worktree + if (taskSaved && taskId) { + try { + await rpc.db.deleteTask(taskId); + void queryClient.invalidateQueries({ queryKey: ['tasks', project.id] }); + } catch { + // ignore + } + } + if (worktreeCreated && taskId && taskPath) { + try { + await window.electronAPI.worktreeRemove({ + projectPath: project.path, + worktreeId: taskId, + worktreePath: taskPath, + }); + } catch { + // Best-effort cleanup — don't mask the original error + } + } + } + }, + [projects, toast, queryClient] + ); + + const drainTasks = useCallback(async () => { + try { + const result = await window.electronAPI.mcpDrainTaskQueue(); + if (result.success && result.data) { + for (const task of result.data) { + void runMcpTaskInBackground(task); + } + } + } catch (err) { + console.error('[MCP] Failed to drain task queue:', err); + } + }, [runMcpTaskInBackground]); + + useEffect(() => { + if (!isInitialLoadComplete) return; + + const unsub = window.electronAPI.onMcpTaskAvailable(() => { + void drainTasks(); + }); + + // Drain on mount — pick up anything queued before we were ready + void drainTasks(); + + return () => { + unsub(); + for (const unsubFn of ptyExitUnsubs.current.values()) { + unsubFn(); + } + ptyExitUnsubs.current.clear(); + }; + }, [drainTasks, isInitialLoadComplete]); +} diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 2dd571332..572a24364 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -1433,6 +1433,21 @@ declare global { error?: string; }>; onAutomationTriggerAvailable: (listener: () => void) => () => void; + mcpDrainTaskQueue: () => Promise<{ + success: boolean; + data?: Array<{ + id: string; + projectId: string; + prompt: string; + taskName?: string; + agentId?: string; + }>; + error?: string; + }>; + onMcpTaskAvailable: (listener: () => void) => () => void; + mcpGetServerInfo: () => Promise< + { running: false } | { running: true; port: number; mcpUrl: string } + >; // Integrations integrationsStatusMap: () => Promise<{ @@ -2231,6 +2246,21 @@ export interface ElectronAPI { error?: string; }>; onAutomationTriggerAvailable: (listener: () => void) => () => void; + mcpDrainTaskQueue: () => Promise<{ + success: boolean; + data?: Array<{ + id: string; + projectId: string; + prompt: string; + taskName?: string; + agentId?: string; + }>; + error?: string; + }>; + onMcpTaskAvailable: (listener: () => void) => () => void; + mcpGetServerInfo: () => Promise< + { running: false } | { running: true; port: number; mcpUrl: string } + >; // Integrations integrationsStatusMap: () => Promise<{ diff --git a/src/renderer/views/Workspace.tsx b/src/renderer/views/Workspace.tsx index e8ba67908..f560c43f8 100644 --- a/src/renderer/views/Workspace.tsx +++ b/src/renderer/views/Workspace.tsx @@ -34,6 +34,7 @@ import { useProjectManagementContext } from '@/contexts/ProjectManagementProvide import { useTheme } from '@/hooks/useTheme'; import useUpdateNotifier from '@/hooks/useUpdateNotifier'; import { useAutomationTrigger } from '@/hooks/useAutomationTrigger'; +import { useMcpTaskTrigger } from '@/hooks/useMcpTaskTrigger'; import { activityStore } from '@/lib/activityStore'; import { agentStatusStore } from '@/lib/agentStatusStore'; import { handleMenuUndo, handleMenuRedo } from '@/lib/menuUndoRedo'; @@ -276,6 +277,9 @@ export function Workspace() { // Listen for automation triggers from the main process (scheduled + manual) useAutomationTrigger(automationsEnabled); + // Listen for MCP task requests from the local MCP server + useMcpTaskTrigger(); + // --- Convenience aliases and SSH-derived remote connection info --- const { selectedProject } = projectMgmt; const { activeTask, isCreatingTask } = taskMgmt; diff --git a/src/shared/mcp/catalog.ts b/src/shared/mcp/catalog.ts index 97a92f5b2..8af98358e 100644 --- a/src/shared/mcp/catalog.ts +++ b/src/shared/mcp/catalog.ts @@ -14,6 +14,16 @@ export interface CatalogEntryDef { } export const catalogData: Record = { + emdash: { + config: { + type: 'http', + url: 'http://127.0.0.1:17823/mcp', + }, + name: 'Emdash', + description: 'Create and manage tasks in the local Emdash desktop app', + docsUrl: 'https://emdash.ai/docs/mcp', + credentialKeys: [], + }, playwright: { config: { command: 'npx', diff --git a/src/test/main/McpTaskServer.test.ts b/src/test/main/McpTaskServer.test.ts new file mode 100644 index 000000000..9997e8e46 --- /dev/null +++ b/src/test/main/McpTaskServer.test.ts @@ -0,0 +1,442 @@ +import http from 'http'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — must come before the module under test is imported +// --------------------------------------------------------------------------- + +const getProjectsMock = vi.fn(); +const getTasksMock = vi.fn(); + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/emdash-mcp-test') }, + BrowserWindow: { + getFocusedWindow: vi.fn(() => null), + getAllWindows: vi.fn(() => []), + }, +})); + +vi.mock('../../main/services/DatabaseService', () => ({ + databaseService: { + getProjects: (...args: any[]) => getProjectsMock(...args), + getTasks: (...args: any[]) => getTasksMock(...args), + }, +})); + +vi.mock('../../main/lib/logger', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('fs', () => ({ + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + existsSync: vi.fn(() => true), +})); + +import { mcpTaskServer } from '../../main/services/McpTaskServer'; + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +function httpRequest( + port: number, + options: http.RequestOptions, + body?: string +): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: '127.0.0.1', port, ...options }, (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on('end', () => + resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, body: data }) + ); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +function mcpPost(port: number, rpc: unknown) { + const body = JSON.stringify(rpc); + return httpRequest( + port, + { + path: '/mcp', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + }, + body + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('McpTaskServer — queue helpers (no server needed)', () => { + beforeEach(() => { + mcpTaskServer.drainQueue(); + }); + + it('hasPendingTasks returns false on empty queue', () => { + expect(mcpTaskServer.hasPendingTasks()).toBe(false); + }); + + it('drainQueue returns empty array when queue is empty', () => { + expect(mcpTaskServer.drainQueue()).toEqual([]); + }); +}); + +describe('McpTaskServer — HTTP server', () => { + let port: number; + + // Start once for the whole group to avoid repeated stop/start timing issues. + beforeAll(async () => { + await mcpTaskServer.start(); + port = mcpTaskServer.getPort(); + }); + + afterAll(() => { + mcpTaskServer.stop(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mcpTaskServer.drainQueue(); + }); + + // ------------------------------------------------------------------------- + // MCP transport + // ------------------------------------------------------------------------- + + describe('GET /mcp', () => { + it('returns 405 with Allow: POST header', async () => { + const res = await httpRequest(port, { path: '/mcp', method: 'GET' }); + expect(res.statusCode).toBe(405); + expect(res.headers['allow']).toBe('POST'); + }); + }); + + describe('POST /mcp — protocol', () => { + it('returns 400 on invalid JSON', async () => { + const body = 'not-json{{{'; + const res = await httpRequest( + port, + { + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, + body + ); + expect(res.statusCode).toBe(400); + const parsed = JSON.parse(res.body); + expect(parsed.error.code).toBe(-32700); + }); + + it('acknowledges notifications (no id) with 202 and empty body', async () => { + const res = await mcpPost(port, { jsonrpc: '2.0', method: 'notifications/initialized' }); + expect(res.statusCode).toBe(202); + expect(res.body).toBe(''); + }); + + it('returns -32601 for unknown methods', async () => { + const res = await mcpPost(port, { jsonrpc: '2.0', id: 1, method: 'unknown/method' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).error.code).toBe(-32601); + }); + }); + + describe('POST /mcp — initialize', () => { + it('negotiates the requested protocol version when supported', async () => { + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test', version: '1' }, + }, + }); + const body = JSON.parse(res.body); + expect(body.result.protocolVersion).toBe('2025-03-26'); + expect(body.result.serverInfo.name).toBe('emdash'); + }); + + it('falls back to preferred version for unsupported client version', async () => { + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '1999-01-01', + capabilities: {}, + clientInfo: { name: 'test', version: '1' }, + }, + }); + expect(JSON.parse(res.body).result.protocolVersion).toBe('2025-03-26'); + }); + }); + + describe('POST /mcp — tools/list', () => { + it('returns the three MCP tools', async () => { + const res = await mcpPost(port, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const names = JSON.parse(res.body).result.tools.map((t: { name: string }) => t.name); + expect(names).toEqual(expect.arrayContaining(['list_projects', 'list_tasks', 'create_task'])); + expect(names).toHaveLength(3); + }); + }); + + describe('POST /mcp — tools/call: list_projects', () => { + it('returns a message when no projects exist', async () => { + getProjectsMock.mockResolvedValue([]); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_projects', arguments: {} }, + }); + expect(JSON.parse(res.body).result.content[0].text).toContain('No projects found'); + }); + + it('lists projects with id, name, and path', async () => { + getProjectsMock.mockResolvedValue([ + { id: 'p1', name: 'Alpha', path: '/code/alpha' }, + { id: 'p2', name: 'Beta', path: '/code/beta' }, + ]); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_projects', arguments: {} }, + }); + const text = JSON.parse(res.body).result.content[0].text; + expect(text).toContain('Alpha'); + expect(text).toContain('p1'); + expect(text).toContain('/code/alpha'); + }); + + it('marks remote projects with [remote]', async () => { + getProjectsMock.mockResolvedValue([{ id: 'p1', name: 'Remote', path: '/r', isRemote: true }]); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_projects', arguments: {} }, + }); + expect(JSON.parse(res.body).result.content[0].text).toContain('[remote]'); + }); + + it('returns isError on database failure', async () => { + getProjectsMock.mockRejectedValue(new Error('db down')); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_projects', arguments: {} }, + }); + expect(JSON.parse(res.body).result.isError).toBe(true); + }); + }); + + describe('POST /mcp — tools/call: list_tasks', () => { + it('returns isError when project_id is missing', async () => { + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_tasks', arguments: {} }, + }); + expect(JSON.parse(res.body).result.isError).toBe(true); + }); + + it('returns a message when no tasks exist', async () => { + getTasksMock.mockResolvedValue([]); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_tasks', arguments: { project_id: 'p1' } }, + }); + expect(JSON.parse(res.body).result.content[0].text).toContain('No active tasks'); + }); + + it('lists tasks with id, name, status, agent, and branch', async () => { + getTasksMock.mockResolvedValue([ + { + id: 't1', + name: 'Fix bug', + status: 'running', + agentId: 'claude', + branch: 'emdash/fix-bug', + }, + ]); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_tasks', arguments: { project_id: 'p1' } }, + }); + const text = JSON.parse(res.body).result.content[0].text; + expect(text).toContain('Fix bug'); + expect(text).toContain('running'); + expect(text).toContain('claude'); + }); + }); + + describe('POST /mcp — tools/call: create_task', () => { + it('returns isError when project_id is missing', async () => { + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'create_task', arguments: { prompt: 'do stuff' } }, + }); + expect(JSON.parse(res.body).result.isError).toBe(true); + }); + + it('returns isError when prompt is missing', async () => { + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'create_task', arguments: { project_id: 'p1' } }, + }); + expect(JSON.parse(res.body).result.isError).toBe(true); + }); + + it('returns isError when project does not exist', async () => { + getProjectsMock.mockResolvedValue([{ id: 'other', name: 'Other', path: '/' }]); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'create_task', arguments: { project_id: 'missing', prompt: 'do stuff' } }, + }); + expect(JSON.parse(res.body).result.isError).toBe(true); + }); + + it('queues a task and returns its id', async () => { + getProjectsMock.mockResolvedValue([{ id: 'p1', name: 'Alpha', path: '/' }]); + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'create_task', + arguments: { + project_id: 'p1', + prompt: 'fix the bug', + task_name: 'Bug fix', + agent_id: 'claude', + }, + }, + }); + const text = JSON.parse(res.body).result.content[0].text; + expect(text).toContain('queued'); + + const queued = mcpTaskServer.drainQueue(); + expect(queued).toHaveLength(1); + expect(queued[0]).toMatchObject({ + projectId: 'p1', + prompt: 'fix the bug', + taskName: 'Bug fix', + agentId: 'claude', + }); + expect(queued[0].id).toBeTruthy(); + }); + + it('drainQueue empties the queue after two tasks', async () => { + getProjectsMock.mockResolvedValue([{ id: 'p1', name: 'Alpha', path: '/' }]); + await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'create_task', arguments: { project_id: 'p1', prompt: 'task 1' } }, + }); + await mcpPost(port, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'create_task', arguments: { project_id: 'p1', prompt: 'task 2' } }, + }); + expect(mcpTaskServer.hasPendingTasks()).toBe(true); + expect(mcpTaskServer.drainQueue()).toHaveLength(2); + expect(mcpTaskServer.hasPendingTasks()).toBe(false); + }); + }); + + describe('POST /mcp — unknown tool', () => { + it('returns isError for an unknown tool name', async () => { + const res = await mcpPost(port, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'nonexistent_tool', arguments: {} }, + }); + const result = JSON.parse(res.body).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown tool'); + }); + }); + + // ------------------------------------------------------------------------- + // Legacy REST API + // ------------------------------------------------------------------------- + + describe('REST API — /api/projects', () => { + it('returns 403 without token', async () => { + const res = await httpRequest(port, { path: '/api/projects', method: 'GET' }); + expect(res.statusCode).toBe(403); + }); + + it('returns 403 with wrong token', async () => { + const res = await httpRequest(port, { + path: '/api/projects', + method: 'GET', + headers: { 'x-emdash-token': 'wrong-token' }, + }); + expect(res.statusCode).toBe(403); + }); + }); + + describe('REST API — unknown routes', () => { + it('auth is checked before routing — unknown path without token returns 403', async () => { + const res = await httpRequest(port, { path: '/api/unknown', method: 'GET' }); + expect(res.statusCode).toBe(403); + }); + }); + + // ------------------------------------------------------------------------- + // Server lifecycle + // ------------------------------------------------------------------------- + + describe('start/stop', () => { + it('getPort returns a non-zero port while running', () => { + expect(mcpTaskServer.getPort()).toBeGreaterThan(0); + }); + + it('start() is idempotent — second call does not restart the server', async () => { + const portBefore = mcpTaskServer.getPort(); + await mcpTaskServer.start(); + expect(mcpTaskServer.getPort()).toBe(portBefore); + }); + + it('getPort returns 0 after stop, and server can be restarted', async () => { + mcpTaskServer.stop(); + expect(mcpTaskServer.getPort()).toBe(0); + // Restart so afterAll and any remaining tests still have a running server. + await mcpTaskServer.start(); + port = mcpTaskServer.getPort(); + expect(port).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/test/main/settings.test.ts b/src/test/main/settings.test.ts index 5456eeab2..041f2a656 100644 --- a/src/test/main/settings.test.ts +++ b/src/test/main/settings.test.ts @@ -283,3 +283,45 @@ describe('normalizeSettings – terminal settings', () => { expect(result.terminal?.macOptionIsMeta).toBe(true); }); }); + +describe('normalizeSettings - mcp settings', () => { + it('defaults mcp.enabled to false when omitted', () => { + const result = normalizeSettings(makeSettings()); + expect(result.mcp?.enabled).toBe(false); + }); + + it('preserves mcp.enabled when set to true', () => { + const result = normalizeSettings(makeSettings({ mcp: { enabled: true } })); + expect(result.mcp?.enabled).toBe(true); + }); + + it('coerces a truthy value to true', () => { + const result = normalizeSettings(makeSettings({ mcp: { enabled: 1 as any } })); + expect(result.mcp?.enabled).toBe(true); + }); + + it('accepts a valid port', () => { + const result = normalizeSettings(makeSettings({ mcp: { enabled: false, port: 18000 } })); + expect(result.mcp?.port).toBe(18000); + }); + + it('rejects a port below 1024', () => { + const result = normalizeSettings(makeSettings({ mcp: { enabled: false, port: 80 } })); + expect(result.mcp?.port).toBeUndefined(); + }); + + it('rejects a port above 65535', () => { + const result = normalizeSettings(makeSettings({ mcp: { enabled: false, port: 99999 } })); + expect(result.mcp?.port).toBeUndefined(); + }); + + it('rejects a non-integer port', () => { + const result = normalizeSettings(makeSettings({ mcp: { enabled: false, port: 17823.5 } })); + expect(result.mcp?.port).toBeUndefined(); + }); + + it('omits port when not provided', () => { + const result = normalizeSettings(makeSettings({ mcp: { enabled: false } })); + expect(result.mcp?.port).toBeUndefined(); + }); +}); diff --git a/src/test/main/settingsIpc.test.ts b/src/test/main/settingsIpc.test.ts new file mode 100644 index 000000000..d8d02b602 --- /dev/null +++ b/src/test/main/settingsIpc.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Hoisted mocks — these are referenced inside vi.mock factories which are +// hoisted before variable declarations, so they must use vi.hoisted(). +// --------------------------------------------------------------------------- + +const { startMock, stopMock } = vi.hoisted(() => ({ + startMock: vi.fn().mockResolvedValue(undefined), + stopMock: vi.fn(), +})); + +vi.mock('../../main/services/McpTaskServer', () => ({ + mcpTaskServer: { + start: (...args: any[]) => startMock(...args), + stop: stopMock, + getPort: vi.fn(() => 17823), + }, +})); + +vi.mock('../../main/lib/logger', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// Settings state driven by these mocks. +let currentSettings: { mcp?: { enabled: boolean; port?: number } } = {}; + +const { updateAppSettingsMock } = vi.hoisted(() => ({ + updateAppSettingsMock: vi.fn((partial: any) => { + if (partial?.mcp) { + currentSettings = { ...currentSettings, mcp: { ...currentSettings.mcp, ...partial.mcp } }; + } + return currentSettings; + }), +})); + +vi.mock('../../main/settings', () => ({ + getAppSettings: () => currentSettings, + updateAppSettings: (partial: any) => updateAppSettingsMock(partial), +})); + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/emdash-test') }, +})); + +import { appSettingsController } from '../../main/ipc/settingsIpc'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('settingsIpc — MCP server lifecycle on settings update', () => { + beforeEach(() => { + vi.clearAllMocks(); + currentSettings = {}; + startMock.mockResolvedValue(undefined); + }); + + it('starts the server when enabled transitions false → true', async () => { + currentSettings = { mcp: { enabled: false } }; + updateAppSettingsMock.mockImplementationOnce(() => { + currentSettings = { mcp: { enabled: true } }; + return currentSettings; + }); + + await appSettingsController.update({ mcp: { enabled: true } } as any); + + expect(startMock).toHaveBeenCalledTimes(1); + expect(stopMock).not.toHaveBeenCalled(); + }); + + it('passes the configured port to start()', async () => { + currentSettings = { mcp: { enabled: false } }; + updateAppSettingsMock.mockImplementationOnce(() => { + currentSettings = { mcp: { enabled: true, port: 19000 } }; + return currentSettings; + }); + + await appSettingsController.update({ mcp: { enabled: true, port: 19000 } } as any); + + expect(startMock).toHaveBeenCalledWith(19000); + }); + + it('stops the server when enabled transitions true → false', async () => { + currentSettings = { mcp: { enabled: true } }; + updateAppSettingsMock.mockImplementationOnce(() => { + currentSettings = { mcp: { enabled: false } }; + return currentSettings; + }); + + await appSettingsController.update({ mcp: { enabled: false } } as any); + + expect(stopMock).toHaveBeenCalledTimes(1); + expect(startMock).not.toHaveBeenCalled(); + }); + + it('restarts the server when the port changes while enabled', async () => { + currentSettings = { mcp: { enabled: true, port: 17823 } }; + updateAppSettingsMock.mockImplementationOnce(() => { + currentSettings = { mcp: { enabled: true, port: 18000 } }; + return currentSettings; + }); + + await appSettingsController.update({ mcp: { port: 18000 } } as any); + + expect(stopMock).toHaveBeenCalledTimes(1); + expect(startMock).toHaveBeenCalledTimes(1); + expect(startMock).toHaveBeenCalledWith(18000); + }); + + it('does not touch the server when an unrelated setting changes', async () => { + currentSettings = { mcp: { enabled: false } }; + updateAppSettingsMock.mockImplementationOnce(() => currentSettings); + + await appSettingsController.update({ tasks: { autoGenerateName: false } } as any); + + expect(startMock).not.toHaveBeenCalled(); + expect(stopMock).not.toHaveBeenCalled(); + }); + + it('logs a warning and does not throw when start() rejects', async () => { + currentSettings = { mcp: { enabled: false } }; + updateAppSettingsMock.mockImplementationOnce(() => { + currentSettings = { mcp: { enabled: true } }; + return currentSettings; + }); + startMock.mockRejectedValueOnce(new Error('port in use')); + + await expect( + appSettingsController.update({ mcp: { enabled: true } } as any) + ).resolves.not.toThrow(); + }); +});