From ffcabaa1f292770717e849cf57c41b6d5117637f Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Fri, 30 Oct 2026 20:23:48 +0530 Subject: [PATCH] feat(mcp-server-kit): serve MCP 2026-07-28 statelessly on SDK 2 Move the kit to the official MCP TypeScript SDK 2 and serve every request through createMcpHandler with a fresh request-local server. Modern clients get protocol 2026-07-28; 2025-era Streamable HTTP clients are served by the SDK's stateless legacy mode, so there are no sessions, no Mcp-Session-Id, and GET/DELETE answer 405. Tool results are no longer reshaped: the invalid { type: "json" } content item is gone (ChatGPT rejected results carrying it and retried, duplicating writes), and tool failures are isError tool results with a platform code instead of JSON-RPC errors with string codes. platform-conformance reads failures from those results. Recorded as ADR 0016. --- .changeset/mcp-2026-07-28.md | 16 + bun.lock | 60 +- .../0016-stateless-mcp-2026-07-28.md | 57 ++ docs/mcp-sources.md | 5 +- .../apps/api/src/app.test.ts | 25 +- packages/mcp-server-kit/README.md | 15 +- packages/mcp-server-kit/package.json | 8 +- .../scripts/packed-consumer.mjs | 19 +- packages/mcp-server-kit/src/cors.ts | 4 +- packages/mcp-server-kit/src/create.test.ts | 572 +++++++++--------- packages/mcp-server-kit/src/create.ts | 565 ++++++----------- packages/mcp-server-kit/src/index.ts | 13 +- packages/mcp-server-kit/src/schema.ts | 23 + packages/mcp-server-kit/src/sessions.test.ts | 19 - packages/mcp-server-kit/src/sessions.ts | 71 --- .../mcp-server-kit/src/streamable-http.ts | 170 +++--- packages/platform-conformance/src/run.test.ts | 14 +- packages/platform-conformance/src/run.ts | 18 +- .../saas-product/apps/api/src/app.test.ts | 25 +- 19 files changed, 764 insertions(+), 935 deletions(-) create mode 100644 .changeset/mcp-2026-07-28.md create mode 100644 docs/decisions/0016-stateless-mcp-2026-07-28.md create mode 100644 packages/mcp-server-kit/src/schema.ts delete mode 100644 packages/mcp-server-kit/src/sessions.test.ts delete mode 100644 packages/mcp-server-kit/src/sessions.ts diff --git a/.changeset/mcp-2026-07-28.md b/.changeset/mcp-2026-07-28.md new file mode 100644 index 0000000..8d65e54 --- /dev/null +++ b/.changeset/mcp-2026-07-28.md @@ -0,0 +1,16 @@ +--- +"@codelitdev/mcp-server-kit": minor +"@codelitdev/platform-conformance": patch +--- + +Serve MCP 2026-07-28. The kit now runs on the official MCP TypeScript SDK 2 (`@modelcontextprotocol/server`) and is stateless: every request is authenticated, resolved to the product context, and handled by a fresh request-local server. + +- Modern clients get the 2026-07-28 protocol: `server/discover`, per-request protocol envelopes, `Mcp-Method`/`Mcp-Name` header validation, `resultType`, and cache hints on `tools/list` and `server/discover` (five minutes, private by default; set `catalogCacheHint` to change it). +- 2025-era Streamable HTTP clients keep working through the SDK's stateless legacy mode. `initialize` no longer mints an `Mcp-Session-Id`; `GET` and `DELETE` answer `405`. +- Tool results carry `structuredContent` plus one `text` copy. The invalid `{ type: "json" }` content item is gone; strict clients such as ChatGPT rejected every result carrying it and retried, duplicating writes. +- Tool failures, including a destructive call without `confirm: true`, are tool results with `isError: true` and `structuredContent: { code, message, details }`, returned with HTTP 200. They were JSON-RPC errors with string codes, which JSON-RPC does not allow. Authentication and context failures before dispatch return the platform error body (`{ code, message }`) with its HTTP status. +- Tool input schemas are advertised as JSON Schema without `$schema`, so clients read them under MCP's default dialect. Annotations follow the spec (`readOnlyHint`, `destructiveHint`); the risk stays in the tool's `_meta.risk`. +- New options: `description`, `instructions`, `catalogCacheHint`. +- Removed exports: `createMcpSessionStore`, `McpSession`, `McpSessionStore`, `StreamableHTTPServerTransport`, `WebStandardStreamableHTTPServerTransport`, and the kit's `sessions` property. + +`platform-conformance` reads MCP tool failures from `isError` tool results. diff --git a/bun.lock b/bun.lock index c4ea6e0..2ad5e8d 100644 --- a/bun.lock +++ b/bun.lock @@ -80,7 +80,7 @@ }, "packages/billing": { "name": "@codelitdev/billing", - "version": "0.4.0", + "version": "0.4.2", "bin": { "codelit-billing": "./bin/codelit-billing.js", }, @@ -106,7 +106,7 @@ }, "packages/design-system": { "name": "@codelitdev/design-system", - "version": "0.4.0", + "version": "0.4.2", "devDependencies": { "@types/react": "^19.2.17", "@types/react-dom": "^19.2.5", @@ -124,24 +124,26 @@ }, "packages/mcp-server-kit": { "name": "@codelitdev/mcp-server-kit", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.2", }, "devDependencies": { "@codelitdev/platform": "workspace:*", + "@modelcontextprotocol/client": "^2.4.0", "@types/bun": "^1.4.1", "@types/node": "^22.14.1", "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/platform": ">=0.1.0-alpha.0", + "@codelitdev/platform": ">=0.4.2", }, }, "packages/oauth-server-kit": { "name": "@codelitdev/oauth-server-kit", - "version": "0.4.0", + "version": "0.4.2", "devDependencies": { "@better-auth/core": "1.7.2", "@better-auth/oauth-provider": "1.7.2", @@ -165,7 +167,7 @@ }, "packages/observability": { "name": "@codelitdev/observability", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { "pino": "^9.6.0", }, @@ -190,7 +192,7 @@ }, "packages/platform": { "name": "@codelitdev/platform", - "version": "0.4.0", + "version": "0.4.2", "devDependencies": { "@codelitdev/billing": "workspace:*", "@electric-sql/pglite": "^0.2.17", @@ -200,7 +202,7 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/billing": "workspace:*", + "@codelitdev/billing": ">=0.4.2", "drizzle-orm": "^0.45.2", }, "optionalPeers": [ @@ -210,7 +212,7 @@ }, "packages/platform-cli": { "name": "@codelitdev/platform-cli", - "version": "0.4.0", + "version": "0.4.2", "bin": { "codelit-platform": "./dist/bin.js", }, @@ -222,7 +224,7 @@ }, "packages/platform-conformance": { "name": "@codelitdev/platform-conformance", - "version": "0.4.0", + "version": "0.4.2", "devDependencies": { "@codelitdev/platform": "workspace:*", "@types/bun": "^1.4.1", @@ -533,8 +535,14 @@ "@manypkg/tools": ["@manypkg/tools@2.1.2", "", { "dependencies": { "jju": "^1.4.0", "tinyglobby": "^0.2.13", "yaml": "^2.9.0" } }, "sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "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" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="], + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], @@ -965,7 +973,7 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -1077,9 +1085,9 @@ "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="], @@ -1461,8 +1469,14 @@ "@manypkg/tools/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "@modelcontextprotocol/client/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@modelcontextprotocol/core/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "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" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "@modelcontextprotocol/server/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@reference-product/api/drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], "@reference-product/web/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], @@ -1483,16 +1497,12 @@ "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "better-auth/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "better-call/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], - "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], "bun-types/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], @@ -1529,6 +1539,8 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1555,8 +1567,6 @@ "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1605,6 +1615,8 @@ "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "@modelcontextprotocol/sdk/express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -1627,8 +1639,6 @@ "@types/serve-static/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1645,8 +1655,6 @@ "tsup/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1657,8 +1665,12 @@ "@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "@modelcontextprotocol/sdk/express/body-parser/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "@modelcontextprotocol/sdk/express/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "@modelcontextprotocol/sdk/express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@modelcontextprotocol/sdk/express/send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/docs/decisions/0016-stateless-mcp-2026-07-28.md b/docs/decisions/0016-stateless-mcp-2026-07-28.md new file mode 100644 index 0000000..6afcda4 --- /dev/null +++ b/docs/decisions/0016-stateless-mcp-2026-07-28.md @@ -0,0 +1,57 @@ +# ADR 0016: Stateless MCP on protocol 2026-07-28 + +Status: accepted +Date: 2026-10-30 + +## Context + +`@codelitdev/mcp-server-kit` was built on the MCP TypeScript SDK 1, which +supports protocol revisions up to `2025-11-25`. It kept an in-memory session +map keyed by `Mcp-Session-Id`, served `GET` and `DELETE` for sessions, and +reshaped SDK results: it prepended a `{ type: "json" }` content item, which +MCP does not define, and turned tool failures into JSON-RPC errors with +string codes, which JSON-RPC does not allow. + +ChatGPT rejected every CourseLit tool result because of the `json` item and +retried the call, so each "failed" `products.create` created a product. + +MCP `2026-07-28` removes protocol sessions and the `initialize` handshake, +adds `server/discover`, requires `Mcp-Method`/`Mcp-Name` headers, `resultType` +on results, and cache hints on list results. SendLit already serves it with +SDK 2, and SendLit, FrontLit, and MediaLit are to move onto this kit. + +## Decision + +- The kit runs on SDK 2 (`@modelcontextprotocol/server`) and serves every + request through `createMcpHandler` with a fresh request-local server. The + SDK implements the protocol; the kit does not translate envelopes or + classify protocol eras itself. +- 2025-era Streamable HTTP clients are served by the SDK's stateless legacy + mode (`legacy: "stateless"`). No `Mcp-Session-Id` is minted, and `GET` and + `DELETE` answer `405`. +- The kit keeps its record-based `handle()` so products route MCP like any + other request. A legacy reply arrives as a one-response SSE stream, and the + kit returns that response as `application/json`, which Streamable HTTP + allows. +- Results are not reshaped. A tool result is `structuredContent` plus one + `text` copy. A tool failure is a tool result with `isError: true` and + `structuredContent: { code, message, details }`, so the model sees the + stable platform code. Failures before dispatch (authentication, context + resolution, shutdown) return the platform error body with its HTTP status. +- Product tool schemas stay Zod 3. The kit converts them to JSON Schema once + per process and advertises them without `$schema`, under MCP's default + dialect. +- `tools/list` and `server/discover` carry a five-minute, private cache hint + by default. + +## Consequences + +- Any API instance can serve any MCP request; there is no session state to + lose on restart or to pin to an instance. +- Clients that relied on `Mcp-Session-Id` or a `GET` stream must work + statelessly. Current clients (Claude, ChatGPT, VS Code, Inspector) do. +- Products that read tool failures from JSON-RPC `error.code` must read + `result.structuredContent.code` when `result.isError` is set. +- The kit no longer exports session or SDK 1 transport helpers. +- Multi round-trip requests, `subscriptions/listen` change notifications, + and tasks are not exposed. Kit tools are request/response only. diff --git a/docs/mcp-sources.md b/docs/mcp-sources.md index bbf5841..2857245 100644 --- a/docs/mcp-sources.md +++ b/docs/mcp-sources.md @@ -1,6 +1,6 @@ # MCP transport source comparison -Status: accepted for Phase 3 extraction. +Status: accepted for Phase 3 extraction. Sessions were removed for MCP 2026-07-28 (ADR 0016). Canonical sources for `@codelitdev/mcp-server-kit`. Products keep tool names, Zod schemas, authorization, and service calls. @@ -8,12 +8,11 @@ names, Zod schemas, authorization, and service calls. | Concern | Canonical source | Origin | Notes | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------- | | Streamable HTTP + request-local server | `frontlit/apps/api/src/mcp/server.ts`, `routes.ts` (`createMcpHandler`, `toNodeHandler`) | FrontLit | One request-local `McpServer`; modern Streamable HTTP. | -| Session map + Accept patching | `medialit/apps/api/src/mcp/routes.ts` (`StreamableHTTPServerTransport`, `Mcp-Session-Id`) | MediaLit | Session store, GET/POST/DELETE, expose `Mcp-Session-Id`. | | CORS + protocol headers | FrontLit `mcpCors` and MediaLit `mcpCors` | FrontLit (header names), MediaLit (session expose) | Allow `Authorization`, API-key header, `MCP-Protocol-Version`. | | OAuth bearer + API-key hooks | FrontLit `mcp/auth-context.ts`, `auth/middleware.ts` (`createAuthMiddleware("mcp")`); SendLit `auth/middleware.ts` MCP mode | FrontLit | MCP is OAuth or API key, never browser sessions. | | Context into tools | FrontLit `createFrontLitMcpAuthInfo` + tool handlers calling application services | FrontLit | Kit carries opaque context; product fills principal/tenant/permissions. | | Error mapping | FrontLit MCP tools + platform `PlatformError` mapping | Platform kernel | Stable codes; no `cause` leakage. | -| Cancellation / shutdown | MediaLit session `Map` teardown; FrontLit process shutdown | MediaLit sessions | Kit owns session delete + abort signal. | +| Cancellation / shutdown | FrontLit process shutdown; SDK 2 `createMcpHandler().close()` | FrontLit | Kit owns the abort signal and shutdown; no session teardown. | | Test harness | FrontLit `mcp/*.test.ts`; MediaLit `__tests__/mcp/*` | FrontLit | Invoke tools with fixture auth without a live MCP client. | | OAuth resource metadata | `@codelitdev/oauth-server-kit/mcp` `createMcpOAuthDiscoveryRoutes` used by FrontLit/SendLit | oauth-server-kit | Remains in oauth-server-kit, not duplicated. | diff --git a/examples/reference-product/apps/api/src/app.test.ts b/examples/reference-product/apps/api/src/app.test.ts index 9afce9b..19e709c 100644 --- a/examples/reference-product/apps/api/src/app.test.ts +++ b/examples/reference-product/apps/api/src/app.test.ts @@ -9,6 +9,14 @@ import { createExpressApp } from "./express-app.js"; import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; import { mintOAuthToken, seedWorld } from "./seed.js"; +/** The platform code of an MCP tool result reported with `isError`. */ +function toolErrorCode(body: unknown): string | undefined { + const result = ( + body as { result?: { isError?: boolean; structuredContent?: { code?: string } } } + ).result; + return result?.isError ? result.structuredContent?.code : undefined; +} + describe.serial("reference API adapters", () => { it("does not run DDL when oauth-server-kit options and schema modules are imported", async () => { const { PGlite } = await import("@electric-sql/pglite"); @@ -332,9 +340,9 @@ describe.serial("reference API adapters", () => { const restItems = (restList.body as { items: Array<{ id: string }> }).items; const mcpJson = ( mcpList.body as { - result: { content: Array<{ json: { items: Array<{ id: string }> } }> }; + result: { structuredContent: { items: Array<{ id: string }> } }; } - ).result.content[0]?.json; + ).result.structuredContent; expect(mcpJson?.items.map((item) => item.id)).toEqual( restItems.map((item) => item.id), ); @@ -352,16 +360,15 @@ describe.serial("reference API adapters", () => { id: 10, method: "initialize", params: { - protocolVersion: "2025-03-26", + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "reference-test", version: "0" }, }, }, }); - expect(initialized.headers?.["Mcp-Session-Id"]).toEqual(expect.any(String)); - expect(initialized.headers?.["Access-Control-Expose-Headers"]).toBe( - "Mcp-Session-Id", - ); + // MCP is stateless: 2025-era clients still initialize, but no session is minted. + expect(initialized.status).toBe(200); + expect(initialized.headers?.["Mcp-Session-Id"]).toBeUndefined(); const cross = await dispatch(runtime, { method: "POST", @@ -380,7 +387,7 @@ describe.serial("reference API adapters", () => { }, }, }); - expect((cross.body as { error: { code: string } }).error.code).toBe("not_found"); + expect(toolErrorCode(cross.body)).toBe("not_found"); const denied = await dispatch(runtime, { method: "POST", @@ -399,7 +406,7 @@ describe.serial("reference API adapters", () => { }, }, }); - expect((denied.body as { error: { code: string } }).error.code).toBe("forbidden"); + expect(toolErrorCode(denied.body)).toBe("forbidden"); const deleted = await dispatch(runtime, { method: "POST", diff --git a/packages/mcp-server-kit/README.md b/packages/mcp-server-kit/README.md index 8c69b84..242ead2 100644 --- a/packages/mcp-server-kit/README.md +++ b/packages/mcp-server-kit/README.md @@ -1,7 +1,14 @@ # @codelitdev/mcp-server-kit -MCP transport helpers for CodeLit products: CORS, sessions, OAuth/API-key -hooks over `@codelitdev/platform` credentials, tool risk metadata, error -mapping, cancellation, shutdown, and parity-manifest validation. +A stateless MCP endpoint for CodeLit products, built on the official MCP +TypeScript SDK 2. It serves protocol `2026-07-28` and, through the SDK's +stateless legacy mode, 2025-era Streamable HTTP clients from one handler. -Products own tool names, Zod schemas, authorization, and service calls. +The kit owns protocol compliance: OAuth/API-key authentication over +`@codelitdev/platform` credentials, CORS, tool annotations and risk metadata, +spec-shaped tool results and errors, cancellation, shutdown, and +parity-manifest validation. Products own tool names, Zod schemas, +authorization, and service calls. + +Every request is authenticated and resolved to a product context before a +fresh, request-local server handles it; there are no MCP sessions. diff --git a/packages/mcp-server-kit/package.json b/packages/mcp-server-kit/package.json index 126896e..9f6d59d 100644 --- a/packages/mcp-server-kit/package.json +++ b/packages/mcp-server-kit/package.json @@ -1,7 +1,7 @@ { "name": "@codelitdev/mcp-server-kit", "version": "0.4.2", - "description": "MCP transport, session, auth hooks, and error mapping for CodeLit products", + "description": "Stateless MCP 2026-07-28 endpoint, auth hooks, and error mapping for CodeLit products", "author": "CodeLit", "type": "module", "license": "MIT", @@ -33,14 +33,16 @@ "tag": "latest" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "zod": "^3.25.76" + "@modelcontextprotocol/server": "^2.4.0", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "@codelitdev/platform": ">=0.4.2" }, "devDependencies": { "@codelitdev/platform": "workspace:*", + "@modelcontextprotocol/client": "^2.4.0", "@types/bun": "^1.4.1", "@types/node": "^22.14.1", "typescript": "^5.9.3" diff --git a/packages/mcp-server-kit/scripts/packed-consumer.mjs b/packages/mcp-server-kit/scripts/packed-consumer.mjs index 55d3e1b..40bbb84 100644 --- a/packages/mcp-server-kit/scripts/packed-consumer.mjs +++ b/packages/mcp-server-kit/scripts/packed-consumer.mjs @@ -67,17 +67,20 @@ writeFileSync( path.join(dir, "assert.mjs"), ` import { createMcpServerKit, validateParityManifest } from "@codelitdev/mcp-server-kit"; +import { z } from "zod"; const kit = createMcpServerKit({ name: "c", version: "0", - async authenticate() { return { kind: "absent" }; }, - async resolveContext() { return { ok: false, error: { code: "unauthenticated", message: "x" } }; }, + async authenticate() { + return { kind: "authenticated", principalId: "u", credential: { kind: "oauth" } }; + }, + async resolveContext() { return { ok: true, context: { tenant: "t" } }; }, tools: [{ name: "t", description: "t", risk: "read", - inputSchema: { safeParse: (value) => ({ success: true, data: value }) }, - async handler() { return {}; }, + inputSchema: z.object({}), + async handler({ context }) { return { tenant: context.tenant }; }, }], }); const res = await kit.handle({ @@ -86,6 +89,14 @@ const res = await kit.handle({ body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, }); if (res.status !== 401) throw new Error("cookie session accepted"); +const called = await kit.handle({ + method: "POST", + headers: { authorization: "Bearer t" }, + body: { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "t", arguments: {} } }, +}); +if (JSON.stringify(called.body?.result?.structuredContent) !== '{"tenant":"t"}') { + throw new Error("packed tool call failed: " + JSON.stringify(called.body)); +} if (JSON.stringify(kit.listTools()).includes("src/")) throw new Error("src"); validateParityManifest([], { restOperationIds: new Set(), mcpToolNames: new Set() }); console.log("packed-exports-ok"); diff --git a/packages/mcp-server-kit/src/cors.ts b/packages/mcp-server-kit/src/cors.ts index 7a90f62..c1d2122 100644 --- a/packages/mcp-server-kit/src/cors.ts +++ b/packages/mcp-server-kit/src/cors.ts @@ -7,7 +7,6 @@ export const MCP_ALLOWED_HEADERS = [ "X-API-Key", "X-Tenant-ID", "MCP-Protocol-Version", - "Mcp-Session-Id", "Mcp-Method", "Mcp-Name", ].join(", "); @@ -18,8 +17,7 @@ export function mcpCorsHeaders(requestHeaders: HeaderMap): Record 0 ? origin : "*", Vary: "Origin", - "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": MCP_ALLOWED_HEADERS, - "Access-Control-Expose-Headers": "Mcp-Session-Id", }; } diff --git a/packages/mcp-server-kit/src/create.test.ts b/packages/mcp-server-kit/src/create.test.ts index b8c26f0..7244424 100644 --- a/packages/mcp-server-kit/src/create.test.ts +++ b/packages/mcp-server-kit/src/create.test.ts @@ -1,35 +1,53 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import { type AuthenticationResult, createPlatformError } from "@codelitdev/platform"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { z } from "zod"; -import { createMcpServerKit } from "./create.js"; +import { createMcpServerKit, type McpServerKit } from "./create.js"; type Ctx = { principalId: string; tenantId: string }; +const oauth = async (): Promise> => ({ + kind: "authenticated", + principalId: "user_1", + credential: { kind: "oauth", credentialId: "client" }, +}); + function kit( authenticate: ( headers: Record, - ) => Promise>, + ) => Promise> = oauth, + captured: string[] = [], ) { return createMcpServerKit({ name: "reference", version: "0.0.0", authenticate, - async resolveContext({ principalId }) { - return { - ok: true, - context: { principalId, tenantId: "tnt_a" }, - }; + async resolveContext({ principalId, headers }) { + if (headers["x-tenant-id"] === "tnt_forbidden") { + return { ok: false, error: createPlatformError("tenant_forbidden") }; + } + return { ok: true, context: { principalId, tenantId: "tnt_a" } }; }, + onError: ({ source }) => captured.push(source), tools: [ { name: "notes.list", description: "List notes", risk: "read", - inputSchema: z.object({}), + inputSchema: z.object({ limit: z.number().int().optional() }), async handler({ context }) { return { tenantId: context.tenantId, items: [] }; }, }, + { + name: "notes.create", + description: "Create a note", + risk: "write", + inputSchema: z.object({ title: z.string().min(1) }), + async handler({ args }) { + return { id: "note_1", title: (args as { title: string }).title }; + }, + }, { name: "notes.delete", description: "Delete a note", @@ -38,329 +56,265 @@ function kit( noteId: z.string(), confirm: z.boolean().optional(), }), + async handler({ args }) { + return (args as { noteId: string }).noteId === "missing" + ? { error: createPlatformError("not_found") } + : { deleted: true }; + }, + }, + { + name: "boom", + description: "Throws", + risk: "read", + inputSchema: z.object({}), async handler() { - return { deleted: true }; + throw new Error("secret=leak"); }, }, ], }); } -describe("createMcpServerKit", () => { - it("rejects a browser session cookie and does not authenticate", async () => { - const server = kit(async () => { - throw new Error("authenticate should not run for cookie-only MCP"); - }); +/** A fetch that serves an SDK client from the kit's record-based handle(). */ +function kitFetch( + server: McpServerKit, + extraHeaders: Record = {}, +) { + return async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + const text = await request.text(); const response = await server.handle({ - method: "POST", + method: request.method, headers: { - cookie: "better-auth.session_token=sess", + ...Object.fromEntries(request.headers), + authorization: "Bearer tok", + ...extraHeaders, }, - body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + body: text ? JSON.parse(text) : undefined, }); - expect(response.status).toBe(401); - const body = response.body as { error: { code: string } }; - expect(body.error.code).toBe("unauthenticated"); - }); - - it("rejects a malformed Authorization header without authenticating", async () => { - const server = kit(async () => { - throw new Error("authenticate should not run for a malformed header"); + return new Response(response.body === null ? null : JSON.stringify(response.body), { + status: response.status, + headers: response.headers, }); - const response = await server.handle({ - method: "POST", - headers: { - authorization: "Basic dXNlcjpwYXNz", - "x-api-key": "key_1", + }; +} + +const closeables: Array<{ close(): Promise }> = []; +afterEach(async () => { + await Promise.allSettled(closeables.splice(0).map((item) => item.close())); +}); + +async function connect(server: McpServerKit, era: "modern" | "legacy") { + const client = new Client( + { name: "kit-test", version: "1.0.0" }, + { + versionNegotiation: { + mode: era === "modern" ? { pin: "2026-07-28" } : "legacy", }, - body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, - }); - expect(response.status).toBe(401); - const body = response.body as { error: { code: string } }; - expect(body.error.code).toBe("unauthenticated"); - }); + }, + ); + closeables.push(client); + await client.connect( + new StreamableHTTPClientTransport(new URL("http://kit.test/mcp"), { + fetch: kitFetch(server), + }), + ); + return client; +} - it("never emits a system credential from MCP mapping", async () => { - const server = kit(async () => ({ - kind: "authenticated", - principalId: "user_1", - credential: { kind: "system" }, - })); - const response = await server.handle({ - method: "POST", - headers: { authorization: "Bearer tok" }, - body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, - }); - expect(response.status).toBe(500); - const body = response.body as { error: { code: string } }; - expect(body.error.code).toBe("internal_error"); +function rawCall( + server: McpServerKit, + name: string, + args: Record, +) { + return server.handle({ + method: "POST", + headers: { authorization: "Bearer tok" }, + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }, }); +} - it("accepts OAuth bearer and lists tools with risk metadata", async () => { - const server = kit(async () => ({ - kind: "authenticated", - principalId: "user_1", - credential: { kind: "oauth", credentialId: "client" }, - })); - const response = await server.handle({ - method: "POST", - headers: { authorization: "Bearer tok" }, - body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, - }); - expect(response.status).toBe(200); - const body = response.body as { - result: { tools: Array<{ name: string; annotations: { risk: string } }> }; - }; - expect(body.result.tools.map((tool) => tool.name)).toEqual([ +type ToolCallBody = { + result: { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + structuredContent: Record; + }; +}; + +describe("createMcpServerKit", () => { + it("serves MCP 2026-07-28 statelessly with a deterministic, cacheable catalog", async () => { + const client = await connect(kit(), "modern"); + expect(client.getProtocolEra()).toBe("modern"); + + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual([ "notes.list", + "notes.create", "notes.delete", + "boom", ]); - expect(body.result.tools[1]?.annotations.risk).toBe("destructive"); - }); - - it("denies destructive tools without confirmation", async () => { - const server = kit(async () => ({ - kind: "authenticated", - principalId: "user_1", - credential: { kind: "api_key", credentialId: "key" }, - })); - const denied = await server.handle({ - method: "POST", - headers: { "x-api-key": "key.secret" }, - body: { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { name: "notes.delete", arguments: { noteId: "note_1" } }, - }, + expect(listed.ttlMs).toBe(300_000); + expect(listed.cacheScope).toBe("private"); + const [list, create, remove] = listed.tools; + expect(list?.annotations).toEqual({ readOnlyHint: true }); + expect(create?.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, }); - expect(denied.status).toBe(403); - const deniedBody = denied.body as { - error: { code: string; data?: { confirmation?: string } }; - }; - expect(deniedBody.error.code).toBe("forbidden"); - expect(deniedBody.error.data?.confirmation).toBe("required"); - const allowed = await server.handle({ - method: "POST", - headers: { "x-api-key": "key.secret" }, - body: { - jsonrpc: "2.0", - id: 3, - method: "tools/call", - params: { - name: "notes.delete", - arguments: { noteId: "note_1", confirm: true }, - }, - }, + expect(remove?.annotations).toEqual({ readOnlyHint: false, destructiveHint: true }); + expect(remove?._meta?.risk).toBe("destructive"); + expect(list?.inputSchema).toMatchObject({ + type: "object", + properties: { limit: { type: "integer" } }, }); - expect(allowed.status).toBe(200); - }); + expect(list?.inputSchema).not.toHaveProperty("$schema"); - it("maps thrown exceptions without leaking cause and honors abort", async () => { - const captured: string[] = []; - const server = createMcpServerKit({ - name: "reference", - version: "0.0.0", - async authenticate() { - return { - kind: "authenticated", - principalId: "user_1", - credential: { kind: "oauth" }, - }; - }, - async resolveContext({ principalId }) { - return { - ok: true, - context: { principalId, tenantId: "tnt_a" }, - }; - }, - onError: ({ source }) => captured.push(source), - tools: [ - { - name: "boom", - description: "throws", - risk: "read", - inputSchema: z.object({}), - async handler() { - throw new Error("secret=leak"); - }, - }, - ], - }); - const thrown = await server.handle({ - method: "POST", - headers: { authorization: "Bearer tok" }, - body: { - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { name: "boom", arguments: {} }, - }, - }); - const body = JSON.stringify(thrown.body); - expect(thrown.status).toBe(500); - expect(body).not.toContain("secret=leak"); - expect(body).toContain("internal_error"); - expect(captured).toEqual(["mcp.tool"]); - - const aborted = new AbortController(); - aborted.abort(); - const cancelled = await server.handle({ - method: "POST", - headers: { authorization: "Bearer tok" }, - body: { - jsonrpc: "2.0", - id: 5, - method: "tools/call", - params: { name: "boom", arguments: {} }, - }, - signal: aborted.signal, - }); - expect(cancelled.status).toBe(500); - await server.shutdown(); - const after = await server.handle({ - method: "POST", - headers: { authorization: "Bearer tok" }, - body: { jsonrpc: "2.0", id: 6, method: "tools/list" }, + const called = await client.callTool({ + name: "notes.create", + arguments: { title: "Hi" }, }); - expect(after.status).toBe(503); + expect(called.isError).toBeFalsy(); + expect(called.structuredContent).toEqual({ id: "note_1", title: "Hi" }); + expect(called.content).toEqual([ + { type: "text", text: JSON.stringify({ id: "note_1", title: "Hi" }) }, + ]); }); - it("mints a streamable HTTP session and uses sessions.get for GET/POST/DELETE", async () => { - const server = kit(async () => ({ - kind: "authenticated", - principalId: "user_1", - credential: { kind: "oauth", credentialId: "client" }, - })); + it("serves 2025-era Streamable HTTP clients without sessions", async () => { + const server = kit(); + const client = await connect(server, "legacy"); + expect(client.getProtocolEra()).toBe("legacy"); + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toContain("notes.list"); + const called = await client.callTool({ name: "notes.list", arguments: {} }); + expect(called.structuredContent).toEqual({ tenantId: "tnt_a", items: [] }); + const initialized = await server.handle({ method: "POST", - headers: { authorization: "Bearer tok", accept: "application/json" }, + headers: { authorization: "Bearer tok" }, body: { jsonrpc: "2.0", id: 1, method: "initialize", params: { - protocolVersion: "2025-03-26", + protocolVersion: "2025-11-25", capabilities: {}, - clientInfo: { name: "test", version: "0" }, + clientInfo: { name: "raw", version: "0" }, }, }, }); expect(initialized.status).toBe(200); - expect(initialized.headers["Access-Control-Expose-Headers"]).toBe("Mcp-Session-Id"); - const sessionId = initialized.headers["Mcp-Session-Id"]; - expect(sessionId).toEqual(expect.any(String)); - expect(server.sessions.get(sessionId)?.id).toBe(sessionId); + expect(initialized.headers["content-type"]).toBe("application/json"); + expect(initialized.headers).not.toHaveProperty("connection"); + expect( + Object.keys(initialized.headers).map((key) => key.toLowerCase()), + ).not.toContain("mcp-session-id"); + }); - const missing = await server.handle({ - method: "GET", - headers: { - authorization: "Bearer tok", - "mcp-session-id": "not-a-session", - }, - }); - expect(missing.status).toBe(404); + it("returns only spec content types alongside structured content", async () => { + const response = await rawCall(kit(), "notes.list", {}); + expect(response.status).toBe(200); + const { result } = response.body as ToolCallBody; + expect(result.structuredContent).toEqual({ tenantId: "tnt_a", items: [] }); + expect(result.content.map((item) => item.type)).toEqual(["text"]); + expect(JSON.parse(result.content[0]!.text!)).toEqual(result.structuredContent); + }); - const streamed = await server.handle({ - method: "GET", + it("rejects modern requests whose headers disagree with the envelope", async () => { + const server = kit(); + const response = await server.handle({ + method: "POST", headers: { authorization: "Bearer tok", - "mcp-session-id": sessionId!, - accept: "text/event-stream, application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/call", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, }, }); - expect(streamed.status).toBeGreaterThanOrEqual(200); - expect(streamed.status).toBeLessThan(500); - expect(server.sessions.get(sessionId)?.id).toBe(sessionId); + expect(response.status).toBe(400); + expect((response.body as { error: { code: number } }).error.code).toEqual( + expect.any(Number), + ); + }); - const listed = await server.handle({ - method: "POST", - headers: { - authorization: "Bearer tok", - "mcp-session-id": sessionId!, - accept: "application/json", - }, - body: { jsonrpc: "2.0", id: 11, method: "tools/list" }, + it("reports tool failures as tool results with stable platform codes", async () => { + const captured: string[] = []; + const server = kit(oauth, captured); + + const unconfirmed = await rawCall(server, "notes.delete", { noteId: "n" }); + expect(unconfirmed.status).toBe(200); + const denied = (unconfirmed.body as ToolCallBody).result; + expect(denied.isError).toBe(true); + expect(denied.structuredContent).toEqual({ + code: "forbidden", + message: expect.any(String), + details: { confirmation: "required" }, }); - expect(listed.status).toBe(200); - const listedBody = listed.body as { - result: { tools: Array<{ name: string; annotations: { risk: string } }> }; - }; - expect(listedBody.result.tools.map((tool) => tool.name)).toContain("notes.list"); - const stalePost = await server.handle({ - method: "POST", - headers: { - authorization: "Bearer tok", - "mcp-session-id": "missing", - }, - body: { jsonrpc: "2.0", id: 2, method: "tools/list" }, + const missing = await rawCall(server, "notes.delete", { + noteId: "missing", + confirm: true, }); - expect(stalePost.status).toBe(404); + expect((missing.body as ToolCallBody).result.structuredContent.code).toBe( + "not_found", + ); - const deleted = await server.handle({ - method: "DELETE", - headers: { - authorization: "Bearer tok", - "mcp-session-id": sessionId!, - }, + const confirmed = await rawCall(server, "notes.delete", { + noteId: "n", + confirm: true, + }); + expect((confirmed.body as ToolCallBody).result.structuredContent).toEqual({ + deleted: true, }); - expect(deleted.status).toBeGreaterThanOrEqual(200); - expect(deleted.status).toBeLessThan(500); - expect(server.sessions.get(sessionId)).toBeUndefined(); + + const thrown = await rawCall(server, "boom", {}); + expect(JSON.stringify(thrown.body)).not.toContain("secret=leak"); + expect((thrown.body as ToolCallBody).result.structuredContent.code).toBe( + "internal_error", + ); + expect(captured).toEqual(["mcp.tool"]); }); - it("does not allocate or expose sessions before authentication", async () => { - let authenticateCalls = 0; + it("rejects a browser session cookie and does not authenticate", async () => { const server = kit(async () => { - authenticateCalls += 1; - return { kind: "absent" }; + throw new Error("authenticate should not run for cookie-only MCP"); }); - const initialize = await server.handle({ + const response = await server.handle({ method: "POST", - headers: {}, - body: { jsonrpc: "2.0", id: 1, method: "initialize" }, - }); - expect(initialize.status).toBe(401); - expect(server.sessions.size()).toBe(0); - expect(authenticateCalls).toBe(0); - - const get = await server.handle({ - method: "GET", - headers: { "mcp-session-id": "unknown" }, - }); - const del = await server.handle({ - method: "DELETE", - headers: { "mcp-session-id": "unknown" }, + headers: { cookie: "better-auth.session_token=sess" }, + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, }); - expect(get.status).toBe(401); - expect(del.status).toBe(401); - expect(server.sessions.size()).toBe(0); + expect(response.status).toBe(401); + expect((response.body as { code: string }).code).toBe("unauthenticated"); }); - it("binds a session to the principal that initialized it", async () => { - let principal = "owner"; - const server = kit(async () => ({ - kind: "authenticated" as const, - principalId: principal, - credential: { kind: "oauth" as const }, - })); - const initialized = await server.handle({ - method: "POST", - headers: { authorization: "Bearer tok" }, - body: { jsonrpc: "2.0", id: 1, method: "initialize" }, + it("rejects a malformed Authorization header without authenticating", async () => { + const server = kit(async () => { + throw new Error("authenticate should not run for a malformed header"); }); - const sessionId = initialized.headers["Mcp-Session-Id"]!; - principal = "other"; const response = await server.handle({ - method: "DELETE", - headers: { - authorization: "Bearer tok", - "mcp-session-id": sessionId, - }, + method: "POST", + headers: { authorization: "Basic dXNlcjpwYXNz", "x-api-key": "key_1" }, + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, }); - expect(response.status).toBe(404); - expect(server.sessions.get(sessionId)).toBeDefined(); + expect(response.status).toBe(401); + expect((response.body as { code: string }).code).toBe("unauthenticated"); }); it("does not fall back when authenticate rejects", async () => { @@ -374,8 +328,78 @@ describe("createMcpServerKit", () => { body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, }); expect(response.status).toBe(401); - expect((response.body as { error: { code: string } }).error.code).toBe( - "unauthenticated", + expect((response.body as { code: string }).code).toBe("unauthenticated"); + }); + + it("never emits a system credential from MCP mapping", async () => { + const server = kit(async () => ({ + kind: "authenticated", + principalId: "user_1", + credential: { kind: "system" }, + })); + const response = await server.handle({ + method: "POST", + headers: { authorization: "Bearer tok" }, + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }); + expect(response.status).toBe(500); + expect((response.body as { code: string }).code).toBe("internal_error"); + }); + + it("maps context resolution failures before dispatch", async () => { + const response = await kit().handle({ + method: "POST", + headers: { authorization: "Bearer tok", "x-tenant-id": "tnt_forbidden" }, + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }); + expect(response.status).toBe(403); + expect((response.body as { code: string }).code).toBe("tenant_forbidden"); + }); + + it("serves POST only, with CORS for the modern protocol headers", async () => { + const server = kit(); + for (const method of ["GET", "DELETE"]) { + const response = await server.handle({ + method, + headers: { authorization: "Bearer tok" }, + }); + expect(response.status).toBe(405); + expect(response.headers.Allow).toBe("POST, OPTIONS"); + } + const preflight = await server.handle({ + method: "OPTIONS", + headers: { origin: "https://client.example" }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers["Access-Control-Allow-Origin"]).toBe( + "https://client.example", ); + expect(preflight.headers["Access-Control-Allow-Methods"]).toBe("POST, OPTIONS"); + expect(preflight.headers["Access-Control-Allow-Headers"]).toContain("Mcp-Method"); + expect(preflight.headers["Access-Control-Allow-Headers"]).toContain("Mcp-Name"); + expect(preflight.headers["Access-Control-Allow-Headers"]).not.toContain( + "Mcp-Session-Id", + ); + }); + + it("honors abort and rejects requests after shutdown", async () => { + const server = kit(); + const aborted = new AbortController(); + aborted.abort(); + const cancelled = await server.handle({ + method: "POST", + headers: { authorization: "Bearer tok" }, + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + signal: aborted.signal, + }); + expect(cancelled.status).toBe(500); + + await server.shutdown(); + const after = await server.handle({ + method: "POST", + headers: { authorization: "Bearer tok" }, + body: { jsonrpc: "2.0", id: 2, method: "tools/list" }, + }); + expect(after.status).toBe(503); }); }); diff --git a/packages/mcp-server-kit/src/create.ts b/packages/mcp-server-kit/src/create.ts index 663f559..40f16ff 100644 --- a/packages/mcp-server-kit/src/create.ts +++ b/packages/mcp-server-kit/src/create.ts @@ -1,27 +1,25 @@ -import { AsyncLocalStorage } from "node:async_hooks"; import { type AuthenticationResult, captureAndMapException, createPlatformError, type HeaderMap, mapTransportAuthentication, - PLATFORM_ERROR_CODES, type PlatformError, - type PlatformErrorCode, selectMcpCredential, toPublicHttpError, } from "@codelitdev/platform"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + type CacheHint, + type CallToolResult, + createMcpHandler, + McpServer, + type ServerContext, + type ToolAnnotations, +} from "@modelcontextprotocol/server"; import type { z } from "zod"; import { mcpCorsHeaders } from "./cors.js"; -import { createMcpSessionStore } from "./sessions.js"; -import { - createStreamableSession, - dispatchWebStandard, - headerString, - patchMcpAccept, - type WebStandardStreamableHTTPServerTransport, -} from "./streamable-http.js"; +import { toMcpInputSchema } from "./schema.js"; +import { patchMcpAccept, readMcpResponse, toWebRequest } from "./streamable-http.js"; export type McpToolRisk = "read" | "write" | "destructive"; @@ -40,125 +38,153 @@ export type McpToolDefinition = { export type CreateMcpServerKitOptions = { name: string; version: string; + /** Shown to clients in `server/discover` and `initialize`. */ + description?: string; + instructions?: string; authenticate: (headers: HeaderMap) => Promise>; resolveContext: (input: { principalId: string; credential: AuthenticationResult & { kind: "authenticated" }; headers: HeaderMap; }) => Promise<{ ok: true; context: Context } | { ok: false; error: PlatformError }>; + /** Registered, and listed by `tools/list`, in this order. */ tools: readonly McpToolDefinition[]; + /** + * Cache hint for `tools/list` and `server/discover` results (MCP + * 2026-07-28). The catalog is static per deployment but only served to + * authenticated callers, so the default is five minutes, private. + */ + catalogCacheHint?: CacheHint; onError?: (input: { error: unknown; source: string }) => void; }; -type ToolScope = { - context: Context; - signal: AbortSignal; +export type McpKitRequest = { + method: string; + headers: HeaderMap; + /** The parsed JSON-RPC body. */ + body?: unknown; + signal?: AbortSignal; }; -type RegisterTool = ( - name: string, - config: { - description?: string; - inputSchema?: Record; - annotations?: { - readOnlyHint?: boolean; - destructiveHint?: boolean; - }; - _meta?: Record; - }, - callback: ( - args: Record, - extra: { - authInfo?: { extra?: Record }; - signal?: AbortSignal; - }, - ) => Promise, -) => unknown; +export type McpKitResponse = { + status: number; + headers: Record; + /** A JSON-RPC message, a platform error body, or null for an empty response. */ + body: unknown; +}; -function isPlatformErrorCode(value: unknown): value is PlatformErrorCode { - return ( - typeof value === "string" && - (PLATFORM_ERROR_CODES as readonly string[]).includes(value) - ); +const DEFAULT_CATALOG_CACHE_HINT: CacheHint = { + ttlMs: 5 * 60 * 1000, + cacheScope: "private", +}; + +function toolAnnotations(risk: McpToolRisk): ToolAnnotations { + if (risk === "read") return { readOnlyHint: true }; + return { readOnlyHint: false, destructiveHint: risk === "destructive" }; } -function platformToolError(error: PlatformError) { +/** + * A tool failure is a tool result, not a JSON-RPC error, so the model sees the + * stable platform code and can correct its call (MCP "tool execution errors"). + */ +function platformToolError(error: PlatformError): CallToolResult { const mapped = toPublicHttpError(error); + const structured = { + code: mapped.body.code, + message: mapped.body.message, + details: mapped.body.details ?? null, + }; return { - isError: true as const, - content: [{ type: "text" as const, text: mapped.body.message }], - structuredContent: { - code: mapped.body.code, - message: mapped.body.message, - details: mapped.body.details ?? null, - }, + isError: true, + content: [{ type: "text", text: JSON.stringify(structured) }], + structuredContent: structured, }; } +function isPlatformErrorResult(value: unknown): value is { error: PlatformError } { + if (!value || typeof value !== "object" || !("error" in value)) return false; + const error = (value as { error: unknown }).error; + return Boolean(error && typeof error === "object" && "code" in error); +} + +function toolResult(result: unknown): CallToolResult { + const structured = + result && typeof result === "object" && !Array.isArray(result) + ? (result as Record) + : { value: result ?? null }; + return { + content: [{ type: "text", text: JSON.stringify(structured) }], + structuredContent: structured, + }; +} + +function platformErrorResponse( + error: PlatformError, + cors: Record, +): McpKitResponse { + const mapped = toPublicHttpError(error); + return { status: mapped.status, headers: { ...cors }, body: mapped.body }; +} + +/** + * A stateless MCP endpoint serving protocol 2026-07-28 and, through the SDK's + * stateless legacy mode, 2025-era Streamable HTTP clients. Every request is + * authenticated and resolved to a product context before a fresh, request-local + * server handles it; nothing is kept between requests. + */ export function createMcpServerKit( options: CreateMcpServerKitOptions, ) { - const transports = new Map(); - const sessions = createMcpSessionStore({ - onExpire(session) { - const transport = transports.get(session.id); - transports.delete(session.id); - if (transport) { - void transport.close().catch(() => { - // Expiry cleanup is best effort; the session has already been - // removed, so it cannot be reused even if close fails. - }); - } - }, - }); - const requestScope = new AsyncLocalStorage>(); + // Zod-to-JSON-Schema conversion happens once per process, not per request. + const tools = options.tools.map((tool) => ({ + tool, + inputSchema: toMcpInputSchema(tool.inputSchema), + })); + const catalogCacheHint = options.catalogCacheHint ?? DEFAULT_CATALOG_CACHE_HINT; - function jsonRpcError(id: unknown, error: PlatformError) { - const mapped = toPublicHttpError(error); - return { - status: mapped.status, - headers: {} as Record, - body: { - jsonrpc: "2.0", - id: id ?? null, - error: { - code: mapped.body.code, - message: mapped.body.message, - data: mapped.body.details, - }, - }, - }; + function reportError(error: unknown, source: string) { + try { + options.onError?.({ error, source }); + } catch { + // Error reporting must not alter the MCP response. + } + } + + function contextFrom(ctx: ServerContext): Context | undefined { + return ctx.http?.authInfo?.extra?.context as Context | undefined; } - function registerTools(server: McpServer) { - const registerTool = (server.registerTool as unknown as RegisterTool).bind(server); - for (const tool of options.tools) { - const shape = - tool.inputSchema && - typeof tool.inputSchema === "object" && - "shape" in tool.inputSchema - ? (tool.inputSchema as { shape: Record }).shape - : undefined; - registerTool( + function buildServer(): McpServer { + const server = new McpServer( + { + name: options.name, + version: options.version, + ...(options.description ? { description: options.description } : {}), + }, + { + ...(options.instructions ? { instructions: options.instructions } : {}), + cacheHints: { + "tools/list": catalogCacheHint, + "server/discover": catalogCacheHint, + }, + }, + ); + for (const { tool, inputSchema } of tools) { + server.registerTool( tool.name, { description: tool.description, - ...(shape ? { inputSchema: shape } : {}), - annotations: { - readOnlyHint: tool.risk === "read", - destructiveHint: tool.risk === "destructive", - }, + inputSchema, + annotations: toolAnnotations(tool.risk), _meta: { risk: tool.risk }, }, - async (args, extra) => { - const scope = requestScope.getStore(); - const context = - scope?.context ?? (extra.authInfo?.extra?.context as Context | undefined); + async (args, ctx) => { + const context = contextFrom(ctx); if (!context) { return platformToolError(createPlatformError("unauthenticated")); } - const signal = extra.signal ?? scope?.signal; - if (signal?.aborted) { + const signal = ctx.mcpReq.signal; + if (signal.aborted) { return platformToolError(createPlatformError("internal_error")); } const rawArgs = (args ?? {}) as Record; @@ -174,56 +200,25 @@ export function createMcpServerKit( return platformToolError(createPlatformError("validation_failed")); } try { - const result = await tool.handler({ - context, - args: parsed.data, - signal: signal ?? new AbortController().signal, - }); - if ( - result && - typeof result === "object" && - "error" in result && - result.error && - typeof result.error === "object" && - "code" in result.error - ) { - return platformToolError(result.error as PlatformError); - } - return { - content: [{ type: "text" as const, text: JSON.stringify(result) }], - structuredContent: - result && typeof result === "object" - ? (result as Record) - : { value: result }, - }; + const result = await tool.handler({ context, args: parsed.data, signal }); + return isPlatformErrorResult(result) + ? platformToolError(result.error) + : toolResult(result); } catch (thrown) { - try { - options.onError?.({ error: thrown, source: "mcp.tool" }); - } catch { - // Error reporting must not alter the MCP response. - } + reportError(thrown, "mcp.tool"); return platformToolError(captureAndMapException(thrown)); } }, ); } + return server; } - async function attachSession(sessionId?: string) { - const live = createStreamableSession({ - name: options.name, - version: options.version, - sessionId, - registerTools, - onclosed: (id) => { - transports.delete(id); - sessions.delete(id); - }, - }); - await live.ready; - if (sessionId) transports.set(sessionId, live.transport); - return live; - } + const handler = createMcpHandler(() => buildServer(), { + legacy: "stateless", + onerror: (error) => reportError(error, "mcp.protocol"), + }); + let closed = false; async function authenticateHeaders(headers: HeaderMap) { const selected = selectMcpCredential(headers); @@ -238,298 +233,76 @@ export function createMcpServerKit( return mapTransportAuthentication(auth, { transport: "mcp" }); } - function adaptSdkBody(body: unknown): unknown { - if (!body || typeof body !== "object") return body; - const rpc = body as { - result?: { - isError?: boolean; - structuredContent?: Record; - content?: Array>; - tools?: Array>; - }; - }; - if (Array.isArray(rpc.result?.tools)) { - for (const listed of rpc.result.tools) { - const meta = listed._meta as { risk?: string } | undefined; - const annotations = { - ...((listed.annotations as Record | undefined) ?? {}), - }; - if (meta?.risk) annotations.risk = meta.risk; - listed.annotations = annotations; - } - } - const structured = rpc.result?.structuredContent; - if (structured && Array.isArray(rpc.result?.content)) { - const hasJson = rpc.result.content.some((item) => item.type === "json"); - if (!hasJson) { - rpc.result.content = [ - { type: "json", json: structured }, - ...rpc.result.content, - ]; - } - } - return body; - } - - function mapSdkResult( - rpcId: unknown, - cors: Record, - sdk: { status: number; headers: Record; body: unknown }, - ) { - const body = adaptSdkBody(sdk.body) as { - result?: { - isError?: boolean; - structuredContent?: { - code?: unknown; - message?: unknown; - details?: Record; - }; - }; - }; - const code = body?.result?.isError - ? body.result.structuredContent?.code - : undefined; - if (isPlatformErrorCode(code)) { - const mapped = jsonRpcError( - rpcId, - createPlatformError(code, { - safeDetails: body.result?.structuredContent?.details, - }), - ); - return { - ...mapped, - headers: { ...cors, ...sdk.headers, ...mapped.headers }, - }; - } - return { - status: sdk.status, - headers: { ...cors, ...sdk.headers }, - body: sdk.body, - }; - } - - async function handle(request: { - method: string; - headers: HeaderMap; - body?: unknown; - signal?: AbortSignal; - }): Promise<{ - status: number; - headers: Record; - body: unknown; - }> { + async function handle(request: McpKitRequest): Promise { const headers = patchMcpAccept(request.headers); const cors = mcpCorsHeaders(headers); const method = request.method.toUpperCase(); if (method === "OPTIONS") { return { status: 204, headers: cors, body: null }; } - if (sessions.closed) { + if (closed) { return { + ...platformErrorResponse(createPlatformError("internal_error"), cors), status: 503, - headers: cors, - body: toPublicHttpError(createPlatformError("internal_error")).body, }; } - - const sessionId = headerString(headers, "mcp-session-id"); - const payload = - request.body && typeof request.body === "object" - ? (request.body as { - jsonrpc?: string; - id?: unknown; - method?: string; - params?: Record; - }) - : {}; - const rpcId = payload.id ?? null; - - // Every MCP operation is protected. In particular, do not allow an - // unauthenticated initialize to allocate a session (or an unauthenticated - // GET/DELETE to operate on a session id). Besides violating the auth - // contract, doing so makes the in-memory session map an easy DoS target. - const auth = await authenticateHeaders(headers); - if (auth.kind !== "authenticated") { - const error = - auth.kind === "rejected" ? auth.error : createPlatformError("unauthenticated"); - const mapped = jsonRpcError(rpcId, error); - return { ...mapped, headers: { ...cors, ...mapped.headers } }; - } - - if (method === "GET" || method === "DELETE") { - const session = sessions.get(sessionId); - if (!session || (session.ownerId && session.ownerId !== auth.principalId)) { - return { - status: 404, - headers: cors, - body: { - jsonrpc: "2.0", - error: { code: -32001, message: "Session not found" }, - id: null, - }, - }; - } - const transport = transports.get(session.id); - if (!transport) { - return { - status: 404, - headers: cors, - body: { - jsonrpc: "2.0", - error: { code: -32001, message: "Session not found" }, - id: null, - }, - }; - } - const result = await dispatchWebStandard(transport, { - method, - headers, - body: request.body, - signal: request.signal, - }); - if (method === "DELETE") { - transports.delete(session.id); - sessions.delete(session.id); - await transport.close(); - } - return { - status: result.status || (method === "DELETE" ? 204 : result.status), - headers: { - ...cors, - ...result.headers, - ...(method === "GET" ? { "Mcp-Session-Id": session.id } : {}), - }, - body: result.body, - }; - } - + // Streamable HTTP without sessions is POST-only: there is no standalone + // GET stream and no session to DELETE. if (method !== "POST") { return { status: 405, - headers: cors, + headers: { ...cors, Allow: "POST, OPTIONS" }, body: toPublicHttpError(createPlatformError("validation_failed")).body, }; } - if (payload.method === "initialize") { - const resolved = await options.resolveContext({ - principalId: auth.principalId, - credential: auth, - headers, - }); - if (!resolved.ok) { - const mapped = jsonRpcError(rpcId, resolved.error); - return { ...mapped, headers: { ...cors, ...mapped.headers } }; - } - const session = sessions.create(auth.principalId); - const live = await attachSession(session.id); - const result = await requestScope.run( - { - context: resolved.context, - signal: request.signal ?? new AbortController().signal, - }, - () => - dispatchWebStandard(live.transport, { - method: "POST", - headers, - body: request.body, - signal: request.signal, - authInfo: { - token: "kit", - clientId: auth.principalId, - scopes: [], - extra: { context: resolved.context }, - }, - }), + // Every MCP request is protected, `server/discover` and legacy + // `initialize` included. + const auth = await authenticateHeaders(headers); + if (auth.kind !== "authenticated") { + return platformErrorResponse( + auth.kind === "rejected" ? auth.error : createPlatformError("unauthenticated"), + cors, ); - return { - status: result.status || 200, - headers: { - ...cors, - ...result.headers, - "Mcp-Session-Id": session.id, - }, - body: result.body, - }; - } - - if (sessionId) { - const session = sessions.get(sessionId); - if (!session || (session.ownerId && session.ownerId !== auth.principalId)) { - return { - status: 404, - headers: cors, - body: { - jsonrpc: "2.0", - error: { code: -32001, message: "Session not found" }, - id: null, - }, - }; - } } - const resolved = await options.resolveContext({ principalId: auth.principalId, credential: auth, headers, }); - if (!resolved.ok) { - const mapped = jsonRpcError(rpcId, resolved.error); - return { ...mapped, headers: { ...cors, ...mapped.headers } }; - } - - const live = sessionId - ? { - transport: transports.get(sessions.get(sessionId)?.id ?? "") ?? null, - close: async () => undefined, - } - : await attachSession(); - const transport = live.transport; - if (!transport) { - return { - status: 404, - headers: cors, - body: { - jsonrpc: "2.0", - error: { code: -32001, message: "Session not found" }, - id: null, - }, - }; + if (!resolved.ok) return platformErrorResponse(resolved.error, cors); + if (request.signal?.aborted) { + return platformErrorResponse(createPlatformError("internal_error"), cors); } - const sdk = await requestScope.run( + const response = await handler.fetch( + toWebRequest({ headers, body: request.body, signal: request.signal }), { - context: resolved.context, - signal: request.signal ?? new AbortController().signal, + parsedBody: request.body, + authInfo: { + // The credential never enters the MCP layer; tools read the + // resolved product context instead. + token: "kit", + clientId: auth.principalId, + scopes: [], + extra: { context: resolved.context }, + }, }, - () => - dispatchWebStandard(transport, { - method: "POST", - headers, - body: request.body, - signal: request.signal, - authInfo: { - token: "kit", - clientId: auth.principalId, - scopes: [], - extra: { context: resolved.context }, - }, - }), ); - if (!sessionId) await transport.close(); - return mapSdkResult(rpcId, cors, sdk); + const result = await readMcpResponse(response); + return { + status: result.status, + headers: { ...cors, ...result.headers }, + body: result.body, + }; } return { handle, listTools: () => [...options.tools], - sessions, shutdown: async () => { - for (const transport of transports.values()) { - await transport.close(); - } - transports.clear(); - sessions.shutdown(); + closed = true; + await handler.close(); }, }; } diff --git a/packages/mcp-server-kit/src/index.ts b/packages/mcp-server-kit/src/index.ts index 16dec3d..e41b8e8 100644 --- a/packages/mcp-server-kit/src/index.ts +++ b/packages/mcp-server-kit/src/index.ts @@ -2,6 +2,8 @@ export { MCP_ALLOWED_HEADERS, mcpCorsHeaders } from "./cors.js"; export { type CreateMcpServerKitOptions, createMcpServerKit, + type McpKitRequest, + type McpKitResponse, type McpServerKit, type McpToolDefinition, type McpToolRisk, @@ -12,13 +14,4 @@ export { type ParityValidationIssue, validateParityManifest, } from "./parity.js"; -export { - createMcpSessionStore, - type McpSession, - type McpSessionStore, -} from "./sessions.js"; -export { - patchMcpAccept, - StreamableHTTPServerTransport, - WebStandardStreamableHTTPServerTransport, -} from "./streamable-http.js"; +export { patchMcpAccept } from "./streamable-http.js"; diff --git a/packages/mcp-server-kit/src/schema.ts b/packages/mcp-server-kit/src/schema.ts new file mode 100644 index 0000000..b88fe7a --- /dev/null +++ b/packages/mcp-server-kit/src/schema.ts @@ -0,0 +1,23 @@ +import { + fromJsonSchema, + type JsonSchemaType, + type StandardSchemaWithJSON, +} from "@modelcontextprotocol/server"; +import type { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +/** + * Advertise a product's Zod 3 tool schema as the JSON Schema object MCP + * requires. Refs are inlined and `$schema` is dropped so clients read the + * schema under MCP's default dialect (JSON Schema 2020-12). + */ +export function toMcpInputSchema(schema: z.ZodType): StandardSchemaWithJSON { + const { $schema: _dialect, ...json } = zodToJsonSchema(schema, { + target: "jsonSchema7", + $refStrategy: "none", + }) as Record; + if (json.type !== "object") { + throw new Error("MCP tool input schemas must describe an object"); + } + return fromJsonSchema(json as JsonSchemaType); +} diff --git a/packages/mcp-server-kit/src/sessions.test.ts b/packages/mcp-server-kit/src/sessions.test.ts deleted file mode 100644 index fc3d29a..0000000 --- a/packages/mcp-server-kit/src/sessions.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { createMcpSessionStore } from "./sessions.js"; - -describe("MCP session store", () => { - it("notifies consumers when expiry removes a session", () => { - let now = new Date("2026-01-01T00:00:00.000Z"); - const expired: string[] = []; - const store = createMcpSessionStore({ - ttlMs: 1, - clock: { now: () => now }, - onExpire: (session) => expired.push(session.id), - }); - const session = store.create("user_1"); - now = new Date("2026-01-01T00:00:00.001Z"); - expect(store.get(session.id)).toBeUndefined(); - expect(expired).toEqual([session.id]); - expect(store.size()).toBe(0); - }); -}); diff --git a/packages/mcp-server-kit/src/sessions.ts b/packages/mcp-server-kit/src/sessions.ts deleted file mode 100644 index bec6379..0000000 --- a/packages/mcp-server-kit/src/sessions.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type Clock, createRequestId, systemClock } from "@codelitdev/platform"; - -export type McpSession = { - id: string; - /** Principal that authenticated the initialize request. */ - ownerId?: string; - createdAt: Date; - expiresAt: Date; -}; - -export function createMcpSessionStore(options: { - ttlMs?: number; - clock?: Clock; - onExpire?: (session: McpSession) => void; -}) { - const ttlMs = options.ttlMs ?? 30 * 60 * 1000; - const clock = options.clock ?? systemClock; - const sessions = new Map(); - let closed = false; - - function purge(now: Date) { - for (const [id, session] of sessions) { - if (session.expiresAt.getTime() <= now.getTime()) { - sessions.delete(id); - try { - options.onExpire?.(session); - } catch { - // Session expiry must not leave stale session state behind because a - // best-effort transport cleanup failed. - } - } - } - } - - return { - create(ownerId?: string): McpSession { - if (closed) throw new Error("mcp_sessions_closed"); - const now = clock.now(); - purge(now); - const session: McpSession = { - id: createRequestId(clock), - ...(ownerId ? { ownerId } : {}), - createdAt: now, - expiresAt: new Date(now.getTime() + ttlMs), - }; - sessions.set(session.id, session); - return session; - }, - get(id: string | undefined): McpSession | undefined { - if (!id || closed) return undefined; - purge(clock.now()); - return sessions.get(id); - }, - delete(id: string): boolean { - return sessions.delete(id); - }, - size(): number { - purge(clock.now()); - return sessions.size; - }, - shutdown() { - closed = true; - sessions.clear(); - }, - get closed() { - return closed; - }, - }; -} - -export type McpSessionStore = ReturnType; diff --git a/packages/mcp-server-kit/src/streamable-http.ts b/packages/mcp-server-kit/src/streamable-http.ts index 4462106..6529cf1 100644 --- a/packages/mcp-server-kit/src/streamable-http.ts +++ b/packages/mcp-server-kit/src/streamable-http.ts @@ -1,9 +1,4 @@ import type { HeaderMap } from "@codelitdev/platform"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; - -export { StreamableHTTPServerTransport, WebStandardStreamableHTTPServerTransport }; export function headerString(headers: HeaderMap, name: string): string | undefined { const value = headers[name] ?? headers[name.toLowerCase()]; @@ -22,113 +17,92 @@ export function patchMcpAccept(headers: HeaderMap): HeaderMap { ...(needsSse ? ["text/event-stream"] : []), ]; const next = accept ? `${accept}, ${additions.join(", ")}` : additions.join(", "); - return { ...headers, accept: next, Accept: next }; + const rest = Object.fromEntries( + Object.entries(headers).filter(([key]) => key.toLowerCase() !== "accept"), + ); + return { ...rest, accept: next }; } -export function headersToRecord(headers: HeaderMap): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(headers)) { - if (typeof value === "string") out[key] = value; - else if (Array.isArray(value) && value[0]) out[key] = value[0]; +// The body is re-serialized from its parsed form, so the original framing +// headers no longer describe it. +const DROPPED_REQUEST_HEADERS = new Set(["content-length", "transfer-encoding"]); + +export function toWebRequest(input: { + headers: HeaderMap; + body?: unknown; + signal?: AbortSignal; +}): Request { + const headers = new Headers(); + for (const [key, value] of Object.entries(input.headers)) { + if (DROPPED_REQUEST_HEADERS.has(key.toLowerCase())) continue; + const single = Array.isArray(value) ? value[0] : value; + if (typeof single === "string") headers.set(key, single); } - return out; + if (!headers.has("content-type")) headers.set("content-type", "application/json"); + return new Request("http://localhost/mcp", { + method: "POST", + headers, + body: JSON.stringify(input.body ?? null), + signal: input.signal, + }); } -export async function dispatchWebStandard( - transport: WebStandardStreamableHTTPServerTransport, - request: { - method: string; - headers: HeaderMap; - body?: unknown; - signal?: AbortSignal; - authInfo?: { - token: string; - clientId: string; - scopes: string[]; - extra?: Record; - }; - }, -): Promise<{ +/** JSON-RPC messages carried by a complete `text/event-stream` body. */ +function sseMessages(text: string): unknown[] { + const messages: unknown[] = []; + for (const event of text.split(/\r?\n\r?\n/)) { + const data = event + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (!data) continue; + try { + messages.push(JSON.parse(data)); + } catch { + // Not a JSON-RPC frame; ignore it like a client would. + } + } + return messages; +} + +function isResponseMessage(message: unknown): boolean { + return Boolean( + message && + typeof message === "object" && + "id" in message && + ("result" in message || "error" in message), + ); +} + +/** + * Settle an SDK response into a JSON record. The stateless 2025 leg answers a + * POST over a short SSE stream that closes after its one response; Streamable + * HTTP lets the same response go back as `application/json`, which is what the + * kit's record-based `handle()` returns. + */ +export async function readMcpResponse(response: Response): Promise<{ status: number; headers: Record; body: unknown; }> { - const patched = patchMcpAccept(request.headers); - const webHeaders = new Headers(); - for (const [key, value] of Object.entries(headersToRecord(patched))) { - webHeaders.set(key, value); - } - const method = request.method.toUpperCase(); - if ( - method !== "GET" && - method !== "HEAD" && - method !== "DELETE" && - !webHeaders.has("content-type") - ) { - webHeaders.set("content-type", "application/json"); - } - const init: RequestInit = { - method, - headers: webHeaders, - signal: request.signal, - }; - if (method !== "GET" && method !== "HEAD" && method !== "DELETE") { - init.body = JSON.stringify(request.body ?? {}); - } - const webRequest = new Request("http://127.0.0.1/mcp", init); - const response = await transport.handleRequest(webRequest, { - parsedBody: request.body, - authInfo: request.authInfo, - }); - const outHeaders: Record = {}; + const headers: Record = {}; response.headers.forEach((value, key) => { - outHeaders[key] = value; + if (key !== "content-length") headers[key] = value; }); const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); if (contentType.includes("text/event-stream")) { - await response.body?.cancel(); - return { status: response.status, headers: outHeaders, body: null }; + const messages = sseMessages(text); + const body = messages.find(isResponseMessage) ?? messages.at(-1) ?? null; + headers["content-type"] = "application/json"; + for (const streamOnly of ["connection", "x-accel-buffering"]) + delete headers[streamOnly]; + return { status: response.status, headers, body }; } + if (!text) return { status: response.status, headers, body: null }; if (contentType.includes("application/json")) { - return { - status: response.status, - headers: outHeaders, - body: await response.json(), - }; + return { status: response.status, headers, body: JSON.parse(text) }; } - const text = await response.text(); - return { - status: response.status, - headers: outHeaders, - body: text.length > 0 ? text : null, - }; -} - -export function createStreamableSession(input: { - name: string; - version: string; - sessionId?: string; - registerTools: (server: McpServer) => void; - onclosed?: (sessionId: string) => void; -}): { - server: McpServer; - transport: WebStandardStreamableHTTPServerTransport; - ready: Promise; -} { - // MediaLit mounts StreamableHTTPServerTransport (Node wrapper around this - // Web Standard transport). The kit handle() API is request/response records, - // so the Web Standard transport is the protocol implementation. - void StreamableHTTPServerTransport; - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: input.sessionId ? () => input.sessionId as string : undefined, - enableJsonResponse: true, - onsessioninitialized: () => undefined, - onsessionclosed: (id) => input.onclosed?.(id), - }); - const server = new McpServer({ - name: input.name, - version: input.version, - }); - input.registerTools(server); - return { server, transport, ready: server.connect(transport) }; + return { status: response.status, headers, body: text }; } diff --git a/packages/platform-conformance/src/run.test.ts b/packages/platform-conformance/src/run.test.ts index 172d3b8..ec64293 100644 --- a/packages/platform-conformance/src/run.test.ts +++ b/packages/platform-conformance/src/run.test.ts @@ -131,14 +131,18 @@ function memoryAdapter(options: { input.args && typeof input.args === "object" ) { - if ((input.args as { confirm?: unknown }).confirm !== true) { - return { status: 403, body: { error: { code: "forbidden" } } }; - } - return { status: 404, body: { error: { code: "not_found" } } }; + const code = + (input.args as { confirm?: unknown }).confirm !== true + ? "forbidden" + : "not_found"; + return { + status: 200, + body: { result: { isError: true, structuredContent: { code } } }, + }; } return { status: 200, - body: { result: { content: [{ json: { items: [] } }] } }, + body: { result: { structuredContent: { items: [] } } }, }; } : undefined, diff --git a/packages/platform-conformance/src/run.ts b/packages/platform-conformance/src/run.ts index 526c6cf..c7b7bf7 100644 --- a/packages/platform-conformance/src/run.ts +++ b/packages/platform-conformance/src/run.ts @@ -33,6 +33,15 @@ type HttpAdapter = PlatformConformanceAdapter< function errorCode(body: unknown): string | undefined { if (!body || typeof body !== "object") return undefined; if ("code" in body && typeof body.code === "string") return body.code; + // MCP tool failures are tool results flagged `isError`, not JSON-RPC errors. + if ("result" in body && body.result && typeof body.result === "object") { + const result = body.result as { + isError?: boolean; + structuredContent?: { code?: unknown }; + }; + const code = result.isError ? result.structuredContent?.code : undefined; + return typeof code === "string" ? code : undefined; + } if ( "error" in body && body.error && @@ -226,10 +235,13 @@ export async function runPlatformConformance( args: {}, }); const mcpBody = mcpList.body as { - result?: { content?: Array<{ json?: { items?: unknown[] } }> }; - error?: { code?: string }; + result?: { isError?: boolean; structuredContent?: { items?: unknown[] } }; }; - if (mcpList.status !== 200 || !mcpBody.result) { + if ( + mcpList.status !== 200 || + mcpBody.result?.isError || + !Array.isArray(mcpBody.result?.structuredContent?.items) + ) { fail("mcp", "notes.list MCP call failed"); } const cookieMcp = await adapter.mcp!({ diff --git a/templates/saas-product/apps/api/src/app.test.ts b/templates/saas-product/apps/api/src/app.test.ts index a5566b2..e82c760 100644 --- a/templates/saas-product/apps/api/src/app.test.ts +++ b/templates/saas-product/apps/api/src/app.test.ts @@ -9,6 +9,14 @@ import { createExpressApp } from "./express-app.js"; import { createPgliteRuntime, freezeRuntimeClock } from "./runtime.js"; import { mintOAuthToken, seedWorld } from "./seed.js"; +/** The platform code of an MCP tool result reported with `isError`. */ +function toolErrorCode(body: unknown): string | undefined { + const result = ( + body as { result?: { isError?: boolean; structuredContent?: { code?: string } } } + ).result; + return result?.isError ? result.structuredContent?.code : undefined; +} + describe.serial("reference API adapters", () => { it("does not run DDL when oauth-server-kit options and schema modules are imported", async () => { const { PGlite } = await import("@electric-sql/pglite"); @@ -332,9 +340,9 @@ describe.serial("reference API adapters", () => { const restItems = (restList.body as { items: Array<{ id: string }> }).items; const mcpJson = ( mcpList.body as { - result: { content: Array<{ json: { items: Array<{ id: string }> } }> }; + result: { structuredContent: { items: Array<{ id: string }> } }; } - ).result.content[0]?.json; + ).result.structuredContent; expect(mcpJson?.items.map((item) => item.id)).toEqual( restItems.map((item) => item.id), ); @@ -352,16 +360,15 @@ describe.serial("reference API adapters", () => { id: 10, method: "initialize", params: { - protocolVersion: "2025-03-26", + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "reference-test", version: "0" }, }, }, }); - expect(initialized.headers?.["Mcp-Session-Id"]).toEqual(expect.any(String)); - expect(initialized.headers?.["Access-Control-Expose-Headers"]).toBe( - "Mcp-Session-Id", - ); + // MCP is stateless: 2025-era clients still initialize, but no session is minted. + expect(initialized.status).toBe(200); + expect(initialized.headers?.["Mcp-Session-Id"]).toBeUndefined(); const cross = await dispatch(runtime, { method: "POST", @@ -380,7 +387,7 @@ describe.serial("reference API adapters", () => { }, }, }); - expect((cross.body as { error: { code: string } }).error.code).toBe("not_found"); + expect(toolErrorCode(cross.body)).toBe("not_found"); const denied = await dispatch(runtime, { method: "POST", @@ -399,7 +406,7 @@ describe.serial("reference API adapters", () => { }, }, }); - expect((denied.body as { error: { code: string } }).error.code).toBe("forbidden"); + expect(toolErrorCode(denied.body)).toBe("forbidden"); const deleted = await dispatch(runtime, { method: "POST",