From 0ddd39d307582dd2e425f0f4d8891ab00e0fa009 Mon Sep 17 00:00:00 2001 From: "Alec M. Wantoch" Date: Mon, 31 Aug 2026 21:05:43 -0700 Subject: [PATCH] Upgrade MCP server SDK to v2 --- e2e/mcp.spec.ts | 73 ++ package-lock.json | 997 +------------------- package.json | 6 +- src/app/api/mcp/route.test.ts | 39 +- src/app/api/mcp/route.ts | 10 +- src/domain/process-flow/schemas.ts | 47 +- src/domain/story-map/schemas.ts | 72 +- src/integrations/mcp/auth.test.ts | 83 +- src/integrations/mcp/auth.ts | 115 ++- src/integrations/mcp/metadata.ts | 28 +- src/integrations/mcp/origin.test.ts | 4 + src/integrations/mcp/origin.ts | 4 +- src/integrations/mcp/server.test.ts | 59 +- src/integrations/mcp/server.ts | 35 +- src/integrations/mcp/tool-support.ts | 16 - src/integrations/mcp/tools/personas.ts | 30 +- src/integrations/mcp/tools/planning.ts | 68 +- src/integrations/mcp/tools/process-flows.ts | 95 +- src/integrations/mcp/tools/stories.ts | 35 +- src/integrations/mcp/tools/story-maps.ts | 43 +- 20 files changed, 531 insertions(+), 1328 deletions(-) create mode 100644 e2e/mcp.spec.ts diff --git a/e2e/mcp.spec.ts b/e2e/mcp.spec.ts new file mode 100644 index 0000000..569a18b --- /dev/null +++ b/e2e/mcp.spec.ts @@ -0,0 +1,73 @@ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { expect, test } from '@playwright/test'; +import { resetE2EState } from './helpers'; +import { createPublicClient, E2E_OWNER_EMAIL, E2E_OWNER_PASSWORD, E2E_TEAM_ID } from './local-fixtures'; + +test.beforeEach(async () => { + await resetE2EState(); +}); + +test('serves authenticated MCP tools over the v2 HTTP transport', async ({ baseURL, request }) => { + if (!baseURL) throw new Error('Playwright baseURL is required for the MCP end-to-end test'); + + const unauthorizedResponse = await request.post('/api/mcp', { + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + data: { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'beemspec-e2e-unauthenticated', version: '1.0.0' }, + }, + }, + }); + + expect(unauthorizedResponse.status()).toBe(401); + expect(unauthorizedResponse.headers()['www-authenticate']).toContain('Bearer'); + + const supabase = createPublicClient(); + const { data, error } = await supabase.auth.signInWithPassword({ + email: E2E_OWNER_EMAIL, + password: E2E_OWNER_PASSWORD, + }); + + expect(error).toBeNull(); + expect(data.session?.access_token).toBeTruthy(); + + const client = new Client({ name: 'beemspec-e2e', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(new URL('/api/mcp', baseURL), { + authProvider: { token: async () => data.session?.access_token }, + }); + + try { + await client.connect(transport); + + expect(client.getServerVersion()).toMatchObject({ name: 'beemspec' }); + expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + + const { tools } = await client.listTools(); + expect(tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'team_list' })])); + + const teamList = await client.callTool({ name: 'team_list', arguments: {} }); + expect(teamList.isError).not.toBe(true); + expect(teamList.structuredContent).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + { + team_id: E2E_TEAM_ID, + name: 'E2E Team', + role: 'owner', + }, + ]), + }); + } finally { + await client.close(); + const { error: signOutError } = await supabase.auth.signOut(); + expect(signOutError).toBeNull(); + } +}); diff --git a/package-lock.json b/package-lock.json index 72ce1ab..25f8934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@linear/sdk": "^91.0.0", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.0.0", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", @@ -40,6 +40,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.9", + "@modelcontextprotocol/client": "^2.0.0", "@next/env": "^16.3.1", "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.3.3", @@ -55,6 +56,9 @@ "tw-animate-css": "^1.4.0", "typescript": "^6.0.3", "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@alloc/quick-lru": { @@ -685,18 +689,6 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@hono/node-server": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1260,65 +1252,49 @@ "node": ">=18.x" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod": "^4.2.0" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "node": ">=20" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "zod": "^4.2.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=20" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.3", @@ -3495,54 +3471,6 @@ "d3-zoom": "^3.0.0" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3614,78 +3542,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001809", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", @@ -3754,26 +3610,6 @@ "node": ">=6" } }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3793,34 +3629,11 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4019,14 +3832,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4070,19 +3875,6 @@ "license": "MIT", "peer": true }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eciesjs": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.5.0.tgz", @@ -4101,25 +3893,12 @@ "node": ">=16" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, "node_modules/elkjs": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz", "integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==", "license": "EPL-2.0 OR GPL-3.0-or-later" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -4147,22 +3926,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -4170,22 +3933,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -4196,18 +3943,11 @@ "@types/estree": "^1.0.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, "dependencies": { "eventsource-parser": "^3.0.1" }, @@ -4219,6 +3959,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, "engines": { "node": ">=18.0.0" } @@ -4233,137 +3974,11 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.6.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", - "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -4379,37 +3994,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4418,29 +4002,6 @@ "node": ">=6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4466,38 +4027,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", - "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -4518,25 +4047,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -4546,43 +4056,6 @@ "node": ">=20.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -4601,15 +4074,11 @@ "dev": true, "license": "MIT" }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -4664,6 +4133,7 @@ "version": "6.2.9", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -4727,11 +4197,6 @@ "node": "20 || >=22" } }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==" - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5064,14 +4529,6 @@ "node": ">=10" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -5114,25 +4571,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -5554,29 +4992,6 @@ } ] }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5600,14 +5015,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/next": { "version": "16.3.1", "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz", @@ -5689,25 +5096,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -5722,25 +5110,6 @@ "node": ">=12.20.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -5754,32 +5123,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5796,6 +5148,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, "engines": { "node": ">=16.20.0" } @@ -5910,18 +5263,6 @@ "license": "MIT", "peer": true }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5932,44 +5273,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -6076,6 +5379,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6113,26 +5417,6 @@ "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -6151,60 +5435,12 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "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" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/server-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, "node_modules/sharp": { "version": "0.35.3", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", @@ -6272,6 +5508,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6283,80 +5520,11 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -6380,14 +5548,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -6585,14 +5745,6 @@ "dev": true, "license": "MIT" }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", @@ -6642,37 +5794,6 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -6734,14 +5855,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -6791,14 +5904,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -7347,6 +6452,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7374,11 +6480,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -7405,14 +6506,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/package.json b/package.json index f336600..94a16e1 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "beemspec", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.9.0" + }, "scripts": { "dev": "next dev", "build": "next build", @@ -27,7 +30,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@linear/sdk": "^91.0.0", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.0.0", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", @@ -56,6 +59,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.9", + "@modelcontextprotocol/client": "^2.0.0", "@next/env": "^16.3.1", "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.3.3", diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index dea25e2..ae1d899 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -1,49 +1,47 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { authenticateMcpRequest } from '@/integrations/mcp/auth'; -import { handleMcpRequest } from '@/integrations/mcp/server'; +import { mcpHandler } from '@/integrations/mcp/server'; import { GET, OPTIONS, POST } from './route'; -const mockSupabase = {} as never; +const authInfo = { + token: 'user-token', + clientId: 'user-1', + scopes: [], + expiresAt: Math.floor(Date.now() / 1000) + 3600, +}; vi.mock('@/integrations/mcp/auth', () => ({ authenticateMcpRequest: vi.fn(), })); vi.mock('@/integrations/mcp/server', () => ({ - handleMcpRequest: vi.fn(), + mcpHandler: { fetch: vi.fn() }, })); describe('mcp route', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(authenticateMcpRequest).mockResolvedValue({ - ok: true, - user: { id: 'user-1', email: 'user@example.com' }, - supabase: mockSupabase, - }); - vi.mocked(handleMcpRequest).mockResolvedValue(new Response('{}', { status: 200 })); + vi.mocked(authenticateMcpRequest).mockResolvedValue(authInfo); + vi.mocked(mcpHandler.fetch).mockResolvedValue(new Response('{}', { status: 200 })); }); it('allows requests when bearer auth succeeds', async () => { const response = await GET(new Request('http://localhost/api/mcp', { method: 'GET' })); - expect(handleMcpRequest).toHaveBeenCalled(); + expect(mcpHandler.fetch).toHaveBeenCalled(); expect(response.status).toBe(200); }); it('returns 401 when auth fails', async () => { - vi.mocked(authenticateMcpRequest).mockResolvedValue({ - ok: false, - response: new Response('{"error":"Unauthorized"}', { status: 401 }), - }); + vi.mocked(authenticateMcpRequest).mockResolvedValue(new Response('{"error":"invalid_token"}', { status: 401 })); const response = await POST(new Request('http://localhost/api/mcp', { method: 'POST' })); - expect(handleMcpRequest).not.toHaveBeenCalled(); + expect(mcpHandler.fetch).not.toHaveBeenCalled(); expect(response.status).toBe(401); }); - it('passes authenticated supabase client into MCP handler', async () => { + it('passes authenticated info into the MCP handler', async () => { const response = await POST( new Request('http://localhost/api/mcp', { method: 'POST', @@ -52,10 +50,7 @@ describe('mcp route', () => { ); expect(authenticateMcpRequest).toHaveBeenCalled(); - expect(handleMcpRequest).toHaveBeenCalledWith(expect.any(Request), mockSupabase, { - id: 'user-1', - email: 'user@example.com', - }); + expect(mcpHandler.fetch).toHaveBeenCalledWith(expect.any(Request), { authInfo }); expect(response.status).toBe(200); }); @@ -71,7 +66,7 @@ describe('mcp route', () => { ); expect(authenticateMcpRequest).toHaveBeenCalled(); - expect(handleMcpRequest).toHaveBeenCalled(); + expect(mcpHandler.fetch).toHaveBeenCalled(); expect(response.status).toBe(200); expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://claude.ai'); expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST'); @@ -104,7 +99,7 @@ describe('mcp route', () => { ); expect(authenticateMcpRequest).not.toHaveBeenCalled(); - expect(handleMcpRequest).not.toHaveBeenCalled(); + expect(mcpHandler.fetch).not.toHaveBeenCalled(); expect(response.status).toBe(403); }); }); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index e8488a9..3314c61 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,6 +1,6 @@ import { authenticateMcpRequest } from '@/integrations/mcp/auth'; import { applyMcpCorsHeaders, isTrustedRequestOrigin } from '@/integrations/mcp/origin'; -import { handleMcpRequest } from '@/integrations/mcp/server'; +import { mcpHandler } from '@/integrations/mcp/server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -10,12 +10,12 @@ async function handleMcpHttpRequest(request: Request): Promise { return applyMcpCorsHeaders(Response.json({ error: 'Forbidden origin' }, { status: 403 }), request); } - const auth = await authenticateMcpRequest(request); - if (!auth.ok) { - return applyMcpCorsHeaders(auth.response, request); + const authInfo = await authenticateMcpRequest(request); + if (authInfo instanceof Response) { + return applyMcpCorsHeaders(authInfo, request); } - return applyMcpCorsHeaders(await handleMcpRequest(request, auth.supabase, auth.user), request); + return applyMcpCorsHeaders(await mcpHandler.fetch(request, { authInfo }), request); } export async function GET(request: Request) { diff --git a/src/domain/process-flow/schemas.ts b/src/domain/process-flow/schemas.ts index fff7f07..427136c 100644 --- a/src/domain/process-flow/schemas.ts +++ b/src/domain/process-flow/schemas.ts @@ -60,11 +60,13 @@ export const createProcessFlowSchema = processFlowBase.partial({ viewport: true, }); -export const updateProcessFlowSchema = processFlowBase - .omit({ team_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updateProcessFlowFieldsSchema = processFlowBase.omit({ team_id: true }).partial().strict(); + +export const updateProcessFlowSchema = updateProcessFlowFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); + +export const updateProcessFlowToolSchema = updateProcessFlowFieldsSchema + .extend({ process_flow_id: uuid }) + .refine(({ process_flow_id: _processFlowId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); export const processFlowNodeBase = z .object({ @@ -85,11 +87,19 @@ export const processFlowNodeBase = z export const createProcessFlowNodeSchema = processFlowNodeBase; -export const updateProcessFlowNodeSchema = processFlowNodeBase - .omit({ process_flow_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updateProcessFlowNodeFieldsSchema = processFlowNodeBase.omit({ process_flow_id: true }).partial().strict(); + +export const updateProcessFlowNodeSchema = updateProcessFlowNodeFieldsSchema.refine( + atLeastOneField, + atLeastOneFieldMessage, +); + +export const updateProcessFlowNodeToolSchema = updateProcessFlowNodeFieldsSchema + .extend({ process_flow_id: uuid, node_id: uuid }) + .refine( + ({ process_flow_id: _processFlowId, node_id: _nodeId, ...changes }) => atLeastOneField(changes), + atLeastOneFieldMessage, + ); export const createProcessFlowNodeBodySchema = processFlowNodeBase.omit({ process_flow_id: true }); @@ -105,11 +115,22 @@ export const processFlowEdgeBase = z export const createProcessFlowEdgeSchema = processFlowEdgeBase; -export const updateProcessFlowEdgeSchema = processFlowEdgeBase +const updateProcessFlowEdgeFieldsSchema = processFlowEdgeBase .omit({ process_flow_id: true, source_node_id: true, target_node_id: true }) .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); + .strict(); + +export const updateProcessFlowEdgeSchema = updateProcessFlowEdgeFieldsSchema.refine( + atLeastOneField, + atLeastOneFieldMessage, +); + +export const updateProcessFlowEdgeToolSchema = updateProcessFlowEdgeFieldsSchema + .extend({ process_flow_id: uuid, edge_id: uuid }) + .refine( + ({ process_flow_id: _processFlowId, edge_id: _edgeId, ...changes }) => atLeastOneField(changes), + atLeastOneFieldMessage, + ); export const createProcessFlowEdgeBodySchema = processFlowEdgeBase.omit({ process_flow_id: true }); diff --git a/src/domain/story-map/schemas.ts b/src/domain/story-map/schemas.ts index 3e77964..7238cf0 100644 --- a/src/domain/story-map/schemas.ts +++ b/src/domain/story-map/schemas.ts @@ -48,11 +48,13 @@ export const storyMapBase = z export const createStoryMapSchema = storyMapBase.partial({ description: true, context_markdown: true }); -export const updateStoryMapSchema = storyMapBase - .omit({ team_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updateStoryMapFieldsSchema = storyMapBase.omit({ team_id: true }).partial().strict(); + +export const updateStoryMapSchema = updateStoryMapFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); + +export const updateStoryMapToolSchema = updateStoryMapFieldsSchema + .extend({ story_map_id: uuid }) + .refine(({ story_map_id: _storyMapId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); // --------------------------------------------------------------------------- // Release @@ -69,11 +71,13 @@ export const releaseBase = z export const createReleaseSchema = releaseBase.partial({ description: true, context_markdown: true }); -export const updateReleaseSchema = releaseBase - .omit({ story_map_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updateReleaseFieldsSchema = releaseBase.omit({ story_map_id: true }).partial().strict(); + +export const updateReleaseSchema = updateReleaseFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); + +export const updateReleaseToolSchema = updateReleaseFieldsSchema + .extend({ release_id: uuid }) + .refine(({ release_id: _releaseId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); export const reorderReleasesSchema = z .object({ @@ -96,11 +100,13 @@ export const activityBase = z export const createActivitySchema = activityBase.partial({ description: true }); -export const updateActivitySchema = activityBase - .omit({ story_map_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updateActivityFieldsSchema = activityBase.omit({ story_map_id: true }).partial().strict(); + +export const updateActivitySchema = updateActivityFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); + +export const updateActivityToolSchema = updateActivityFieldsSchema + .extend({ activity_id: uuid }) + .refine(({ activity_id: _activityId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); export const reorderActivitiesSchema = z .object({ @@ -123,11 +129,13 @@ export const taskBase = z export const createTaskSchema = taskBase.partial({ description: true }); -export const updateTaskSchema = taskBase - .omit({ activity_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updateTaskFieldsSchema = taskBase.omit({ activity_id: true }).partial().strict(); + +export const updateTaskSchema = updateTaskFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); + +export const updateTaskToolSchema = updateTaskFieldsSchema + .extend({ task_id: uuid }) + .refine(({ task_id: _taskId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); export const reorderTasksSchema = z .object({ @@ -164,11 +172,13 @@ export const createStorySchema = storyBase }) .extend({ status: storyStatus.optional().default('backlog') }); -export const updateStorySchema = storyBase - .omit({ task_id: true, release_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updateStoryFieldsSchema = storyBase.omit({ task_id: true, release_id: true }).partial().strict(); + +export const updateStorySchema = updateStoryFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); + +export const updateStoryToolSchema = updateStoryFieldsSchema + .extend({ story_id: uuid }) + .refine(({ story_id: _storyId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); export const reorderStoriesSchema = z .object({ @@ -204,11 +214,13 @@ export const createPersonaSchema = personaBase.partial({ goals: true, }); -export const updatePersonaSchema = personaBase - .omit({ story_map_id: true }) - .partial() - .strict() - .refine(atLeastOneField, atLeastOneFieldMessage); +const updatePersonaFieldsSchema = personaBase.omit({ story_map_id: true }).partial().strict(); + +export const updatePersonaSchema = updatePersonaFieldsSchema.refine(atLeastOneField, atLeastOneFieldMessage); + +export const updatePersonaToolSchema = updatePersonaFieldsSchema + .extend({ persona_id: uuid }) + .refine(({ persona_id: _personaId, ...changes }) => atLeastOneField(changes), atLeastOneFieldMessage); // --------------------------------------------------------------------------- // Inferred types diff --git a/src/integrations/mcp/auth.test.ts b/src/integrations/mcp/auth.test.ts index b83ace2..d3dec2a 100644 --- a/src/integrations/mcp/auth.test.ts +++ b/src/integrations/mcp/auth.test.ts @@ -8,21 +8,22 @@ vi.mock('@/lib/supabase/token', () => ({ createClientForAccessToken: createClientForAccessTokenMock, })); -import { authenticateMcpRequest } from './auth'; +import { authenticateMcpRequest, getMcpAuthContext } from './auth'; describe('mcp auth', () => { - it('returns 401 with metadata header when bearer token is missing', async () => { + it('returns an OAuth challenge with metadata when the bearer token is missing', async () => { const result = await authenticateMcpRequest(new Request('http://localhost/api/mcp', { method: 'POST' })); - expect(result.ok).toBe(false); - if (result.ok) throw new Error('Expected auth failure'); + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error('Expected auth failure'); - expect(result.response.status).toBe(401); - expect(result.response.headers.get('WWW-Authenticate')).toContain('resource_metadata='); - expect(result.response.headers.get('WWW-Authenticate')).toContain('/.well-known/oauth-protected-resource/api/mcp'); + expect(result.status).toBe(401); + expect(result.headers.get('WWW-Authenticate')).toContain('resource_metadata='); + expect(result.headers.get('WWW-Authenticate')).toContain('/.well-known/oauth-protected-resource/api/mcp'); + await expect(result.json()).resolves.toMatchObject({ error: 'invalid_token' }); }); - it('returns 401 when Supabase rejects token', async () => { + it('returns an invalid-token challenge when Supabase rejects the token', async () => { createClientForAccessTokenMock.mockReturnValue({ auth: { getClaims: vi.fn().mockResolvedValue({ @@ -39,28 +40,55 @@ describe('mcp auth', () => { }), ); - expect(result.ok).toBe(false); - if (result.ok) throw new Error('Expected auth failure'); + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error('Expected auth failure'); - expect(result.response.status).toBe(401); - expect(result.response.headers.get('WWW-Authenticate')).toContain('invalid_token'); + expect(result.status).toBe(401); + expect(result.headers.get('WWW-Authenticate')).toContain('invalid_token'); }); - it('returns user and supabase client for valid token', async () => { + it('rejects an expired Supabase token', async () => { + createClientForAccessTokenMock.mockReturnValue({ + auth: { + getClaims: vi.fn().mockResolvedValue({ + data: { + claims: { + sub: 'd7f34189-5d27-4dc0-b2c5-23d11796add4', + exp: Math.floor(Date.now() / 1000) - 1, + }, + }, + error: null, + }), + }, + }); + + const result = await authenticateMcpRequest( + new Request('http://localhost/api/mcp', { + method: 'POST', + headers: { authorization: 'Bearer expired-token' }, + }), + ); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error('Expected auth failure'); + expect(result.status).toBe(401); + await expect(result.json()).resolves.toMatchObject({ error: 'invalid_token' }); + }); + + it('returns SDK auth info with the scoped BeemSpec context for a valid token', async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 3600; const getClaims = vi.fn().mockResolvedValue({ data: { claims: { sub: 'd7f34189-5d27-4dc0-b2c5-23d11796add4', email: 'owner@example.com', + client_id: 'mcp-client', + exp: expiresAt, }, }, error: null, }); - const supabase = { - auth: { - getClaims, - }, - }; + const supabase = { auth: { getClaims } }; createClientForAccessTokenMock.mockReturnValue(supabase); const result = await authenticateMcpRequest( @@ -70,11 +98,22 @@ describe('mcp auth', () => { }), ); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error('Expected auth success'); + expect(result).not.toBeInstanceOf(Response); + if (result instanceof Response) throw new Error('Expected auth success'); - expect(result.user.id).toBe('d7f34189-5d27-4dc0-b2c5-23d11796add4'); - expect(result.supabase).toBe(supabase); + expect(result).toMatchObject({ + token: 'valid-token', + clientId: 'mcp-client', + scopes: [], + expiresAt, + }); + expect(getMcpAuthContext(result)).toEqual({ + user: { + id: 'd7f34189-5d27-4dc0-b2c5-23d11796add4', + email: 'owner@example.com', + }, + supabase, + }); expect(getClaims).toHaveBeenCalledWith('valid-token'); }); }); diff --git a/src/integrations/mcp/auth.ts b/src/integrations/mcp/auth.ts index d5cc8d4..64c05e5 100644 --- a/src/integrations/mcp/auth.ts +++ b/src/integrations/mcp/auth.ts @@ -1,64 +1,93 @@ -import { NextResponse } from 'next/server'; +import { + type AuthInfo, + OAuthError, + OAuthErrorCode, + type OAuthTokenVerifier, + requireBearerAuth, +} from '@modelcontextprotocol/server'; import { buildProtectedResourceMetadataUrl } from '@/integrations/mcp/metadata'; import { resolveRequestOrigin } from '@/integrations/mcp/origin'; -import { type AuthenticatedUser, getAuthenticatedUser } from '@/lib/auth'; +import type { AuthenticatedUser } from '@/lib/auth'; import { createClientForAccessToken } from '@/lib/supabase/token'; import type { Supabase } from '@/lib/supabase/types'; -function buildWwwAuthenticateHeader(request: Request, error: 'invalid_request' | 'invalid_token'): string { - const origin = resolveRequestOrigin(request); - const pathname = new URL(request.url).pathname; - const resourceMetadata = buildProtectedResourceMetadataUrl(origin, pathname); - return ['Bearer realm="beemspec-mcp"', `error="${error}"`, `resource_metadata="${resourceMetadata}"`].join(', '); +export interface McpAuthContext { + user: AuthenticatedUser; + supabase: Supabase; } -function unauthorizedResponse(request: Request, error: 'invalid_request' | 'invalid_token') { - const response = NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - response.headers.set('WWW-Authenticate', buildWwwAuthenticateHeader(request, error)); - return response; -} +const AUTH_CONTEXT_KEY = 'beemspec'; -function parseBearerToken(request: Request): string | null { - const authHeader = request.headers.get('authorization') ?? ''; - if (!authHeader.startsWith('Bearer ')) return null; - - const token = authHeader.slice('Bearer '.length).trim(); - return token.length > 0 ? token : null; +function invalidToken(message: string): never { + throw new OAuthError(OAuthErrorCode.InvalidToken, message); } -export type McpAuthResult = - | { - ok: true; - user: AuthenticatedUser; - supabase: Supabase; +const supabaseTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token) { + const supabase = createClientForAccessToken(token); + const { data, error } = await supabase.auth.getClaims(token); + const claims = data?.claims; + + if (error || typeof claims?.sub !== 'string' || !claims.sub || typeof claims.exp !== 'number') { + invalidToken('The access token is invalid or missing required claims'); } - | { - ok: false; - response: Response; - }; -export async function authenticateMcpRequest(request: Request): Promise { - const accessToken = parseBearerToken(request); - if (!accessToken) { - return { - ok: false, - response: unauthorizedResponse(request, 'invalid_request'), + const user: AuthenticatedUser = { + id: claims.sub, + email: typeof claims.email === 'string' ? claims.email : '', }; - } - - const supabase = createClientForAccessToken(accessToken); - const user = await getAuthenticatedUser(supabase, accessToken); - if (!user) { return { - ok: false, - response: unauthorizedResponse(request, 'invalid_token'), + token, + clientId: typeof claims.client_id === 'string' ? claims.client_id : claims.sub, + scopes: [], + expiresAt: claims.exp, + extra: { + [AUTH_CONTEXT_KEY]: { user, supabase } satisfies McpAuthContext, + }, }; + }, +}; + +export function getMcpAuthContext(authInfo: AuthInfo | undefined): McpAuthContext { + const context = authInfo?.extra?.[AUTH_CONTEXT_KEY]; + if ( + !context || + typeof context !== 'object' || + !('user' in context) || + !context.user || + typeof context.user !== 'object' || + !('id' in context.user) || + typeof context.user.id !== 'string' || + !('email' in context.user) || + typeof context.user.email !== 'string' || + !('supabase' in context) || + !context.supabase || + typeof context.supabase !== 'object' + ) { + throw new Error('Missing authenticated BeemSpec context'); } + return context as unknown as McpAuthContext; +} + +export function createMcpAuthInfo(token: string, context: McpAuthContext, expiresAt?: number): AuthInfo { return { - ok: true, - user, - supabase, + token, + clientId: context.user.id, + scopes: [], + expiresAt, + extra: { [AUTH_CONTEXT_KEY]: context }, }; } + +export async function authenticateMcpRequest(request: Request): Promise { + const origin = resolveRequestOrigin(request); + const pathname = new URL(request.url).pathname; + const authenticate = requireBearerAuth({ + verifier: supabaseTokenVerifier, + resourceMetadataUrl: buildProtectedResourceMetadataUrl(origin, pathname), + }); + + return authenticate(request); +} diff --git a/src/integrations/mcp/metadata.ts b/src/integrations/mcp/metadata.ts index 0232877..c8484b9 100644 --- a/src/integrations/mcp/metadata.ts +++ b/src/integrations/mcp/metadata.ts @@ -1,9 +1,8 @@ -import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js'; import { + getOAuthProtectedResourceMetadataUrl, type OAuthProtectedResourceMetadata, - OAuthProtectedResourceMetadataSchema, -} from '@modelcontextprotocol/sdk/shared/auth.js'; -import { resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk/shared/auth-utils.js'; + resourceUrlFromServerUrl, +} from '@modelcontextprotocol/server'; import { env } from '@/lib/env'; export const MCP_DEFAULT_RESOURCE_PATH = '/api/mcp'; @@ -15,8 +14,7 @@ function buildSupabaseOAuthIssuer(originFallback?: string): string { throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL for MCP OAuth metadata'); } - const issuer = new URL('/auth/v1', supabaseUrl); - return issuer.toString().replace(/\/+$/, ''); + return new URL('/auth/v1', supabaseUrl).toString().replace(/\/+$/, ''); } function normalizePath(pathname: string): string { @@ -31,22 +29,26 @@ export function buildMcpResourceUrl(origin: string, resourcePath = MCP_DEFAULT_R } export function buildProtectedResourceMetadataPath(resourcePath = MCP_DEFAULT_RESOURCE_PATH): string { - const metadataUrl = getOAuthProtectedResourceMetadataUrl(new URL(`https://local.test${normalizePath(resourcePath)}`)); - return new URL(metadataUrl).pathname; + return new URL(buildProtectedResourceMetadataUrl('https://local.test', resourcePath)).pathname; } export function buildProtectedResourceMetadataUrl(origin: string, resourcePath = MCP_DEFAULT_RESOURCE_PATH): string { return getOAuthProtectedResourceMetadataUrl(new URL(buildMcpResourceUrl(origin, resourcePath))); } -export function buildProtectedResourceMetadata(origin: string, resourcePath = MCP_DEFAULT_RESOURCE_PATH) { - const issuer = buildSupabaseOAuthIssuer(origin); +export function buildProtectedResourceMetadata( + origin: string, + resourcePath = MCP_DEFAULT_RESOURCE_PATH, +): OAuthProtectedResourceMetadata { const metadata: OAuthProtectedResourceMetadata = { resource: buildMcpResourceUrl(origin, resourcePath), - authorization_servers: [issuer], + authorization_servers: [buildSupabaseOAuthIssuer(origin)], bearer_methods_supported: ['header'], - ...(MCP_DEFAULT_SCOPES.length > 0 ? { scopes_supported: MCP_DEFAULT_SCOPES } : {}), }; - return OAuthProtectedResourceMetadataSchema.parse(metadata); + if (MCP_DEFAULT_SCOPES.length > 0) { + metadata.scopes_supported = MCP_DEFAULT_SCOPES; + } + + return metadata; } diff --git a/src/integrations/mcp/origin.test.ts b/src/integrations/mcp/origin.test.ts index 4766ced..8bf7891 100644 --- a/src/integrations/mcp/origin.test.ts +++ b/src/integrations/mcp/origin.test.ts @@ -62,6 +62,10 @@ describe('mcp origin', () => { expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://claude.ai'); expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Authorization'); + expect(response.headers.get('Access-Control-Allow-Headers')).toContain('MCP-Protocol-Version'); + expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Mcp-Method'); + expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Mcp-Name'); + expect(response.headers.get('Access-Control-Expose-Headers')).toContain('MCP-Protocol-Version'); expect(response.headers.get('Vary')).toContain('Origin'); }); }); diff --git a/src/integrations/mcp/origin.ts b/src/integrations/mcp/origin.ts index ad660b1..84d594f 100644 --- a/src/integrations/mcp/origin.ts +++ b/src/integrations/mcp/origin.ts @@ -95,9 +95,9 @@ export function applyMcpCorsHeaders(response: Response, request: Request): Respo response.headers.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); response.headers.set( 'Access-Control-Allow-Headers', - 'Authorization, Content-Type, Accept, Mcp-Session-Id, Last-Event-ID', + 'Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID', ); - response.headers.set('Access-Control-Expose-Headers', 'WWW-Authenticate, Mcp-Session-Id'); + response.headers.set('Access-Control-Expose-Headers', 'WWW-Authenticate, MCP-Protocol-Version, Mcp-Session-Id'); response.headers.set('Access-Control-Max-Age', '86400'); appendVaryValue(response.headers, 'Origin'); diff --git a/src/integrations/mcp/server.test.ts b/src/integrations/mcp/server.test.ts index 697c703..bac28e4 100644 --- a/src/integrations/mcp/server.test.ts +++ b/src/integrations/mcp/server.test.ts @@ -1,8 +1,28 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import * as processflowService from '@/processflow/service'; import * as storymapService from '@/storymap/service'; -import { handleMcpRequest } from './server'; +import { createMcpAuthInfo } from './auth'; +import { createBeemspecMcpHandler } from './server'; + +const handler = createBeemspecMcpHandler(); + +async function handleMcpRequest( + request: Request, + supabase: never, + user: { id: string; email: string }, +): Promise { + const response = await handler.fetch(request, { + authInfo: createMcpAuthInfo('test-token', { supabase, user }), + }); + if (!response.headers.get('content-type')?.includes('text/event-stream')) return response; + + const data = (await response.text()).split('\n').find((line) => line.startsWith('data: ')); + return new Response(data?.slice('data: '.length), { + status: response.status, + headers: { 'content-type': 'application/json' }, + }); +} function rpcRequest(body: unknown): Request { return new Request('http://localhost/api/mcp', { @@ -23,6 +43,10 @@ describe('mcp server', () => { vi.clearAllMocks(); }); + afterAll(async () => { + await handler.close(); + }); + it('lists major story map management tools', async () => { const initializeResponse = await handleMcpRequest( rpcRequest({ @@ -872,12 +896,9 @@ describe('mcp server', () => { const payload = (await response.json()) as { result: { isError: boolean; - structuredContent: { ok: boolean; error: string; details?: unknown }; }; }; expect(payload.result.isError).toBe(true); - expect(payload.result.structuredContent.ok).toBe(false); - expect(payload.result.structuredContent.error).toBe('Validation failed'); expect(updateTaskSpy).not.toHaveBeenCalled(); }); @@ -906,15 +927,37 @@ describe('mcp server', () => { const payload = (await response.json()) as { result: { isError: boolean; - structuredContent: { ok: boolean; error: string; details?: unknown }; }; }; expect(payload.result.isError).toBe(true); - expect(payload.result.structuredContent.ok).toBe(false); - expect(payload.result.structuredContent.error).toBe('Validation failed'); expect(updateStorySpy).not.toHaveBeenCalled(); }); + it('rejects storymap_update when no changes are supplied', async () => { + const fakeSupabase = { from: vi.fn(), rpc: vi.fn() } as never; + const updateStoryMapSpy = vi.spyOn(storymapService, 'updateStoryMap'); + + const response = await handleMcpRequest( + rpcRequest({ + jsonrpc: '2.0', + id: 12, + method: 'tools/call', + params: { + name: 'storymap_update', + arguments: { + story_map_id: 'd7f34189-5d27-4dc0-b2c5-23d11796add4', + }, + }, + }), + fakeSupabase, + user, + ); + + const payload = (await response.json()) as { result: { isError: boolean } }; + expect(payload.result.isError).toBe(true); + expect(updateStoryMapSpy).not.toHaveBeenCalled(); + }); + it('returns context for a backlog story', async () => { const fakeSupabase = { from: vi.fn((table: string) => { diff --git a/src/integrations/mcp/server.ts b/src/integrations/mcp/server.ts index 3352ee2..0bb4924 100644 --- a/src/integrations/mcp/server.ts +++ b/src/integrations/mcp/server.ts @@ -1,5 +1,5 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { getMcpAuthContext } from '@/integrations/mcp/auth'; import type { AuthenticatedUser } from '@/lib/auth'; import type { Supabase } from '@/lib/supabase/types'; import { listTeamsForUser } from '@/lib/teams'; @@ -39,27 +39,14 @@ function createMcpServer(supabase: Supabase, user: AuthenticatedUser): McpServer return server; } -async function handleMcpRequestOnce(request: Request, supabase: Supabase, user: AuthenticatedUser): Promise { - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }); - const server = createMcpServer(supabase, user); - await server.connect(transport); - - try { - return await transport.handleRequest(request); - } finally { - await server.close().catch(() => { - // no-op on cleanup failures - }); - } +export function createBeemspecMcpHandler() { + return createMcpHandler( + ({ authInfo }) => { + const { supabase, user } = getMcpAuthContext(authInfo); + return createMcpServer(supabase, user); + }, + { responseMode: 'json' }, + ); } -export async function handleMcpRequest( - request: Request, - supabase: Supabase, - user: AuthenticatedUser, -): Promise { - return handleMcpRequestOnce(request, supabase, user); -} +export const mcpHandler = createBeemspecMcpHandler(); diff --git a/src/integrations/mcp/tool-support.ts b/src/integrations/mcp/tool-support.ts index 6285b8e..9ce8f51 100644 --- a/src/integrations/mcp/tool-support.ts +++ b/src/integrations/mcp/tool-support.ts @@ -1,4 +1,3 @@ -import type { z } from 'zod'; import { DbErrorCode } from '@/lib/errors'; import type { Supabase } from '@/lib/supabase/types'; import { listTeamsForUser } from '@/lib/teams'; @@ -68,21 +67,6 @@ export function withToolErrorBoundary(name: string, handler: ToolCall( - schema: z.ZodSchema, - input: unknown, -): { ok: true; data: T } | { ok: false; result: ReturnType } { - const parsed = schema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - result: errorResult('Validation failed', parsed.error.flatten()), - }; - } - - return { ok: true, data: parsed.data }; -} - export const readAnnotations = { readOnlyHint: true, idempotentHint: true, diff --git a/src/integrations/mcp/tools/personas.ts b/src/integrations/mcp/tools/personas.ts index f8238ca..5a378d3 100644 --- a/src/integrations/mcp/tools/personas.ts +++ b/src/integrations/mcp/tools/personas.ts @@ -1,6 +1,7 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; -import { createPersonaSchema, updatePersonaSchema } from '@/domain/story-map'; +import { createPersonaSchema } from '@/domain/story-map'; +import { updatePersonaToolSchema } from '@/domain/story-map/schemas'; import type { Supabase } from '@/lib/supabase/types'; import { createPersona, deletePersona, listPersonas, updatePersona } from '@/storymap/service'; import { getStoryContext } from '../queries'; @@ -12,7 +13,6 @@ import { mutateAnnotations, readAnnotations, successResult, - validateToolInput, withToolErrorBoundary, } from '../tool-support'; @@ -23,9 +23,7 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi { title: 'List Personas', description: 'List personas attached to a story map. Prefer storymap_get if you already need full map context.', - inputSchema: { - story_map_id: z.string().uuid(), - }, + inputSchema: z.object({ story_map_id: z.string().uuid() }).strict(), annotations: readAnnotations, }, withToolErrorBoundary('persona_list', async ({ story_map_id }) => { @@ -42,7 +40,7 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi { title: 'Create Persona', description: 'Create a persona for a story map to capture user archetypes and goals.', - inputSchema: createPersonaSchema.shape, + inputSchema: createPersonaSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('persona_create', async (input) => { @@ -59,18 +57,12 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi { title: 'Update Persona', description: 'Update persona fields like name, description, or goals.', - inputSchema: { - persona_id: z.string().uuid(), - ...updatePersonaSchema.shape, - }, + inputSchema: updatePersonaToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('persona_update', async ({ persona_id, ...changes }) => { - const validation = validateToolInput(updatePersonaSchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updatePersona(supabase, persona_id, validation.data); + const { data, error } = await updatePersona(supabase, persona_id, changes); if (error) { if (isNotFound(error)) return errorResult('Persona not found'); @@ -86,9 +78,7 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi { title: 'Delete Persona', description: 'Destructive. Deletes a persona from the story map.', - inputSchema: { - persona_id: z.string().uuid(), - }, + inputSchema: z.object({ persona_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('persona_delete', async ({ persona_id }) => { @@ -110,9 +100,7 @@ export function registerPersonaTools(server: McpServer, supabase: Supabase): voi title: 'Get Story Context', description: 'Full implementation context for one story, including workflow placement, personas, and Figma hints when present. Use after selecting a story via storymap_get.', - inputSchema: { - story_id: z.string().uuid().describe('BeemSpec story UUID'), - }, + inputSchema: z.object({ story_id: z.string().uuid().describe('BeemSpec story UUID') }).strict(), annotations: readAnnotations, }, withToolErrorBoundary('story_context_get', async ({ story_id }) => { diff --git a/src/integrations/mcp/tools/planning.ts b/src/integrations/mcp/tools/planning.ts index a26a9f1..54b05f2 100644 --- a/src/integrations/mcp/tools/planning.ts +++ b/src/integrations/mcp/tools/planning.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; import { createActivitySchema, @@ -8,10 +8,8 @@ import { reorderActivitiesSchema, reorderReleasesSchema, reorderTasksSchema, - updateActivitySchema, - updateReleaseSchema, - updateTaskSchema, } from '@/domain/story-map'; +import { updateActivityToolSchema, updateReleaseToolSchema, updateTaskToolSchema } from '@/domain/story-map/schemas'; import type { Supabase } from '@/lib/supabase/types'; import { createActivity, @@ -36,10 +34,11 @@ import { isNotFound, mutateAnnotations, successResult, - validateToolInput, withToolErrorBoundary, } from '../tool-support'; +const moveTaskToolSchema = moveTaskSchema.extend({ task_id: z.string().uuid() }); + export function registerPlanningTools(server: McpServer, supabase: Supabase): void { const getUserScopedClient = () => supabase; server.registerTool( @@ -47,7 +46,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Create Activity', description: 'Create an activity column in a story map. Use IDs from storymap_get and refresh after mutation.', - inputSchema: createActivitySchema.shape, + inputSchema: createActivitySchema, annotations: mutateAnnotations, }, withToolErrorBoundary('activity_create', async (input) => { @@ -71,18 +70,12 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Update Activity', description: 'Update activity fields like name or description. Use reorder tools for position changes.', - inputSchema: { - activity_id: z.string().uuid(), - ...updateActivitySchema.shape, - }, + inputSchema: updateActivityToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('activity_update', async ({ activity_id, ...changes }) => { - const validation = validateToolInput(updateActivitySchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updateActivity(supabase, activity_id, validation.data); + const { data, error } = await updateActivity(supabase, activity_id, changes); if (error) { if (isNotFound(error)) return errorResult('Activity not found'); @@ -104,9 +97,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Delete Activity', description: 'Destructive. Deletes an activity and all nested tasks/stories under it.', - inputSchema: { - activity_id: z.string().uuid(), - }, + inputSchema: z.object({ activity_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('activity_delete', async ({ activity_id }) => { @@ -126,7 +117,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Reorder Activities', description: 'Reorder activities in final sequence. Provide full ordered ID list from storymap_get context.', - inputSchema: reorderActivitiesSchema.shape, + inputSchema: reorderActivitiesSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('activity_reorder', async ({ story_map_id, order }) => { @@ -150,7 +141,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Create Task', description: 'Create a task under an activity. Choose target activity from storymap_get output.', - inputSchema: createTaskSchema.shape, + inputSchema: createTaskSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('task_create', async (input) => { @@ -174,18 +165,12 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Update Task', description: 'Update task fields like name or description. Use move/reorder tools for position changes.', - inputSchema: { - task_id: z.string().uuid(), - ...updateTaskSchema.shape, - }, + inputSchema: updateTaskToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('task_update', async ({ task_id, ...changes }) => { - const validation = validateToolInput(updateTaskSchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updateTask(supabase, task_id, validation.data); + const { data, error } = await updateTask(supabase, task_id, changes); if (error) { if (isNotFound(error)) return errorResult('Task not found'); @@ -207,9 +192,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Delete Task', description: 'Destructive. Deletes a task and all stories under it.', - inputSchema: { - task_id: z.string().uuid(), - }, + inputSchema: z.object({ task_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('task_delete', async ({ task_id }) => { @@ -229,7 +212,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Reorder Tasks', description: 'Reorder tasks within an activity. Provide full ordered ID list for that activity.', - inputSchema: reorderTasksSchema.shape, + inputSchema: reorderTasksSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('task_reorder', async ({ activity_id, order }) => { @@ -253,10 +236,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Move Task', description: 'Atomically move a task to another activity and set full target order in one operation.', - inputSchema: { - task_id: z.string().uuid(), - ...moveTaskSchema.shape, - }, + inputSchema: moveTaskToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('task_move', async ({ task_id, ...input }) => { @@ -282,7 +262,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Create Release', description: 'Create a release lane (row) in a story map. Useful before placing or moving stories.', - inputSchema: createReleaseSchema.shape, + inputSchema: createReleaseSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('release_create', async (input) => { @@ -306,18 +286,12 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Update Release', description: 'Update release fields like name or description. Use reorder tools for position changes.', - inputSchema: { - release_id: z.string().uuid(), - ...updateReleaseSchema.shape, - }, + inputSchema: updateReleaseToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('release_update', async ({ release_id, ...changes }) => { - const validation = validateToolInput(updateReleaseSchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updateRelease(supabase, release_id, validation.data); + const { data, error } = await updateRelease(supabase, release_id, changes); if (error) { if (isNotFound(error)) return errorResult('Release not found'); @@ -339,9 +313,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Delete Release', description: 'Destructive. Deletes a release and stories currently assigned to that release.', - inputSchema: { - release_id: z.string().uuid(), - }, + inputSchema: z.object({ release_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('release_delete', async ({ release_id }) => { @@ -361,7 +333,7 @@ export function registerPlanningTools(server: McpServer, supabase: Supabase): vo { title: 'Reorder Releases', description: 'Reorder release lanes in final sequence. Provide full ordered release ID list.', - inputSchema: reorderReleasesSchema.shape, + inputSchema: reorderReleasesSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('release_reorder', async ({ story_map_id, order }) => { diff --git a/src/integrations/mcp/tools/process-flows.ts b/src/integrations/mcp/tools/process-flows.ts index f8afdd4..82b2cf5 100644 --- a/src/integrations/mcp/tools/process-flows.ts +++ b/src/integrations/mcp/tools/process-flows.ts @@ -1,13 +1,15 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; import { createProcessFlowEdgeSchema, createProcessFlowNodeSchema, createProcessFlowSchema, - updateProcessFlowEdgeSchema, - updateProcessFlowNodeSchema, - updateProcessFlowSchema, } from '@/domain/process-flow'; +import { + updateProcessFlowEdgeToolSchema, + updateProcessFlowNodeToolSchema, + updateProcessFlowToolSchema, +} from '@/domain/process-flow/schemas'; import type { AuthenticatedUser } from '@/lib/auth'; import type { Supabase } from '@/lib/supabase/types'; import { @@ -37,10 +39,13 @@ import { resolveAccessibleTeamId, resolveProcessFlowIdByName, successResult, - validateToolInput, withToolErrorBoundary, } from '../tool-support'; +const createProcessFlowToolSchema = createProcessFlowSchema.extend({ + team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), +}); + export function registerProcessFlowTools(server: McpServer, supabase: Supabase, user: AuthenticatedUser): void { const getUserScopedClient = () => supabase; server.registerTool( @@ -117,9 +122,9 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'List Process Flows', description: 'Starting point. List process flows for a team. team_id is optional when the user has exactly one team.', - inputSchema: { - team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), - }, + inputSchema: z + .object({ team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)') }) + .strict(), annotations: readAnnotations, }, withToolErrorBoundary('processflow_list', async ({ team_id }) => { @@ -140,11 +145,13 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'Get Process Flow', description: 'Primary context loader. Pass process_flow_id directly, or pass process_flow_name (and optional team_id) for resolution.', - inputSchema: { - process_flow_id: z.string().uuid().optional().describe('Process flow UUID'), - process_flow_name: z.string().min(1).max(200).optional().describe('Process flow name'), - team_id: z.string().uuid().optional().describe('Team UUID for disambiguating name matches'), - }, + inputSchema: z + .object({ + process_flow_id: z.string().uuid().optional().describe('Process flow UUID'), + process_flow_name: z.string().min(1).max(200).optional().describe('Process flow name'), + team_id: z.string().uuid().optional().describe('Team UUID for disambiguating name matches'), + }) + .strict(), annotations: readAnnotations, }, withToolErrorBoundary('processflow_get', async ({ process_flow_id, process_flow_name, team_id }) => { @@ -187,9 +194,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Validate Process Flow', description: 'Return deterministic structural warnings for a process flow.', - inputSchema: { - process_flow_id: z.string().uuid().describe('Process flow UUID'), - }, + inputSchema: z.object({ process_flow_id: z.string().uuid().describe('Process flow UUID') }).strict(), annotations: readAnnotations, }, withToolErrorBoundary('processflow_validation_get', async ({ process_flow_id }) => { @@ -209,13 +214,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Create Process Flow', description: 'Create a new process flow container. team_id is optional when the user has exactly one team.', - inputSchema: { - team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), - name: createProcessFlowSchema.shape.name, - description: createProcessFlowSchema.shape.description, - context_markdown: createProcessFlowSchema.shape.context_markdown, - viewport: createProcessFlowSchema.shape.viewport, - }, + inputSchema: createProcessFlowToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('processflow_create', async (input) => { @@ -241,18 +240,12 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Update Process Flow', description: 'Update process flow metadata such as name, description, context, or viewport.', - inputSchema: { - process_flow_id: z.string().uuid(), - ...updateProcessFlowSchema.shape, - }, + inputSchema: updateProcessFlowToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('processflow_update', async ({ process_flow_id, ...changes }) => { - const validation = validateToolInput(updateProcessFlowSchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updateProcessFlow(supabase, process_flow_id, validation.data); + const { data, error } = await updateProcessFlow(supabase, process_flow_id, changes); if (error) { if (isNotFound(error)) return errorResult('Process flow not found'); return errorResult('Failed to update process flow', describeDbError(error)); @@ -267,9 +260,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Delete Process Flow', description: 'Destructive. Deletes a process flow and all nested nodes and edges.', - inputSchema: { - process_flow_id: z.string().uuid(), - }, + inputSchema: z.object({ process_flow_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('processflow_delete', async ({ process_flow_id }) => { @@ -290,7 +281,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'Create Process Flow Node', description: 'Create a node in a process flow. Node data fields include label, owner_role, systems, inputs, outputs, pain_points, notes, automation_opportunity, frequency, estimated_duration, and time_constraint.', - inputSchema: createProcessFlowNodeSchema.shape, + inputSchema: createProcessFlowNodeSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('processflow_node_create', async (input) => { @@ -308,19 +299,12 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'Update Process Flow Node', description: 'Update a process flow node. Use this for label, ownership, metadata, position, or node data changes including systems, inputs, outputs, pain_points, notes, automation_opportunity, frequency, estimated_duration, and time_constraint.', - inputSchema: { - process_flow_id: z.string().uuid(), - node_id: z.string().uuid(), - ...updateProcessFlowNodeSchema.shape, - }, + inputSchema: updateProcessFlowNodeToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('processflow_node_update', async ({ process_flow_id, node_id, ...changes }) => { - const validation = validateToolInput(updateProcessFlowNodeSchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updateProcessFlowNode(supabase, process_flow_id, node_id, validation.data); + const { data, error } = await updateProcessFlowNode(supabase, process_flow_id, node_id, changes); if (error) { if (isNotFound(error)) return errorResult('Process flow node not found'); return errorResult('Failed to update process flow node', describeDbError(error)); @@ -335,10 +319,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Delete Process Flow Node', description: 'Destructive. Deletes a process flow node and any connected edges removed by cascade.', - inputSchema: { - process_flow_id: z.string().uuid(), - node_id: z.string().uuid(), - }, + inputSchema: z.object({ process_flow_id: z.string().uuid(), node_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('processflow_node_delete', async ({ process_flow_id, node_id }) => { @@ -358,7 +339,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Create Process Flow Edge', description: 'Create an edge between two nodes in a process flow. Edge data fields include label and condition.', - inputSchema: createProcessFlowEdgeSchema.shape, + inputSchema: createProcessFlowEdgeSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('processflow_edge_create', async (input) => { @@ -376,19 +357,12 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, title: 'Update Process Flow Edge', description: 'Update a process flow edge. Use this for type changes or edge data updates including label and condition.', - inputSchema: { - process_flow_id: z.string().uuid(), - edge_id: z.string().uuid(), - ...updateProcessFlowEdgeSchema.shape, - }, + inputSchema: updateProcessFlowEdgeToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('processflow_edge_update', async ({ process_flow_id, edge_id, ...changes }) => { - const validation = validateToolInput(updateProcessFlowEdgeSchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updateProcessFlowEdge(supabase, process_flow_id, edge_id, validation.data); + const { data, error } = await updateProcessFlowEdge(supabase, process_flow_id, edge_id, changes); if (error) { if (isNotFound(error)) return errorResult('Process flow edge not found'); return errorResult('Failed to update process flow edge', describeDbError(error)); @@ -403,10 +377,7 @@ export function registerProcessFlowTools(server: McpServer, supabase: Supabase, { title: 'Delete Process Flow Edge', description: 'Destructive. Deletes a process flow edge.', - inputSchema: { - process_flow_id: z.string().uuid(), - edge_id: z.string().uuid(), - }, + inputSchema: z.object({ process_flow_id: z.string().uuid(), edge_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('processflow_edge_delete', async ({ process_flow_id, edge_id }) => { diff --git a/src/integrations/mcp/tools/stories.ts b/src/integrations/mcp/tools/stories.ts index a0c23d6..d8ad2af 100644 --- a/src/integrations/mcp/tools/stories.ts +++ b/src/integrations/mcp/tools/stories.ts @@ -1,6 +1,7 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; -import { createStorySchema, moveStorySchema, reorderStoriesSchema, updateStorySchema } from '@/domain/story-map'; +import { createStorySchema, moveStorySchema, reorderStoriesSchema } from '@/domain/story-map'; +import { updateStoryToolSchema } from '@/domain/story-map/schemas'; import type { Supabase } from '@/lib/supabase/types'; import { createStory, deleteStory, getStory, moveStory, reorderStories, updateStory } from '@/storymap/service'; import { buildMutationGuidance } from '../insights/story-map'; @@ -13,10 +14,11 @@ import { mutateAnnotations, readAnnotations, successResult, - validateToolInput, withToolErrorBoundary, } from '../tool-support'; +const moveStoryToolSchema = moveStorySchema.extend({ story_id: z.string().uuid() }); + export function registerStoryTools(server: McpServer, supabase: Supabase): void { const getUserScopedClient = () => supabase; server.registerTool( @@ -24,9 +26,7 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void { title: 'Get Story', description: 'Load one story by ID when full map context is not required.', - inputSchema: { - story_id: z.string().uuid(), - }, + inputSchema: z.object({ story_id: z.string().uuid() }).strict(), annotations: readAnnotations, }, withToolErrorBoundary('story_get', async ({ story_id }) => { @@ -50,7 +50,7 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void { title: 'Create Story', description: 'Create a story in a task/release cell. Requires task_id and structured story content.', - inputSchema: createStorySchema.shape, + inputSchema: createStorySchema, annotations: mutateAnnotations, }, withToolErrorBoundary('story_create', async (input) => { @@ -76,18 +76,12 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void title: 'Update Story', description: 'Update story fields like title, status, or content. Use story_move for task/release placement changes.', - inputSchema: { - story_id: z.string().uuid(), - ...updateStorySchema.shape, - }, + inputSchema: updateStoryToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('story_update', async ({ story_id, ...changes }) => { - const validation = validateToolInput(updateStorySchema, changes); - if (!validation.ok) return validation.result; - const supabase = getUserScopedClient(); - const { data, error } = await updateStory(supabase, story_id, validation.data); + const { data, error } = await updateStory(supabase, story_id, changes); if (error) { if (isNotFound(error)) return errorResult('Story not found'); @@ -110,9 +104,7 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void { title: 'Delete Story', description: 'Destructive. Deletes a story from the map.', - inputSchema: { - story_id: z.string().uuid(), - }, + inputSchema: z.object({ story_id: z.string().uuid() }).strict(), annotations: destructiveAnnotations, }, withToolErrorBoundary('story_delete', async ({ story_id }) => { @@ -133,7 +125,7 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void { title: 'Reorder Stories', description: 'Reorder stories within a specific task+release cell using a full ordered story ID list.', - inputSchema: reorderStoriesSchema.shape, + inputSchema: reorderStoriesSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('story_reorder', async ({ task_id, release_id, order }) => { @@ -159,10 +151,7 @@ export function registerStoryTools(server: McpServer, supabase: Supabase): void { title: 'Move Story', description: 'Atomically move a story to another task/release cell and set full target order in one operation.', - inputSchema: { - story_id: z.string().uuid(), - ...moveStorySchema.shape, - }, + inputSchema: moveStoryToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('story_move', async ({ story_id, ...input }) => { diff --git a/src/integrations/mcp/tools/story-maps.ts b/src/integrations/mcp/tools/story-maps.ts index 4ff5b1e..7e7f2e7 100644 --- a/src/integrations/mcp/tools/story-maps.ts +++ b/src/integrations/mcp/tools/story-maps.ts @@ -1,6 +1,7 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; -import { createStoryMapSchema, updateStoryMapSchema } from '@/domain/story-map'; +import { createStoryMapSchema } from '@/domain/story-map'; +import { updateStoryMapToolSchema } from '@/domain/story-map/schemas'; import type { AuthenticatedUser } from '@/lib/auth'; import type { Supabase } from '@/lib/supabase/types'; import { @@ -28,6 +29,10 @@ import { withToolErrorBoundary, } from '../tool-support'; +const createStoryMapToolSchema = createStoryMapSchema.extend({ + team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), +}); + export function registerStoryMapTools(server: McpServer, supabase: Supabase, user: AuthenticatedUser): void { const getUserScopedClient = () => supabase; server.registerTool( @@ -163,9 +168,9 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use title: 'List Story Maps', description: 'Starting point. List story maps for a team. team_id is optional when the user has exactly one team.', - inputSchema: { - team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), - }, + inputSchema: z + .object({ team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)') }) + .strict(), annotations: readAnnotations, }, withToolErrorBoundary('storymap_list', async ({ team_id }) => { @@ -189,11 +194,13 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use title: 'Get Story Map', description: 'Primary context loader. Pass story_map_id directly, or pass story_map_name (and optional team_id) for resolution.', - inputSchema: { - story_map_id: z.string().uuid().optional().describe('Story map UUID'), - story_map_name: z.string().min(1).max(200).optional().describe('Story map name'), - team_id: z.string().uuid().optional().describe('Team UUID for disambiguating name matches'), - }, + inputSchema: z + .object({ + story_map_id: z.string().uuid().optional().describe('Story map UUID'), + story_map_name: z.string().min(1).max(200).optional().describe('Story map name'), + team_id: z.string().uuid().optional().describe('Team UUID for disambiguating name matches'), + }) + .strict(), annotations: readAnnotations, }, withToolErrorBoundary('storymap_get', async ({ story_map_id, story_map_name, team_id }) => { @@ -267,9 +274,7 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use title: 'Get Release', description: 'Load one release with release context and lightweight story references. Use this for release planning and scope review.', - inputSchema: { - release_id: z.string().uuid().describe('Release UUID'), - }, + inputSchema: z.object({ release_id: z.string().uuid().describe('Release UUID') }).strict(), annotations: readAnnotations, }, withToolErrorBoundary('release_get', async ({ release_id }) => { @@ -324,12 +329,7 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use title: 'Create Story Map', description: 'Create a new story map container. team_id is optional when user has exactly one team. Call storymap_get afterward for full context.', - inputSchema: { - team_id: z.string().uuid().optional().describe('Team UUID (optional for single-team users)'), - name: createStoryMapSchema.shape.name, - description: createStoryMapSchema.shape.description, - context_markdown: createStoryMapSchema.shape.context_markdown, - }, + inputSchema: createStoryMapToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('storymap_create', async (input) => { @@ -355,10 +355,7 @@ export function registerStoryMapTools(server: McpServer, supabase: Supabase, use title: 'Update Story Map', description: 'Update story map metadata (name/description). Re-read with storymap_get to continue planning safely.', - inputSchema: { - story_map_id: z.string().uuid(), - ...updateStoryMapSchema.shape, - }, + inputSchema: updateStoryMapToolSchema, annotations: mutateAnnotations, }, withToolErrorBoundary('storymap_update', async ({ story_map_id, ...changes }) => {