From 49e13e4b2f512a745b26d1c89f7701db8c3dbedd Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 12:34:12 -0400 Subject: [PATCH 01/11] feat: add MCP stdio server exposing standards as tools and resources Adds a read-only Model Context Protocol server so AI clients can discover and query engineering standards from the same PostgreSQL database used by the Fastify HTTP API. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 54 ++- package-lock.json | 917 ++++++++++++++++++++++++++++++++++++++++++- package.json | 3 + src/mcp/resources.ts | 50 +++ src/mcp/server.ts | 37 ++ src/mcp/tools.ts | 111 ++++++ 6 files changed, 1169 insertions(+), 3 deletions(-) create mode 100644 src/mcp/resources.ts create mode 100644 src/mcp/server.ts create mode 100644 src/mcp/tools.ts diff --git a/README.md b/README.md index 6266748..54787a5 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,67 @@ docker compose exec app npm run prisma:seed:prod ## Scripts ```bash -npm run dev # start local dev server +npm run dev # start local HTTP dev server npm run build # compile TypeScript npm run lint # strict TypeScript check npm test # run tests +npm run mcp:dev # start MCP stdio server (tsx, no build required) +npm run mcp:start # start compiled MCP stdio server npm run prisma:migrate # create/apply local migration npm run prisma:deploy # apply migrations in deployed environments npm run prisma:seed # load example standards ``` +## MCP Server + +The MCP server exposes read-only access to engineering standards over the [Model Context Protocol](https://modelcontextprotocol.io) stdio transport. It shares the same PostgreSQL database as the HTTP API. + +### Running locally + +```bash +# Development (no build step required) +DATABASE_URL=postgresql://user:password@localhost:5432/standards npm run mcp:dev + +# Production (build first) +npm run build +DATABASE_URL=postgresql://user:password@localhost:5432/standards npm run mcp:start +``` + +### Client configuration + +Add the following to your MCP client configuration (for example, `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "standards-api": { + "command": "npm", + "args": ["run", "mcp:start"], + "cwd": "/absolute/path/to/standards-api", + "env": { + "DATABASE_URL": "postgresql://user:password@localhost:5432/standards" + } + } + } +} +``` + +### Tools + +| Tool | Description | +| --- | --- | +| `list_standards` | List standards with optional `status`, `category`, `severity`, `owner`, `limit`, `offset` filters | +| `get_standard` | Get the latest version of a single standard by `rule_key` | +| `latest_standards` | Return the latest active standards payload (same as `GET /api/v1/standards/latest`) | +| `applicable_standards` | Return active standards matching `repo`, `team`, `language`, `framework`, `runtime`, `environment`, and/or `changed_paths` | + +### Resources + +| URI | Description | +| --- | --- | +| `standards://latest` | Latest active standards payload as JSON | +| `standards://rule/{rule_key}` | A single standard by rule key | + ## Database The service creates one table: diff --git a/package-lock.json b/package-lock.json index 9d0f885..819ee9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.3", + "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/client": "^6.8.2", "dotenv": "^16.5.0", "fastify": "^5.3.3", @@ -624,6 +625,18 @@ "vary": "^1.1.2" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -640,6 +653,55 @@ "node": ">=8" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "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" + }, + "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_modules/@modelcontextprotocol/sdk/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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1237,6 +1299,19 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1318,6 +1393,39 @@ "node": "18 || 20 || >=22" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -1330,6 +1438,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -1368,6 +1485,35 @@ "node": ">=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==", + "license": "MIT", + "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==", + "license": "MIT", + "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/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1434,6 +1580,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/content-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", @@ -1460,11 +1619,50 @@ "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1539,6 +1737,26 @@ "url": "https://dotenvx.com" } }, + "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==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT" + }, "node_modules/effect": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", @@ -1558,6 +1776,33 @@ "node": ">=14" } }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1565,6 +1810,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", @@ -1607,6 +1864,12 @@ "@esbuild/win32-x64": "0.28.0" } }, + "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==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1617,6 +1880,36 @@ "@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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1627,6 +1920,85 @@ "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==", + "license": "MIT", + "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.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "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/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1792,6 +2164,27 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "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/find-my-way": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", @@ -1815,6 +2208,15 @@ "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==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1830,6 +2232,52 @@ "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -1847,6 +2295,51 @@ "giget": "dist/cli.mjs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.22", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", + "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1867,12 +2360,37 @@ "url": "https://opencollective.com/express" } }, + "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==", + "license": "MIT", + "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==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -1882,6 +2400,18 @@ "node": ">= 10" } }, + "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==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1891,6 +2421,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1923,6 +2462,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "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==", + "license": "BSD-2-Clause" + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -1977,6 +2522,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -1986,6 +2540,18 @@ "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==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2030,7 +2596,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2052,6 +2617,15 @@ "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -2081,6 +2655,27 @@ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "license": "MIT" }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -2096,6 +2691,55 @@ "node": ">=14.0.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==", + "license": "MIT", + "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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "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==", + "license": "MIT", + "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", @@ -2175,6 +2819,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -2256,6 +2909,28 @@ ], "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/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==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -2272,12 +2947,51 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -2396,6 +3110,22 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "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/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -2427,6 +3157,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -2455,6 +3191,51 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "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==", + "license": "MIT", + "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/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -2467,6 +3248,99 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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", @@ -2695,6 +3569,15 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3366,6 +4249,21 @@ "dev": true, "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3383,6 +4281,12 @@ "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==", + "license": "ISC" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -3391,6 +4295,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 26b7051..ef32355 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "tsc -p tsconfig.json", "dev": "tsx watch src/server.ts", "start": "node dist/server.js", + "mcp:dev": "tsx src/mcp/server.ts", + "mcp:start": "node dist/src/mcp/server.js", "test": "vitest run", "lint": "tsc -p tsconfig.json --noEmit", "prisma:generate": "prisma generate", @@ -26,6 +28,7 @@ "dependencies": { "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.3", + "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/client": "^6.8.2", "dotenv": "^16.5.0", "fastify": "^5.3.3", diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts new file mode 100644 index 0000000..746ba2c --- /dev/null +++ b/src/mcp/resources.ts @@ -0,0 +1,50 @@ +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { StandardsService } from "../services/standards-service.js"; +import { serializeReviewOpsPayload, serializeStandard } from "../http/serializers.js"; + +export function registerResources(server: McpServer, service: StandardsService): void { + server.registerResource( + "latest-standards", + "standards://latest", + { + description: "Latest active engineering standards payload, including standards_version fingerprint.", + mimeType: "application/json" + }, + async (_uri) => { + const payload = await service.latestPayload(); + return { + contents: [ + { + uri: "standards://latest", + mimeType: "application/json", + text: JSON.stringify(serializeReviewOpsPayload(payload), null, 2) + } + ] + }; + } + ); + + const ruleTemplate = new ResourceTemplate("standards://rule/{rule_key}", { list: undefined }); + + server.registerResource( + "standard-by-rule-key", + ruleTemplate, + { + description: "A single engineering standard by rule key.", + mimeType: "application/json" + }, + async (uri, { rule_key }) => { + const key = Array.isArray(rule_key) ? rule_key[0] : rule_key; + const rule = await service.getLatest(key); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify(serializeStandard(rule), null, 2) + } + ] + }; + } + ); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..38e99a2 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,37 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { PrismaClient } from "@prisma/client"; +import { PrismaStandardsRepository } from "../repositories/prisma-standards-repository.js"; +import { StandardsService } from "../services/standards-service.js"; +import { registerTools } from "./tools.js"; +import { registerResources } from "./resources.js"; + +const prisma = new PrismaClient(); +const repository = new PrismaStandardsRepository(prisma); +const service = new StandardsService(repository); + +const server = new McpServer({ + name: "standards-api", + version: "1.0.0" +}); + +registerTools(server, service); +registerResources(server, service); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +async function shutdown(): Promise { + await server.close(); + await prisma.$disconnect(); +} + +process.on("SIGINT", () => void shutdown().then(() => process.exit(0))); +process.on("SIGTERM", () => void shutdown().then(() => process.exit(0))); + +main().catch((err) => { + console.error("MCP server error:", err); + process.exit(1); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts new file mode 100644 index 0000000..19cee82 --- /dev/null +++ b/src/mcp/tools.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { NotFoundError, ValidationError } from "../domain/errors.js"; +import type { StandardsService } from "../services/standards-service.js"; +import { serializeReviewOpsPayload, serializeStandard } from "../http/serializers.js"; +import { categories, severities, statuses } from "../domain/standard.js"; + +function toolError(err: unknown): { isError: true; content: [{ type: "text"; text: string }] } { + const message = err instanceof Error ? err.message : String(err); + return { isError: true, content: [{ type: "text", text: message }] }; +} + +function toolResult(data: unknown): { content: [{ type: "text"; text: string }] } { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +export function registerTools(server: McpServer, service: StandardsService): void { + server.registerTool( + "list_standards", + { + description: "List engineering standards with optional filters. Returns the latest version of each matching standard.", + inputSchema: { + status: z.enum(statuses).optional().default("active"), + category: z.enum(categories).optional(), + severity: z.enum(severities).optional(), + owner: z.string().trim().min(1).optional(), + limit: z.number().int().min(1).max(500).optional(), + offset: z.number().int().min(0).optional() + } + }, + async (input) => { + try { + const rules = await service.list(input); + return toolResult({ data: rules.map(serializeStandard) }); + } catch (err) { + return toolError(err); + } + } + ); + + server.registerTool( + "get_standard", + { + description: "Get the latest version of a single engineering standard by its rule key.", + inputSchema: { + rule_key: z.string().trim().min(1) + } + }, + async ({ rule_key }) => { + try { + const rule = await service.getLatest(rule_key); + return toolResult(serializeStandard(rule)); + } catch (err) { + if (err instanceof NotFoundError) { + return toolError(err); + } + return toolError(err); + } + } + ); + + server.registerTool( + "latest_standards", + { + description: "Returns the latest active standards payload used by review tooling, including a standards_version fingerprint." + }, + async () => { + try { + const payload = await service.latestPayload(); + return toolResult(serializeReviewOpsPayload(payload)); + } catch (err) { + return toolError(err); + } + } + ); + + server.registerTool( + "applicable_standards", + { + description: "Returns active standards that apply to a given repo, team, language, framework, runtime, environment, or set of changed file paths. All filters are optional.", + inputSchema: { + repo: z.string().trim().min(1).optional(), + team: z.string().trim().min(1).optional(), + language: z.string().trim().min(1).optional(), + framework: z.string().trim().min(1).optional(), + runtime: z.string().trim().min(1).optional(), + environment: z.string().trim().min(1).optional(), + changed_paths: z.array(z.string().trim().min(1)).optional() + } + }, + async ({ repo, team, language, framework, runtime, environment, changed_paths }) => { + try { + const payload = await service.applicable({ + repo, + team, + language, + framework, + runtime, + environment, + changedPaths: changed_paths + }); + return toolResult(serializeReviewOpsPayload(payload)); + } catch (err) { + if (err instanceof ValidationError) { + return toolError(err); + } + return toolError(err); + } + } + ); +} From 7837c9806c938aef56c94ebff932653f0ec0c7f2 Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 12:50:37 -0400 Subject: [PATCH 02/11] fix: address Copilot review comments on MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toolError now serializes AppError code and details into JSON so MCP clients get structured error metadata, not just the message string - get_standard validates rule_key against the same regex as the HTTP API to fail fast on malformed keys before touching the database - list_standards uses z.coerce.number() for limit/offset so LLM-generated string values round-trip correctly - Remove redundant per-type catch branches in get_standard and applicable_standards — both paths returned toolError(err) identically - Add test/mcp-tools.test.ts covering all four tools, changed_paths array mapping, empty-result cases, and AppError serialization Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/tools.ts | 23 ++++---- test/mcp-tools.test.ts | 122 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 test/mcp-tools.test.ts diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 19cee82..bd8ff21 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,11 +1,20 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { NotFoundError, ValidationError } from "../domain/errors.js"; +import { AppError } from "../domain/errors.js"; import type { StandardsService } from "../services/standards-service.js"; import { serializeReviewOpsPayload, serializeStandard } from "../http/serializers.js"; import { categories, severities, statuses } from "../domain/standard.js"; +const RULE_KEY_REGEX = /^[A-Z0-9]+(?:-[A-Z0-9]+)+-\d{3,}$/; + function toolError(err: unknown): { isError: true; content: [{ type: "text"; text: string }] } { + if (err instanceof AppError) { + const payload: Record = { code: err.code, message: err.message }; + if (err.details !== undefined) { + payload.details = err.details; + } + return { isError: true, content: [{ type: "text", text: JSON.stringify(payload) }] }; + } const message = err instanceof Error ? err.message : String(err); return { isError: true, content: [{ type: "text", text: message }] }; } @@ -24,8 +33,8 @@ export function registerTools(server: McpServer, service: StandardsService): voi category: z.enum(categories).optional(), severity: z.enum(severities).optional(), owner: z.string().trim().min(1).optional(), - limit: z.number().int().min(1).max(500).optional(), - offset: z.number().int().min(0).optional() + limit: z.coerce.number().int().min(1).max(500).optional(), + offset: z.coerce.number().int().min(0).optional() } }, async (input) => { @@ -43,7 +52,7 @@ export function registerTools(server: McpServer, service: StandardsService): voi { description: "Get the latest version of a single engineering standard by its rule key.", inputSchema: { - rule_key: z.string().trim().min(1) + rule_key: z.string().trim().regex(RULE_KEY_REGEX, "rule_key must match pattern like SRE-K8S-003") } }, async ({ rule_key }) => { @@ -51,9 +60,6 @@ export function registerTools(server: McpServer, service: StandardsService): voi const rule = await service.getLatest(rule_key); return toolResult(serializeStandard(rule)); } catch (err) { - if (err instanceof NotFoundError) { - return toolError(err); - } return toolError(err); } } @@ -101,9 +107,6 @@ export function registerTools(server: McpServer, service: StandardsService): voi }); return toolResult(serializeReviewOpsPayload(payload)); } catch (err) { - if (err instanceof ValidationError) { - return toolError(err); - } return toolError(err); } } diff --git a/test/mcp-tools.test.ts b/test/mcp-tools.test.ts new file mode 100644 index 0000000..4388ac1 --- /dev/null +++ b/test/mcp-tools.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { MemoryStandardsRepository } from "../src/repositories/memory-standards-repository.js"; +import { StandardsService } from "../src/services/standards-service.js"; +import { registerTools } from "../src/mcp/tools.js"; +import { seedStandards } from "../src/seed-data.js"; + +function makeServer(standards = seedStandards) { + const repo = new MemoryStandardsRepository(standards); + const service = new StandardsService(repo); + const server = new McpServer({ name: "test", version: "0.0.0" }); + registerTools(server, service); + return server; +} + +async function callTool(server: McpServer, name: string, args: Record = {}) { + const tools = (server as unknown as { _registeredTools: Record unknown }> })._registeredTools; + const tool = tools[name]; + if (!tool) throw new Error(`Tool ${name} not registered`); + return tool.handler(args); +} + +describe("MCP tools", () => { + describe("list_standards", () => { + it("returns all active standards by default", async () => { + const server = makeServer(); + const result = await callTool(server, "list_standards", {}); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + expect(parsed.data).toHaveLength(seedStandards.length); + expect(parsed.data[0]).toHaveProperty("rule_key"); + }); + + it("filters by category", async () => { + const server = makeServer(); + const result = await callTool(server, "list_standards", { category: "reliability" }); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + expect(parsed.data.every((r: { category: string }) => r.category === "reliability")).toBe(true); + }); + + it("applies limit and offset", async () => { + const server = makeServer(); + const result = await callTool(server, "list_standards", { limit: 2, offset: 0 }); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + expect(parsed.data).toHaveLength(2); + }); + }); + + describe("get_standard", () => { + it("returns the standard for a valid rule_key", async () => { + const server = makeServer(); + const result = await callTool(server, "get_standard", { rule_key: "SRE-K8S-003" }); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + expect(parsed.rule_key).toBe("SRE-K8S-003"); + }); + + it("returns a tool error for an unknown rule_key", async () => { + const server = makeServer(); + const result = await callTool(server, "get_standard", { rule_key: "MISS-ING-999" }); + const r = result as { isError?: boolean; content: [{ text: string }] }; + expect(r.isError).toBe(true); + const payload = JSON.parse(r.content[0].text); + expect(payload.code).toBe("not_found"); + }); + }); + + describe("latest_standards", () => { + it("returns standards_version and rules", async () => { + const server = makeServer(); + const result = await callTool(server, "latest_standards", {}); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + expect(parsed).toHaveProperty("standards_version"); + expect(Array.isArray(parsed.rules)).toBe(true); + }); + }); + + describe("applicable_standards", () => { + it("maps changed_paths array to service changedPaths and returns matching rules", async () => { + const server = makeServer(); + const result = await callTool(server, "applicable_standards", { + framework: "kubernetes", + runtime: "container", + environment: "production", + changed_paths: ["deploy/deployment.yaml"] + }); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + const k8sRule = parsed.rules.find((r: { rule_key: string }) => r.rule_key === "SRE-K8S-003"); + expect(k8sRule).toBeDefined(); + expect(k8sRule.match_reason).toContain("changed_paths=deploy/deployment.yaml"); + }); + + it("returns an empty rules array when no standards match the filters", async () => { + const server = makeServer(); + const result = await callTool(server, "applicable_standards", { + repo: "nonexistent/repo", + language: "cobol" + }); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + expect(parsed.rules).toHaveLength(0); + }); + + it("returns all active standards when no filters are provided", async () => { + const server = makeServer([ + { ...seedStandards[0], ruleKey: "OPS-GLOBAL-001", title: "Global rule", appliesTo: {}, version: 1 } + ]); + const result = await callTool(server, "applicable_standards", {}); + const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + expect(parsed.rules[0].match_reason).toBe("Matched globally"); + }); + }); + + describe("toolError serialization", () => { + it("includes AppError code and details in tool error text", async () => { + const server = makeServer(); + const result = await callTool(server, "get_standard", { rule_key: "MISS-ING-999" }); + const r = result as { isError?: boolean; content: [{ text: string }] }; + expect(r.isError).toBe(true); + const payload = JSON.parse(r.content[0].text); + expect(payload).toHaveProperty("code"); + expect(payload).toHaveProperty("message"); + }); + }); +}); From c983187c288449a6240c4b06a0afb3aa4cb8e553 Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 13:04:47 -0400 Subject: [PATCH 03/11] fix: address second round of Copilot review comments on MCP server - toolError now includes statusCode alongside code and message so MCP clients can distinguish error types (400/404/409) without mapping codes - changed_paths uses .min(1) to reject empty arrays, aligning with the HTTP API's "omit instead of empty" contract - SIGINT/SIGTERM handlers now catch shutdown errors and exit non-zero instead of silently swallowing Prisma disconnect failures - Refactor tool handlers into createHandlers(service) so tests call them directly without reaching into SDK private fields; McpServer and _registeredTools are no longer referenced in tests Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/server.ts | 13 +++- src/mcp/tools.ts | 144 ++++++++++++++++++++++++++++------------- test/mcp-tools.test.ts | 103 +++++++++++++---------------- 3 files changed, 155 insertions(+), 105 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 38e99a2..c655672 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -28,8 +28,17 @@ async function shutdown(): Promise { await prisma.$disconnect(); } -process.on("SIGINT", () => void shutdown().then(() => process.exit(0))); -process.on("SIGTERM", () => void shutdown().then(() => process.exit(0))); +function handleSignal(signal: string): void { + shutdown() + .then(() => process.exit(0)) + .catch((err) => { + console.error(`Shutdown error on ${signal}:`, err); + process.exit(1); + }); +} + +process.on("SIGINT", () => handleSignal("SIGINT")); +process.on("SIGTERM", () => handleSignal("SIGTERM")); main().catch((err) => { console.error("MCP server error:", err); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index bd8ff21..4684521 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -4,12 +4,20 @@ import { AppError } from "../domain/errors.js"; import type { StandardsService } from "../services/standards-service.js"; import { serializeReviewOpsPayload, serializeStandard } from "../http/serializers.js"; import { categories, severities, statuses } from "../domain/standard.js"; +import type { StandardCategory, StandardSeverity, StandardStatus } from "../domain/standard.js"; -const RULE_KEY_REGEX = /^[A-Z0-9]+(?:-[A-Z0-9]+)+-\d{3,}$/; +export const RULE_KEY_REGEX = /^[A-Z0-9]+(?:-[A-Z0-9]+)+-\d{3,}$/; -function toolError(err: unknown): { isError: true; content: [{ type: "text"; text: string }] } { +type ToolResult = { content: [{ type: "text"; text: string }] }; +type ToolError = { isError: true; content: [{ type: "text"; text: string }] }; + +function toolError(err: unknown): ToolError { if (err instanceof AppError) { - const payload: Record = { code: err.code, message: err.message }; + const payload: Record = { + code: err.code, + message: err.message, + statusCode: err.statusCode + }; if (err.details !== undefined) { payload.details = err.details; } @@ -19,11 +27,90 @@ function toolError(err: unknown): { isError: true; content: [{ type: "text"; tex return { isError: true, content: [{ type: "text", text: message }] }; } -function toolResult(data: unknown): { content: [{ type: "text"; text: string }] } { +function toolResult(data: unknown): ToolResult { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; } +export type ListStandardsInput = { + status?: StandardStatus; + category?: StandardCategory; + severity?: StandardSeverity; + owner?: string; + limit?: number; + offset?: number; +}; + +export type GetStandardInput = { rule_key: string }; + +export type ApplicableStandardsInput = { + repo?: string; + team?: string; + language?: string; + framework?: string; + runtime?: string; + environment?: string; + changed_paths?: string[]; +}; + +export function createHandlers(service: StandardsService) { + return { + async listStandards(input: ListStandardsInput): Promise { + try { + const rules = await service.list(input); + return toolResult({ data: rules.map(serializeStandard) }); + } catch (err) { + return toolError(err); + } + }, + + async getStandard({ rule_key }: GetStandardInput): Promise { + try { + const rule = await service.getLatest(rule_key); + return toolResult(serializeStandard(rule)); + } catch (err) { + return toolError(err); + } + }, + + async latestStandards(): Promise { + try { + const payload = await service.latestPayload(); + return toolResult(serializeReviewOpsPayload(payload)); + } catch (err) { + return toolError(err); + } + }, + + async applicableStandards({ + repo, + team, + language, + framework, + runtime, + environment, + changed_paths + }: ApplicableStandardsInput): Promise { + try { + const payload = await service.applicable({ + repo, + team, + language, + framework, + runtime, + environment, + changedPaths: changed_paths + }); + return toolResult(serializeReviewOpsPayload(payload)); + } catch (err) { + return toolError(err); + } + } + }; +} + export function registerTools(server: McpServer, service: StandardsService): void { + const handlers = createHandlers(service); + server.registerTool( "list_standards", { @@ -37,14 +124,7 @@ export function registerTools(server: McpServer, service: StandardsService): voi offset: z.coerce.number().int().min(0).optional() } }, - async (input) => { - try { - const rules = await service.list(input); - return toolResult({ data: rules.map(serializeStandard) }); - } catch (err) { - return toolError(err); - } - } + handlers.listStandards ); server.registerTool( @@ -55,14 +135,7 @@ export function registerTools(server: McpServer, service: StandardsService): voi rule_key: z.string().trim().regex(RULE_KEY_REGEX, "rule_key must match pattern like SRE-K8S-003") } }, - async ({ rule_key }) => { - try { - const rule = await service.getLatest(rule_key); - return toolResult(serializeStandard(rule)); - } catch (err) { - return toolError(err); - } - } + handlers.getStandard ); server.registerTool( @@ -70,20 +143,14 @@ export function registerTools(server: McpServer, service: StandardsService): voi { description: "Returns the latest active standards payload used by review tooling, including a standards_version fingerprint." }, - async () => { - try { - const payload = await service.latestPayload(); - return toolResult(serializeReviewOpsPayload(payload)); - } catch (err) { - return toolError(err); - } - } + handlers.latestStandards ); server.registerTool( "applicable_standards", { - description: "Returns active standards that apply to a given repo, team, language, framework, runtime, environment, or set of changed file paths. All filters are optional.", + description: + "Returns active standards that apply to a given repo, team, language, framework, runtime, environment, or set of changed file paths. All filters are optional.", inputSchema: { repo: z.string().trim().min(1).optional(), team: z.string().trim().min(1).optional(), @@ -91,24 +158,9 @@ export function registerTools(server: McpServer, service: StandardsService): voi framework: z.string().trim().min(1).optional(), runtime: z.string().trim().min(1).optional(), environment: z.string().trim().min(1).optional(), - changed_paths: z.array(z.string().trim().min(1)).optional() + changed_paths: z.array(z.string().trim().min(1)).min(1).optional() } }, - async ({ repo, team, language, framework, runtime, environment, changed_paths }) => { - try { - const payload = await service.applicable({ - repo, - team, - language, - framework, - runtime, - environment, - changedPaths: changed_paths - }); - return toolResult(serializeReviewOpsPayload(payload)); - } catch (err) { - return toolError(err); - } - } + handlers.applicableStandards ); } diff --git a/test/mcp-tools.test.ts b/test/mcp-tools.test.ts index 4388ac1..89aea41 100644 --- a/test/mcp-tools.test.ts +++ b/test/mcp-tools.test.ts @@ -1,121 +1,110 @@ import { describe, expect, it } from "vitest"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { MemoryStandardsRepository } from "../src/repositories/memory-standards-repository.js"; import { StandardsService } from "../src/services/standards-service.js"; -import { registerTools } from "../src/mcp/tools.js"; +import { createHandlers } from "../src/mcp/tools.js"; import { seedStandards } from "../src/seed-data.js"; -function makeServer(standards = seedStandards) { +function makeHandlers(standards = seedStandards) { const repo = new MemoryStandardsRepository(standards); const service = new StandardsService(repo); - const server = new McpServer({ name: "test", version: "0.0.0" }); - registerTools(server, service); - return server; + return createHandlers(service); } -async function callTool(server: McpServer, name: string, args: Record = {}) { - const tools = (server as unknown as { _registeredTools: Record unknown }> })._registeredTools; - const tool = tools[name]; - if (!tool) throw new Error(`Tool ${name} not registered`); - return tool.handler(args); +function parseResult(result: { content: [{ text: string }] }) { + return JSON.parse(result.content[0].text); } -describe("MCP tools", () => { - describe("list_standards", () => { +describe("MCP tool handlers", () => { + describe("listStandards", () => { it("returns all active standards by default", async () => { - const server = makeServer(); - const result = await callTool(server, "list_standards", {}); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + const { listStandards } = makeHandlers(); + const parsed = parseResult(await listStandards({ status: "active" })); expect(parsed.data).toHaveLength(seedStandards.length); expect(parsed.data[0]).toHaveProperty("rule_key"); }); it("filters by category", async () => { - const server = makeServer(); - const result = await callTool(server, "list_standards", { category: "reliability" }); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + const { listStandards } = makeHandlers(); + const parsed = parseResult(await listStandards({ category: "reliability" })); expect(parsed.data.every((r: { category: string }) => r.category === "reliability")).toBe(true); }); it("applies limit and offset", async () => { - const server = makeServer(); - const result = await callTool(server, "list_standards", { limit: 2, offset: 0 }); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + const { listStandards } = makeHandlers(); + const parsed = parseResult(await listStandards({ limit: 2, offset: 0 })); expect(parsed.data).toHaveLength(2); }); }); - describe("get_standard", () => { - it("returns the standard for a valid rule_key", async () => { - const server = makeServer(); - const result = await callTool(server, "get_standard", { rule_key: "SRE-K8S-003" }); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + describe("getStandard", () => { + it("returns the standard for a known rule_key", async () => { + const { getStandard } = makeHandlers(); + const parsed = parseResult(await getStandard({ rule_key: "SRE-K8S-003" })); expect(parsed.rule_key).toBe("SRE-K8S-003"); }); - it("returns a tool error for an unknown rule_key", async () => { - const server = makeServer(); - const result = await callTool(server, "get_standard", { rule_key: "MISS-ING-999" }); + it("returns a structured tool error for an unknown rule_key", async () => { + const { getStandard } = makeHandlers(); + const result = await getStandard({ rule_key: "MISS-ING-999" }); const r = result as { isError?: boolean; content: [{ text: string }] }; expect(r.isError).toBe(true); const payload = JSON.parse(r.content[0].text); expect(payload.code).toBe("not_found"); + expect(payload.statusCode).toBe(404); + expect(payload.message).toContain("MISS-ING-999"); }); }); - describe("latest_standards", () => { + describe("latestStandards", () => { it("returns standards_version and rules", async () => { - const server = makeServer(); - const result = await callTool(server, "latest_standards", {}); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + const { latestStandards } = makeHandlers(); + const parsed = parseResult(await latestStandards()); expect(parsed).toHaveProperty("standards_version"); expect(Array.isArray(parsed.rules)).toBe(true); + expect(parsed.rules).toHaveLength(seedStandards.length); }); }); - describe("applicable_standards", () => { + describe("applicableStandards", () => { it("maps changed_paths array to service changedPaths and returns matching rules", async () => { - const server = makeServer(); - const result = await callTool(server, "applicable_standards", { - framework: "kubernetes", - runtime: "container", - environment: "production", - changed_paths: ["deploy/deployment.yaml"] - }); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + const { applicableStandards } = makeHandlers(); + const parsed = parseResult( + await applicableStandards({ + framework: "kubernetes", + runtime: "container", + environment: "production", + changed_paths: ["deploy/deployment.yaml"] + }) + ); const k8sRule = parsed.rules.find((r: { rule_key: string }) => r.rule_key === "SRE-K8S-003"); expect(k8sRule).toBeDefined(); expect(k8sRule.match_reason).toContain("changed_paths=deploy/deployment.yaml"); }); - it("returns an empty rules array when no standards match the filters", async () => { - const server = makeServer(); - const result = await callTool(server, "applicable_standards", { - repo: "nonexistent/repo", - language: "cobol" - }); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + it("returns empty rules when no standards match the filters", async () => { + const { applicableStandards } = makeHandlers(); + const parsed = parseResult(await applicableStandards({ repo: "nonexistent/repo", language: "cobol" })); expect(parsed.rules).toHaveLength(0); }); - it("returns all active standards when no filters are provided", async () => { - const server = makeServer([ + it("returns globally-applicable rules when no filters are provided", async () => { + const { applicableStandards } = makeHandlers([ { ...seedStandards[0], ruleKey: "OPS-GLOBAL-001", title: "Global rule", appliesTo: {}, version: 1 } ]); - const result = await callTool(server, "applicable_standards", {}); - const parsed = JSON.parse((result as { content: [{ text: string }] }).content[0].text); + const parsed = parseResult(await applicableStandards({})); expect(parsed.rules[0].match_reason).toBe("Matched globally"); }); }); describe("toolError serialization", () => { - it("includes AppError code and details in tool error text", async () => { - const server = makeServer(); - const result = await callTool(server, "get_standard", { rule_key: "MISS-ING-999" }); + it("includes code, statusCode, and message from AppError", async () => { + const { getStandard } = makeHandlers(); + const result = await getStandard({ rule_key: "MISS-ING-999" }); const r = result as { isError?: boolean; content: [{ text: string }] }; expect(r.isError).toBe(true); const payload = JSON.parse(r.content[0].text); expect(payload).toHaveProperty("code"); + expect(payload).toHaveProperty("statusCode"); expect(payload).toHaveProperty("message"); }); }); From 2b7890f4b6e2d2ef598f33455b17e4e582ee140b Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 13:11:32 -0400 Subject: [PATCH 04/11] fix: address third round of Copilot review comments on MCP tools - Non-AppError exceptions now return a structured JSON internal_error payload and log to stderr, preventing internal detail leakage - listStandards defaults status to "active" in the handler itself so the service receives an explicit filter and applies latest-by-ruleKey deduplication correctly when called with no arguments - applicableStandards defaults its argument to {} so invoking the handler with no args does not throw on destructuring - Test updated to call listStandards({}) and assert all returned rules are active, actually exercising the default-status contract Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/tools.ts | 10 +++++----- test/mcp-tools.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4684521..5741216 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -23,8 +23,8 @@ function toolError(err: unknown): ToolError { } return { isError: true, content: [{ type: "text", text: JSON.stringify(payload) }] }; } - const message = err instanceof Error ? err.message : String(err); - return { isError: true, content: [{ type: "text", text: message }] }; + console.error("Unexpected MCP tool error:", err); + return { isError: true, content: [{ type: "text", text: JSON.stringify({ code: "internal_error", message: "An unexpected error occurred" }) }] }; } function toolResult(data: unknown): ToolResult { @@ -54,9 +54,9 @@ export type ApplicableStandardsInput = { export function createHandlers(service: StandardsService) { return { - async listStandards(input: ListStandardsInput): Promise { + async listStandards({ status = "active", ...rest }: ListStandardsInput = {}): Promise { try { - const rules = await service.list(input); + const rules = await service.list({ status, ...rest }); return toolResult({ data: rules.map(serializeStandard) }); } catch (err) { return toolError(err); @@ -89,7 +89,7 @@ export function createHandlers(service: StandardsService) { runtime, environment, changed_paths - }: ApplicableStandardsInput): Promise { + }: ApplicableStandardsInput = {}): Promise { try { const payload = await service.applicable({ repo, diff --git a/test/mcp-tools.test.ts b/test/mcp-tools.test.ts index 89aea41..b7432fd 100644 --- a/test/mcp-tools.test.ts +++ b/test/mcp-tools.test.ts @@ -16,11 +16,11 @@ function parseResult(result: { content: [{ text: string }] }) { describe("MCP tool handlers", () => { describe("listStandards", () => { - it("returns all active standards by default", async () => { + it("defaults to active status when called with no arguments", async () => { const { listStandards } = makeHandlers(); - const parsed = parseResult(await listStandards({ status: "active" })); + const parsed = parseResult(await listStandards({})); expect(parsed.data).toHaveLength(seedStandards.length); - expect(parsed.data[0]).toHaveProperty("rule_key"); + expect(parsed.data.every((r: { status: string }) => r.status === "active")).toBe(true); }); it("filters by category", async () => { From 891e7c2b41b683aaf78af01ff3508b6a396d66f6 Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 13:24:57 -0400 Subject: [PATCH 05/11] feat: add Streamable HTTP MCP server entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/mcp/http-server.ts — a second MCP entrypoint using StreamableHTTPServerTransport that exposes the same four tools and two resources as the existing stdio server, without touching either the stdio server or the Fastify HTTP API. Key details: - Uses Node's built-in node:http (no new framework dependency) - Mounts POST/GET/DELETE /mcp per the Streamable HTTP spec - Hard auth guard via x-api-key header (MCP_API_KEY env var required; unset → all requests rejected 401) - Per-session McpServer + transport stored in a Map, cleaned up on close - isMain guard prevents server startup when the file is imported in tests - New scripts: mcp:http:dev (tsx) and mcp:http:start (compiled output) - README documents env vars, run commands, and client config block - 6 unit tests for the isAuthorized helper Co-Authored-By: Claude Sonnet 4.6 --- README.md | 54 +++++++++ package.json | 2 + src/mcp/http-server.ts | 219 +++++++++++++++++++++++++++++++++++ test/mcp-http-server.test.ts | 29 +++++ 4 files changed, 304 insertions(+) create mode 100644 src/mcp/http-server.ts create mode 100644 test/mcp-http-server.test.ts diff --git a/README.md b/README.md index 54787a5..c9d2b7c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ npm run lint # strict TypeScript check npm test # run tests npm run mcp:dev # start MCP stdio server (tsx, no build required) npm run mcp:start # start compiled MCP stdio server +npm run mcp:http:dev # start MCP Streamable HTTP server (tsx, no build required) +npm run mcp:http:start # start compiled MCP Streamable HTTP server npm run prisma:migrate # create/apply local migration npm run prisma:deploy # apply migrations in deployed environments npm run prisma:seed # load example standards @@ -116,6 +118,58 @@ Add the following to your MCP client configuration (for example, `claude_desktop | `standards://latest` | Latest active standards payload as JSON | | `standards://rule/{rule_key}` | A single standard by rule key | +## MCP Streamable HTTP Server + +The Streamable HTTP server exposes the same tools and resources as the stdio server over the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). Remote MCP clients (such as Claude.ai) can connect to it over HTTPS via a reverse proxy. + +### Environment variables + +| Variable | Default | Description | +| --- | --- | --- | +| `MCP_API_KEY` | required | API key sent in `x-api-key` on every request. Unset = all requests rejected. | +| `MCP_HTTP_PORT` | `3001` | Port for the Streamable HTTP MCP server (avoids collision with Fastify on `3000`). | + +### Running locally + +```bash +# Development (no build step required) +DATABASE_URL=postgresql://user:password@localhost:5432/standards \ + MCP_API_KEY=secret \ + npm run mcp:http:dev + +# Production (build first) +npm run build +DATABASE_URL=postgresql://user:password@localhost:5432/standards \ + MCP_API_KEY=secret \ + npm run mcp:http:start +``` + +Requests without a valid `x-api-key` header return `401`. Deploy behind a TLS-terminating reverse proxy (nginx, Caddy, AWS ALB, etc.) before exposing to the public internet. + +### Client configuration + +Add the following to your MCP client configuration to connect to a remotely hosted instance: + +```json +{ + "mcpServers": { + "standards-api-remote": { + "type": "http", + "url": "https://your-host/mcp", + "headers": { + "x-api-key": "your-secret-key" + } + } + } +} +``` + +Replace `https://your-host/mcp` with the public URL of your reverse proxy. + +### Tools and resources + +The Streamable HTTP server exposes the same four tools and two resources as the stdio server. See the [MCP Server](#mcp-server) section above for the full list. + ## Database The service creates one table: diff --git a/package.json b/package.json index ef32355..35eca21 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "start": "node dist/server.js", "mcp:dev": "tsx src/mcp/server.ts", "mcp:start": "node dist/src/mcp/server.js", + "mcp:http:dev": "tsx src/mcp/http-server.ts", + "mcp:http:start": "node dist/src/mcp/http-server.js", "test": "vitest run", "lint": "tsc -p tsconfig.json --noEmit", "prisma:generate": "prisma generate", diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts new file mode 100644 index 0000000..79b7705 --- /dev/null +++ b/src/mcp/http-server.ts @@ -0,0 +1,219 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { PrismaClient } from "@prisma/client"; +import { PrismaStandardsRepository } from "../repositories/prisma-standards-repository.js"; +import { StandardsService } from "../services/standards-service.js"; +import { registerTools } from "./tools.js"; +import { registerResources } from "./resources.js"; + +type Session = { server: McpServer; transport: StreamableHTTPServerTransport }; +const sessions = new Map(); + +/** + * Returns true if the request carries a valid API key. + * Exported for unit testing. + */ +export function isAuthorized( + headerValue: string | string[] | undefined, + envKey: string | undefined +): boolean { + if (!envKey) return false; + const key = Array.isArray(headerValue) ? headerValue[0] : headerValue; + return key === envKey; +} + +function rejectUnauthorized(res: ServerResponse): void { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing or invalid API key" })); +} + +function rejectBadRequest(res: ServerResponse, message: string): void { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32000, message }, + id: null + }) + ); +} + +function serverError(res: ServerResponse): void { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null + }) + ); + } +} + +async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk: Buffer) => { + raw += chunk.toString(); + }); + req.on("end", () => { + try { + resolve(raw.length > 0 ? (JSON.parse(raw) as unknown) : undefined); + } catch (e) { + reject(e); + } + }); + req.on("error", reject); + }); +} + +function createMcpServer(service: StandardsService): McpServer { + const server = new McpServer({ name: "standards-api", version: "1.0.0" }); + registerTools(server, service); + registerResources(server, service); + return server; +} + +async function handlePost( + req: IncomingMessage, + res: ServerResponse, + service: StandardsService +): Promise { + try { + const body = await readBody(req); + const sessionId = req.headers["mcp-session-id"]; + const existingId = Array.isArray(sessionId) ? sessionId[0] : sessionId; + + if (existingId && sessions.has(existingId)) { + const { transport } = sessions.get(existingId)!; + await transport.handleRequest(req, res, body); + return; + } + + if (!existingId && isInitializeRequest(body)) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, { server: mcpServer, transport }); + } + }); + + const mcpServer = createMcpServer(service); + + transport.onclose = () => { + const id = transport.sessionId; + if (id) sessions.delete(id); + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, body); + return; + } + + rejectBadRequest(res, "No valid session ID provided"); + } catch (err) { + console.error("Error handling POST /mcp:", err); + serverError(res); + } +} + +async function handleGet(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers["mcp-session-id"]; + const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; + + if (!id || !sessions.has(id)) { + rejectBadRequest(res, "Invalid or missing session ID"); + return; + } + + try { + const { transport } = sessions.get(id)!; + await transport.handleRequest(req, res); + } catch (err) { + console.error("Error handling GET /mcp:", err); + serverError(res); + } +} + +async function handleDelete(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers["mcp-session-id"]; + const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; + + if (!id || !sessions.has(id)) { + rejectBadRequest(res, "Invalid or missing session ID"); + return; + } + + try { + const { transport } = sessions.get(id)!; + await transport.handleRequest(req, res); + } catch (err) { + console.error("Error handling DELETE /mcp:", err); + serverError(res); + } +} + +const isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (isMain) { + const MCP_HTTP_PORT = parseInt(process.env.MCP_HTTP_PORT ?? "3001", 10); + + const prisma = new PrismaClient(); + const repository = new PrismaStandardsRepository(prisma); + const service = new StandardsService(repository); + + const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const url = req.url ?? ""; + const method = req.method ?? ""; + + if (url !== "/mcp") { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + return; + } + + if (!isAuthorized(req.headers["x-api-key"], process.env.MCP_API_KEY)) { + rejectUnauthorized(res); + return; + } + + if (method === "POST") { + await handlePost(req, res, service); + } else if (method === "GET") { + await handleGet(req, res); + } else if (method === "DELETE") { + await handleDelete(req, res); + } else { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + } + }); + + async function shutdown(): Promise { + await new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve())); + }); + await prisma.$disconnect(); + } + + function handleSignal(signal: string): void { + shutdown() + .then(() => process.exit(0)) + .catch((err) => { + console.error(`Shutdown error on ${signal}:`, err); + process.exit(1); + }); + } + + process.on("SIGINT", () => handleSignal("SIGINT")); + process.on("SIGTERM", () => handleSignal("SIGTERM")); + + httpServer.listen(MCP_HTTP_PORT, () => { + console.log(`MCP Streamable HTTP server listening on port ${MCP_HTTP_PORT}`); + }); +} diff --git a/test/mcp-http-server.test.ts b/test/mcp-http-server.test.ts new file mode 100644 index 0000000..6844098 --- /dev/null +++ b/test/mcp-http-server.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { isAuthorized } from "../src/mcp/http-server.js"; + +describe("isAuthorized", () => { + it("returns false when MCP_API_KEY is undefined", () => { + expect(isAuthorized("any-key", undefined)).toBe(false); + }); + + it("returns false when MCP_API_KEY is empty string", () => { + expect(isAuthorized("any-key", "")).toBe(false); + }); + + it("returns false when header is missing", () => { + expect(isAuthorized(undefined, "secret")).toBe(false); + }); + + it("returns false when header does not match the env key", () => { + expect(isAuthorized("wrong-key", "secret")).toBe(false); + }); + + it("returns true when header matches the env key", () => { + expect(isAuthorized("secret", "secret")).toBe(true); + }); + + it("uses the first value when header is an array", () => { + expect(isAuthorized(["secret", "other"], "secret")).toBe(true); + expect(isAuthorized(["other", "secret"], "secret")).toBe(false); + }); +}); From f33f1861dc7bfb830480a3fe783db80e96ac35ee Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 13:30:54 -0400 Subject: [PATCH 06/11] fix: address Copilot review comments on HTTP MCP server - Enforce 1 MiB body size cap in readBody; stream is destroyed on overflow (BodyTooLargeError) to avoid unbounded memory use - Return 413 for oversized bodies and 400 for invalid JSON so clients get actionable errors instead of a 500 - Close McpServer in transport.onclose to prevent listener/resource leaks when sessions disconnect - Call res.end() in serverError even when headers are already sent so SSE streams don't hang after a mid-stream error Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/http-server.ts | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts index 79b7705..1ee8e47 100644 --- a/src/mcp/http-server.ts +++ b/src/mcp/http-server.ts @@ -52,20 +52,40 @@ function serverError(res: ServerResponse): void { id: null }) ); + } else { + // Headers already sent (e.g. mid-SSE stream) — terminate the response so + // the connection doesn't hang. + res.end(); } } +const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB + +class BodyTooLargeError extends Error {} +class BadJsonError extends Error {} + async function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let raw = ""; + let bytesRead = 0; req.on("data", (chunk: Buffer) => { + bytesRead += chunk.byteLength; + if (bytesRead > MAX_BODY_BYTES) { + reject(new BodyTooLargeError("Request body exceeds maximum allowed size")); + req.destroy(); + return; + } raw += chunk.toString(); }); req.on("end", () => { + if (raw.length === 0) { + resolve(undefined); + return; + } try { - resolve(raw.length > 0 ? (JSON.parse(raw) as unknown) : undefined); - } catch (e) { - reject(e); + resolve(JSON.parse(raw) as unknown); + } catch { + reject(new BadJsonError("Request body is not valid JSON")); } }); req.on("error", reject); @@ -84,8 +104,21 @@ async function handlePost( res: ServerResponse, service: StandardsService ): Promise { + let body: unknown; + try { + body = await readBody(req); + } catch (err) { + if (err instanceof BodyTooLargeError) { + res.writeHead(413, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Request body too large" })); + } else { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Request body is not valid JSON" })); + } + return; + } + try { - const body = await readBody(req); const sessionId = req.headers["mcp-session-id"]; const existingId = Array.isArray(sessionId) ? sessionId[0] : sessionId; @@ -108,6 +141,7 @@ async function handlePost( transport.onclose = () => { const id = transport.sessionId; if (id) sessions.delete(id); + mcpServer.close().catch((err) => console.error("Error closing McpServer:", err)); }; await mcpServer.connect(transport); From 776ca17b67d5963009c68929a0eeb2f177bcc878 Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 14:14:49 -0400 Subject: [PATCH 07/11] fix: address second round of Copilot review comments on HTTP MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use JSON-RPC code -32600 (Invalid Request) in rejectBadRequest instead of -32000 (server error range) — helps clients classify client errors correctly - Distinguish BadJsonError from I/O stream errors in handlePost body catch; unexpected stream errors now return 500 rather than 400 - Normalize process.argv[1] with path.resolve() before comparing to import.meta.url so the isMain guard works when npm passes a relative path (e.g. tsx src/mcp/http-server.ts from project root) - Extract session management into exported createMcpHttpHandler factory so tests can inject a memory-backed service and inspect the sessions map without a real database - Add 6 integration tests covering: 400 for non-init POST, GET, and DELETE without session IDs; session created and stored on initialize; subsequent POST routed to transport; 400 for unknown session ID on GET Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/http-server.ts | 212 +++++++++++++++++++---------------- test/mcp-http-server.test.ts | 143 ++++++++++++++++++++++- 2 files changed, 259 insertions(+), 96 deletions(-) diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts index 1ee8e47..60f7dcd 100644 --- a/src/mcp/http-server.ts +++ b/src/mcp/http-server.ts @@ -1,5 +1,6 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -10,8 +11,8 @@ import { StandardsService } from "../services/standards-service.js"; import { registerTools } from "./tools.js"; import { registerResources } from "./resources.js"; -type Session = { server: McpServer; transport: StreamableHTTPServerTransport }; -const sessions = new Map(); +export type Session = { server: McpServer; transport: StreamableHTTPServerTransport }; +export type SessionMap = Map; /** * Returns true if the request carries a valid API key. @@ -36,7 +37,8 @@ function rejectBadRequest(res: ServerResponse, message: string): void { res.end( JSON.stringify({ jsonrpc: "2.0", - error: { code: -32000, message }, + // -32600 = Invalid Request per JSON-RPC 2.0 spec + error: { code: -32600, message }, id: null }) ); @@ -92,116 +94,117 @@ async function readBody(req: IncomingMessage): Promise { }); } -function createMcpServer(service: StandardsService): McpServer { +function buildMcpServer(service: StandardsService): McpServer { const server = new McpServer({ name: "standards-api", version: "1.0.0" }); registerTools(server, service); registerResources(server, service); return server; } -async function handlePost( - req: IncomingMessage, - res: ServerResponse, - service: StandardsService -): Promise { - let body: unknown; - try { - body = await readBody(req); - } catch (err) { - if (err instanceof BodyTooLargeError) { - res.writeHead(413, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Request body too large" })); - } else { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Request body is not valid JSON" })); - } - return; - } - - try { - const sessionId = req.headers["mcp-session-id"]; - const existingId = Array.isArray(sessionId) ? sessionId[0] : sessionId; - - if (existingId && sessions.has(existingId)) { - const { transport } = sessions.get(existingId)!; - await transport.handleRequest(req, res, body); +/** + * Creates a request handler for the MCP Streamable HTTP transport along with + * the session map it manages. Exported so tests can inject a memory-backed + * service and inspect sessions directly. + */ +export function createMcpHttpHandler(service: StandardsService): { + handler: (req: IncomingMessage, res: ServerResponse) => Promise; + sessions: SessionMap; +} { + const sessions: SessionMap = new Map(); + + async function handlePost(req: IncomingMessage, res: ServerResponse): Promise { + let body: unknown; + try { + body = await readBody(req); + } catch (err) { + if (err instanceof BodyTooLargeError) { + res.writeHead(413, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Request body too large" })); + } else if (err instanceof BadJsonError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Request body is not valid JSON" })); + } else { + console.error("Error reading request body:", err); + serverError(res); + } return; } - if (!existingId && isInitializeRequest(body)) { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id) => { - sessions.set(id, { server: mcpServer, transport }); - } - }); + try { + const sessionId = req.headers["mcp-session-id"]; + const existingId = Array.isArray(sessionId) ? sessionId[0] : sessionId; - const mcpServer = createMcpServer(service); + if (existingId && sessions.has(existingId)) { + const { transport } = sessions.get(existingId)!; + await transport.handleRequest(req, res, body); + return; + } - transport.onclose = () => { - const id = transport.sessionId; - if (id) sessions.delete(id); - mcpServer.close().catch((err) => console.error("Error closing McpServer:", err)); - }; + if (!existingId && isInitializeRequest(body)) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, { server: mcpServer, transport }); + } + }); - await mcpServer.connect(transport); - await transport.handleRequest(req, res, body); - return; - } + const mcpServer = buildMcpServer(service); - rejectBadRequest(res, "No valid session ID provided"); - } catch (err) { - console.error("Error handling POST /mcp:", err); - serverError(res); - } -} + transport.onclose = () => { + const id = transport.sessionId; + if (id) sessions.delete(id); + mcpServer.close().catch((err) => console.error("Error closing McpServer:", err)); + }; -async function handleGet(req: IncomingMessage, res: ServerResponse): Promise { - const sessionId = req.headers["mcp-session-id"]; - const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; - - if (!id || !sessions.has(id)) { - rejectBadRequest(res, "Invalid or missing session ID"); - return; - } + await mcpServer.connect(transport); + await transport.handleRequest(req, res, body); + return; + } - try { - const { transport } = sessions.get(id)!; - await transport.handleRequest(req, res); - } catch (err) { - console.error("Error handling GET /mcp:", err); - serverError(res); + rejectBadRequest(res, "No valid session ID provided"); + } catch (err) { + console.error("Error handling POST /mcp:", err); + serverError(res); + } } -} -async function handleDelete(req: IncomingMessage, res: ServerResponse): Promise { - const sessionId = req.headers["mcp-session-id"]; - const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; + async function handleGet(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers["mcp-session-id"]; + const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; - if (!id || !sessions.has(id)) { - rejectBadRequest(res, "Invalid or missing session ID"); - return; - } + if (!id || !sessions.has(id)) { + rejectBadRequest(res, "Invalid or missing session ID"); + return; + } - try { - const { transport } = sessions.get(id)!; - await transport.handleRequest(req, res); - } catch (err) { - console.error("Error handling DELETE /mcp:", err); - serverError(res); + try { + const { transport } = sessions.get(id)!; + await transport.handleRequest(req, res); + } catch (err) { + console.error("Error handling GET /mcp:", err); + serverError(res); + } } -} -const isMain = process.argv[1] === fileURLToPath(import.meta.url); + async function handleDelete(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers["mcp-session-id"]; + const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; -if (isMain) { - const MCP_HTTP_PORT = parseInt(process.env.MCP_HTTP_PORT ?? "3001", 10); + if (!id || !sessions.has(id)) { + rejectBadRequest(res, "Invalid or missing session ID"); + return; + } - const prisma = new PrismaClient(); - const repository = new PrismaStandardsRepository(prisma); - const service = new StandardsService(repository); + try { + const { transport } = sessions.get(id)!; + await transport.handleRequest(req, res); + } catch (err) { + console.error("Error handling DELETE /mcp:", err); + serverError(res); + } + } - const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + async function handler(req: IncomingMessage, res: ServerResponse): Promise { const url = req.url ?? ""; const method = req.method ?? ""; @@ -211,13 +214,8 @@ if (isMain) { return; } - if (!isAuthorized(req.headers["x-api-key"], process.env.MCP_API_KEY)) { - rejectUnauthorized(res); - return; - } - if (method === "POST") { - await handlePost(req, res, service); + await handlePost(req, res); } else if (method === "GET") { await handleGet(req, res); } else if (method === "DELETE") { @@ -226,6 +224,30 @@ if (isMain) { res.writeHead(405, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Method not allowed" })); } + } + + return { handler, sessions }; +} + +// Normalize argv[1] to an absolute path so the comparison works whether npm +// passes a relative path (e.g. "src/mcp/http-server.ts") or an absolute one. +const isMain = resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url); + +if (isMain) { + const MCP_HTTP_PORT = parseInt(process.env.MCP_HTTP_PORT ?? "3001", 10); + + const prisma = new PrismaClient(); + const repository = new PrismaStandardsRepository(prisma); + const service = new StandardsService(repository); + + const { handler } = createMcpHttpHandler(service); + + const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (!isAuthorized(req.headers["x-api-key"], process.env.MCP_API_KEY)) { + rejectUnauthorized(res); + return; + } + await handler(req, res); }); async function shutdown(): Promise { diff --git a/test/mcp-http-server.test.ts b/test/mcp-http-server.test.ts index 6844098..34f04f2 100644 --- a/test/mcp-http-server.test.ts +++ b/test/mcp-http-server.test.ts @@ -1,5 +1,10 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { describe, expect, it } from "vitest"; -import { isAuthorized } from "../src/mcp/http-server.js"; +import { isAuthorized, createMcpHttpHandler } from "../src/mcp/http-server.js"; +import { MemoryStandardsRepository } from "../src/repositories/memory-standards-repository.js"; +import { StandardsService } from "../src/services/standards-service.js"; +import { seedStandards } from "../src/seed-data.js"; describe("isAuthorized", () => { it("returns false when MCP_API_KEY is undefined", () => { @@ -27,3 +32,139 @@ describe("isAuthorized", () => { expect(isAuthorized(["other", "secret"], "secret")).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Session lifecycle integration tests +// --------------------------------------------------------------------------- + +const INIT_BODY = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" } + } +}; + +// Streamable HTTP requires both Content-Type and Accept headers on POST requests. +const MCP_POST_HEADERS = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" +}; + +function makeTestServer() { + const repo = new MemoryStandardsRepository(seedStandards); + const service = new StandardsService(repo); + const { handler, sessions } = createMcpHttpHandler(service); + + const server = createServer(async (req, res) => { + await handler(req, res); + }); + + return new Promise<{ + base: string; + sessions: ReturnType["sessions"]; + close: () => Promise; + }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + base: `http://127.0.0.1:${port}`, + sessions, + close: () => new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))) + }); + }); + }); +} + +describe("MCP HTTP session lifecycle", () => { + it("returns 400 for POST without session ID and non-initialize body", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: MCP_POST_HEADERS, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: 1 }) + }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); + + it("returns 400 for GET without a session ID", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { method: "GET" }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); + + it("returns 400 for DELETE without a session ID", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { method: "DELETE" }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); + + it("creates a session on initialize and stores it in the sessions map", async () => { + const { base, sessions, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: MCP_POST_HEADERS, + body: JSON.stringify(INIT_BODY) + }); + expect(res.status).toBe(200); + const sessionId = res.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + expect(sessions.has(sessionId!)).toBe(true); + } finally { + await close(); + } + }); + + it("routes a subsequent POST with a valid session ID to the transport", async () => { + const { base, close } = await makeTestServer(); + try { + // Initialize to get a session + const initRes = await fetch(`${base}/mcp`, { + method: "POST", + headers: MCP_POST_HEADERS, + body: JSON.stringify(INIT_BODY) + }); + const sessionId = initRes.headers.get("mcp-session-id")!; + + // Send initialized notification (required by MCP before issuing requests) + const notifyRes = await fetch(`${base}/mcp`, { + method: "POST", + headers: { ...MCP_POST_HEADERS, "mcp-session-id": sessionId }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) + }); + // Notification is fire-and-forget — transport returns 202 + expect([200, 202]).toContain(notifyRes.status); + } finally { + await close(); + } + }); + + it("returns 400 for GET with an unknown session ID", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { + method: "GET", + headers: { "mcp-session-id": "nonexistent-session-id" } + }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); +}); From e911f6fcf9a9a40f8ce764696a0f75e981ace766 Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 14:18:01 -0400 Subject: [PATCH 08/11] fix: address third round of Copilot review comments on HTTP MCP server - Reject empty POST bodies with 400 before session/transport routing to avoid forwarding undefined to transport.handleRequest, which would throw internally and produce a misleading 500 - Fix shutdown hang with open SSE sessions: close all McpServers in the session map, clear the map, then call httpServer.closeAllConnections() before awaiting httpServer.close() so the process exits promptly Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/http-server.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts index 60f7dcd..976fbe9 100644 --- a/src/mcp/http-server.ts +++ b/src/mcp/http-server.ts @@ -130,6 +130,11 @@ export function createMcpHttpHandler(service: StandardsService): { return; } + if (body === undefined) { + rejectBadRequest(res, "POST body must not be empty"); + return; + } + try { const sessionId = req.headers["mcp-session-id"]; const existingId = Array.isArray(sessionId) ? sessionId[0] : sessionId; @@ -240,7 +245,7 @@ if (isMain) { const repository = new PrismaStandardsRepository(prisma); const service = new StandardsService(repository); - const { handler } = createMcpHttpHandler(service); + const { handler, sessions } = createMcpHttpHandler(service); const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (!isAuthorized(req.headers["x-api-key"], process.env.MCP_API_KEY)) { @@ -251,6 +256,15 @@ if (isMain) { }); async function shutdown(): Promise { + // Close every active McpServer so SSE streams are terminated and clients + // receive a clean disconnect rather than a socket hang. + await Promise.allSettled( + [...sessions.values()].map(({ server }) => server.close()) + ); + sessions.clear(); + // Force-close any remaining open connections (e.g. lingering SSE streams) + // so httpServer.close() resolves promptly instead of hanging until idle. + httpServer.closeAllConnections(); await new Promise((resolve, reject) => { httpServer.close((err) => (err ? reject(err) : resolve())); }); From e76a09d72efab9b62adf467cbcaccd56318f5453 Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 14:22:04 -0400 Subject: [PATCH 09/11] chore: disable copilot-review workflow (sample only) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/copilot-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/copilot-review.yml b/.github/workflows/copilot-review.yml index 2dd7283..db4a593 100644 --- a/.github/workflows/copilot-review.yml +++ b/.github/workflows/copilot-review.yml @@ -7,6 +7,7 @@ on: jobs: copilot-review: name: Fetch Standards & Request Copilot Review + if: false # sample workflow — disabled runs-on: self-hosted # Must have network access to your K8s cluster permissions: contents: read From 1f45eee739cb38f8f12e58f0812c545a56f3b81f Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 14:25:22 -0400 Subject: [PATCH 10/11] fix: address fourth round of Copilot review comments on HTTP MCP server - Return JSON-RPC shaped error bodies for oversized (-32600) and unparseable (-32700 Parse error) POST bodies so MCP/JSON-RPC clients can classify these failures correctly rather than receiving plain { error: '...' } objects - Parse req.url with URL() and compare .pathname instead of the raw string so requests with query strings (e.g. /mcp?foo=bar) are routed correctly instead of getting a spurious 404 Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/http-server.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts index 976fbe9..d594e96 100644 --- a/src/mcp/http-server.ts +++ b/src/mcp/http-server.ts @@ -119,10 +119,23 @@ export function createMcpHttpHandler(service: StandardsService): { } catch (err) { if (err instanceof BodyTooLargeError) { res.writeHead(413, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Request body too large" })); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32600, message: "Request body too large" }, + id: null + }) + ); } else if (err instanceof BadJsonError) { res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Request body is not valid JSON" })); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + // -32700 = Parse error per JSON-RPC 2.0 spec + error: { code: -32700, message: "Parse error: request body is not valid JSON" }, + id: null + }) + ); } else { console.error("Error reading request body:", err); serverError(res); @@ -210,10 +223,10 @@ export function createMcpHttpHandler(service: StandardsService): { } async function handler(req: IncomingMessage, res: ServerResponse): Promise { - const url = req.url ?? ""; + const pathname = new URL(req.url ?? "", "http://localhost").pathname; const method = req.method ?? ""; - if (url !== "/mcp") { + if (pathname !== "/mcp") { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); return; From 1209aaca30b72d389a9a78bcac583f25abe2a974 Mon Sep 17 00:00:00 2001 From: Tyler La Fronz Date: Sun, 24 May 2026 14:31:46 -0400 Subject: [PATCH 11/11] fix: catch URL parse errors to prevent DoS via malformed request targets Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/http-server.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts index d594e96..8fa57d9 100644 --- a/src/mcp/http-server.ts +++ b/src/mcp/http-server.ts @@ -223,7 +223,13 @@ export function createMcpHttpHandler(service: StandardsService): { } async function handler(req: IncomingMessage, res: ServerResponse): Promise { - const pathname = new URL(req.url ?? "", "http://localhost").pathname; + let pathname: string; + try { + pathname = new URL(req.url ?? "", "http://localhost").pathname; + } catch { + rejectBadRequest(res, "Malformed request URL"); + return; + } const method = req.method ?? ""; if (pathname !== "/mcp") {