diff --git a/README.md b/README.md index 4766cb6..bccc31b 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,17 @@ For clients with no deferral of their own. `tools/list` returns 15 tools instead `call_tool` dispatches **read-only tools only**. A client gates approval on the tool name it can see, so routing an order through a generic dispatcher would hide it from the check meant to catch it. It also refuses a read-only tool that is already advertised, and answers an unknown name with near matches rather than a guess. Arguments are validated against the real tool schema before dispatch; a rejection returns the offending paths and that tool's schema. +### Protocol revisions + +Both endpoints serve the **2026-07-28** revision and every legacy revision back to 2024-10-07 from the same URL. + +- A 2026-07-28 client sends each request on its own, with no `initialize` handshake, names the method in the `Mcp-Method` header (and the tool in `Mcp-Name`) so a gateway can route without parsing the body, and carries its protocol version, client info and capabilities in `_meta`. +- `server/discover` returns capabilities, instructions and supported versions in one call. +- `tools/list`, `prompts/list`, `resources/list` and `server/discover` carry `ttlMs` and `cacheScope`. `tools/list` is always `private`: it is filtered per credential, so a shared cache would hand one key's catalogue to another. +- An `initialize`-based client negotiates 2025-11-25, 2025-06-18 or older exactly as before. Claude Code and the existing connectors are unaffected. + +The server is stateless in both eras: no sessions, no `Mcp-Session-Id`, one fresh server per request. + ### Scope-aware discovery `tools/list` carries only the tools the calling credential's scopes permit. A `data` key sees 10 tools, not 31; an identity-only OAuth token sees `get_profile` alone; `search_tools` will not return an out-of-scope tool and `call_tool` will not dispatch one. Scopes come from `GET /me` on the REST API, cached per credential for 60 seconds and looked up only for `tools/list`, never on the call path. @@ -164,6 +175,7 @@ Tool names, arguments, results and pagination are identical on both endpoints. D ```text Remote MCP client → POST /mcp (full catalogue) or POST /mcp/compact (search_tools + call_tool) + → createMcpHandler, legacy: 'stateless' (2026-07-28 and every legacy revision) → fresh stateless server per request → user's API credentials, resolved from headers or encrypted OAuth token → CPZ REST API /functions/v1/rest-api/v1 diff --git a/package-lock.json b/package-lock.json index d8c45d9..c480168 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { "name": "@cpzai/mcp-server", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cpzai/mcp-server", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.0.0", "@sentry/node": "^10.41.0", "express": "^4.21.2", - "zod": "^3.24.2", - "zod-to-json-schema": "^3.24.1" + "zod": "^4.6.5" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/express": "^5.0.1", "@types/node": "^22.13.10", "tsx": "^4.19.3", @@ -531,18 +531,6 @@ "module-details-from-path": "^1.0.4" } }, - "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", @@ -550,329 +538,48 @@ "dev": true, "license": "MIT" }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod": "^4.2.0" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "node": ">=20" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "node_modules/@modelcontextprotocol/core": { "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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", "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" + "zod": "^4.2.0" }, "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=20" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "node_modules/@modelcontextprotocol/server": { "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/@modelcontextprotocol/sdk/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==", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", "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/@modelcontextprotocol/sdk/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/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" + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" + "node": ">=20" } }, "node_modules/@opentelemetry/api": { @@ -2164,39 +1871,6 @@ "acorn": "^8" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2405,27 +2079,11 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, - "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==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2611,6 +2269,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -2623,6 +2282,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2684,46 +2344,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", - "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", - "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2894,15 +2514,6 @@ "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.12.17", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.17.tgz", - "integrity": "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==", - "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", @@ -2956,15 +2567,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2974,22 +2576,18 @@ "node": ">= 0.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==", + "dev": true, "license": "ISC" }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -3002,18 +2600,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "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/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -3155,15 +2741,6 @@ "node": ">= 0.6" } }, - "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", @@ -3188,15 +2765,6 @@ "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", @@ -3210,6 +2778,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3293,6 +2862,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -3403,46 +2973,6 @@ "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/raw-body/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/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-in-the-middle": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", @@ -3534,55 +3064,6 @@ "fsevents": "~2.3.2" } }, - "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/router/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/router/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/router/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/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3664,6 +3145,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3676,6 +3158,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4181,6 +3664,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4209,12 +3693,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -4225,22 +3703,13 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "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 59ed561..8c5b587 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cpzai/mcp-server", - "version": "1.3.0", + "version": "1.4.0", "description": "CPZAI MCP server — AI agent access to quantitative trading strategies, backtests, multi-broker order routing, portfolios, and risk analytics.", "license": "MIT", "homepage": "https://ai.cpz-lab.com", @@ -33,13 +33,13 @@ "export:catalog": "tsx scripts/export-tool-catalog.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.0.0", "@sentry/node": "^10.41.0", "express": "^4.21.2", - "zod": "^3.24.2", - "zod-to-json-schema": "^3.24.1" + "zod": "^4.6.5" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/express": "^5.0.1", "@types/node": "^22.13.10", "tsx": "^4.19.3", diff --git a/scripts/export-tool-catalog.ts b/scripts/export-tool-catalog.ts index 099c2e7..58d5dac 100644 --- a/scripts/export-tool-catalog.ts +++ b/scripts/export-tool-catalog.ts @@ -3,9 +3,8 @@ import { writeFile, mkdir } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Request } from 'express'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { ToolAnnotations } from "@modelcontextprotocol/client"; import { createMcpServer } from '../src/server.js'; export interface ToolCatalogEntry { diff --git a/src/capabilities.ts b/src/capabilities.ts index f3ad084..9aebd8a 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from "@modelcontextprotocol/server"; import { z } from 'zod'; const BASE_INSTRUCTIONS = `CPZAI provides user-scoped trading and research tools. Use tools/list for the current catalog and read cpzai://guides/tool-usage and cpzai://guides/permissions before planning a workflow. Paginate list results; a page is not the entire portfolio. Read-only review does not require order submission or strategy execution. execute_strategy can place real orders. Inspect account environment and tradable status before any user-authorized trading. A failed or timed-out mutation can have an unknown outcome: reconcile orders before considering another submission. Never infer zero positions, prices, or risk from an error or missing data.`; @@ -70,6 +70,9 @@ tools/list carries every state-changing tool, the read anchors (list_accounts, l Results from call_tool are the tool's own result, unchanged: same structuredContent, same isError semantics. +## Protocol revisions +Both endpoints serve the 2026-07-28 revision and every legacy revision back to 2024-10-07 from the same URL. A 2026-07-28 client sends each request on its own with no initialize handshake, names the method in the Mcp-Method header (and the tool in Mcp-Name), carries its protocol version and identity in _meta, and may call server/discover for capabilities and instructions. tools/list, prompts/list, resources/list and server/discover carry ttlMs and cacheScope; tools/list is always cacheScope private because it is filtered per credential. An initialize-based client negotiates 2025-11-25 or older exactly as before. + ## Scope filtering applies to both endpoints tools/list carries only the tools the calling credential's scopes permit, when those scopes can be determined: a data-scoped key is not shown the order surface, and search_tools does not return it either. If the scope lookup is unavailable the full catalogue is advertised, because unknown is not the same as none. Filtering is discovery, not enforcement; the REST API decides what a credential may touch. @@ -91,7 +94,7 @@ export function registerCapabilities(server: McpServer) { server.registerPrompt('review_portfolio', { title: 'Review Portfolio', description: 'Read-only portfolio review using accounts, paginated positions, orders, and stored risk snapshots.', - argsSchema: { account_id: z.string().trim().min(1).optional().describe('Optional account filter') }, + argsSchema: z.object({ account_id: z.string().trim().min(1).optional().describe('Optional account filter') }), }, ({ account_id }) => ({ messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `Review my portfolio${account_id ? ` for account ${JSON.stringify(account_id)}` : ''}. Read cpzai://guides/tool-usage. List accounts, retrieve all pages of positions and relevant stored risk snapshots, and examine orders as permitted. Report account environments, timestamps, exposure, and missing or failed inputs. Keep currency amounts exact. This request is read-only; do not compute new snapshots, sync accounts, execute strategies, or submit orders.` } }], @@ -100,7 +103,7 @@ export function registerCapabilities(server: McpServer) { server.registerPrompt('analyze_strategy', { title: 'Analyze Strategy', description: 'Inspect stored strategy code and backtest evidence without execution or edits.', - argsSchema: { strategy_id: z.string().uuid().describe('Strategy UUID') }, + argsSchema: z.object({ strategy_id: z.string().uuid().describe('Strategy UUID') }), }, ({ strategy_id }) => ({ messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `Analyze strategy ${strategy_id}. Read its code with get_strategy, page through get_backtest_results for this strategy, and inspect relevant get_backtest_result records. If data inputs need investigation, use list_data_files/get_data_file and list_connections. Evaluate the actual stored evidence, call out missing data and failed requests, and propose improvements. This request authorizes analysis only; do not update or execute the strategy, create connections, or place trades.` } }], diff --git a/src/expanded-tools.ts b/src/expanded-tools.ts index d4d524f..5da0a57 100644 --- a/src/expanded-tools.ts +++ b/src/expanded-tools.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from "@modelcontextprotocol/server"; import { callRestApi } from './api-client.js'; import { formatResult } from './tool-result.js'; diff --git a/src/index.ts b/src/index.ts index 95dc0fd..39e30bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/node'; import express from 'express'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createMcpHandler } from '@modelcontextprotocol/server'; import { extractCredentials } from './tools.js'; import { createMcpServer } from './server.js'; import { resolveScopes } from './scopes.js'; @@ -23,7 +23,7 @@ if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV || 'production', - release: process.env.SENTRY_RELEASE || 'cpzai-mcp-server@1.3.0', + release: process.env.SENTRY_RELEASE || 'cpzai-mcp-server@1.4.0', tracesSampleRate: 0.2, profilesSampleRate: 0.1, }); @@ -36,7 +36,7 @@ app.use(express.urlencoded({ extended: true })); function cors(_req: express.Request, res: express.Response, next: express.NextFunction) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CPZ-Key, X-CPZ-Secret, X-Request-Id, MCP-Protocol-Version, MCP-Session-Id'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CPZ-Key, X-CPZ-Secret, X-Request-Id, MCP-Protocol-Version, MCP-Session-Id, Mcp-Method, Mcp-Name'); res.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate, MCP-Session-Id, X-Request-Id'); next(); } @@ -355,6 +355,47 @@ app.post(['/simons/stream', '/simons/chat'], cors, async (req, res) => { // ── MCP ───────────────────────────────────────────────────────── +/** + * Rebuild the web-standard Request the MCP handler expects. + * + * The handler reads a web-standard Request, and express.json() has already + * consumed the raw Node stream by the time this runs, so converting the socket + * directly would hand it an empty body. Re-serializing the parsed body is the + * honest fix; content-length is dropped because it described the original bytes. + */ +function toWebRequest(req: express.Request): Request { + const host = req.get('host') ?? 'localhost'; + const url = new URL(req.originalUrl, `${req.protocol}://${host}`); + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (name === 'content-length') continue; + if (Array.isArray(value)) for (const entry of value) headers.append(name, entry); + else if (typeof value === 'string') headers.set(name, value); + } + return new Request(url, { method: req.method, headers, body: JSON.stringify(req.body ?? {}) }); +} + +/** Stream a web Response back through Express, without buffering the stream. */ +async function pipeWebResponse(response: Response, res: express.Response): Promise { + res.status(response.status); + response.headers.forEach((value, name) => res.setHeader(name, value)); + if (!response.body) { + res.end(); + return; + } + const reader = response.body.getReader(); + // A client that hangs up mid-stream should stop the pump, not keep writing + // into a dead socket. + let closed = false; + res.on('close', () => { closed = true; void reader.cancel().catch(() => {}); }); + for (;;) { + const { done, value } = await reader.read(); + if (done || closed) break; + res.write(Buffer.from(value)); + } + res.end(); +} + /** True when this JSON-RPC body (single or batched) asks for the tool list. */ function requestsToolList(body: unknown): boolean { const messages = Array.isArray(body) ? body : [body]; @@ -396,19 +437,27 @@ app.post(['/mcp', '/mcp/compact'], cors, async (req, res, next) => { ? await resolveScopes(creds.apiKey, creds.apiSecret, typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'] : undefined) : null; - const server = createMcpServer(req, { - mode: req.path === '/mcp/compact' ? 'compact' : 'full', - scopes, - }); - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - res.on('close', () => { - void server.close().catch(error => console.error('[mcp] failed to close request transport', { error })); + const mode = req.path === '/mcp/compact' ? 'compact' : 'full'; + + // `legacy: 'stateless'` is what lets one endpoint answer both eras: a + // 2026-07-28 client gets the modern path (server/discover, cacheable list + // results, no handshake), and an initialize-based client on 2025-11-25 or + // 2025-06-18 is served exactly as before. + const handler = createMcpHandler(() => createMcpServer(req, { mode, scopes }), { + legacy: 'stateless', + onerror: error => console.error('[mcp] handler error', { + message: error.message, + request_id: req.headers['x-request-id'], + path: req.path, + }), }); + try { - await server.connect(transport); - await transport.handleRequest(req, res, req.body); + await pipeWebResponse(await handler.fetch(toWebRequest(req)), res); } catch (error) { next(error); + } finally { + await handler.close?.(); } }); diff --git a/src/server.ts b/src/server.ts index 24304f0..3c42868 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,12 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer } from "@modelcontextprotocol/server"; +import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; + +/** + * The 2026-07-28 revision. The SDK's SUPPORTED_PROTOCOL_VERSIONS still lists + * only the legacy set, and a server opts into the modern era by naming it: + * that is what registers server/discover and the stateless request path. + */ +const MODERN_PROTOCOL_VERSION = '2026-07-28'; import type { Request } from 'express'; import { registerTools } from './tools.js'; import { registerCapabilities, serverInstructions } from './capabilities.js'; @@ -29,8 +37,22 @@ export interface ServerOptions { export function createMcpServer(req: Request, options: ToolMode | ServerOptions = {}) { const { mode = 'full', scopes = null } = typeof options === 'string' ? { mode: options } as ServerOptions : options; - const server = new McpServer({ name: 'cpzai-mcp-server', version: '1.3.0' }, { + const server = new McpServer({ name: 'cpzai-mcp-server', version: '1.4.0' }, { instructions: serverInstructions(mode), + // Serve the 2026-07-28 revision alongside every legacy one the SDK still + // supports. A 2026-07-28 client gets the stateless path and server/discover; + // Claude Code (2025-06-18) and existing connectors negotiate as before. + supportedProtocolVersions: [MODERN_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS], + // Cacheable list results. PRIVATE, never public: tools/list is filtered by + // the calling credential's scopes, so a shared cache would hand one key's + // catalogue to another. Sixty seconds matches the scope cache's own TTL, + // so a re-minted key cannot be stale for longer than the scopes behind it. + cacheHints: { + 'tools/list': { ttlMs: 60_000, cacheScope: 'private' }, + 'prompts/list': { ttlMs: 300_000, cacheScope: 'public' }, + 'resources/list': { ttlMs: 300_000, cacheScope: 'public' }, + 'server/discover': { ttlMs: 60_000, cacheScope: 'private' }, + }, }); // One definition of every tool. Capture first, then decide what reaches diff --git a/src/tool-registry.ts b/src/tool-registry.ts index 0d172b4..c832309 100644 --- a/src/tool-registry.ts +++ b/src/tool-registry.ts @@ -9,17 +9,24 @@ * registration functions against a recorder, so there is exactly one definition * of every tool and no second list to drift. */ -import { zodToJsonSchema } from 'zod-to-json-schema'; +import { z } from 'zod'; import { OUTPUT_SCHEMAS } from './tool-output.js'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; -import type { ZodTypeAny, ZodObject, ZodRawShape } from 'zod'; +import type { McpServer, CallToolResult, ToolAnnotations } from "@modelcontextprotocol/server"; +import type { ZodType } from 'zod'; + +// The target the v2 server itself converts with, so a schema handed out by +// search_tools is byte-identical to the one tools/list publishes. +const JSON_SCHEMA_TARGET = 'draft-2020-12' as const; + +function toJsonSchema(schema: ZodType, io: 'input' | 'output'): Record { + return z.toJSONSchema(schema, { target: JSON_SCHEMA_TARGET, io }) as Record; +} export interface CapturedToolConfig { title?: string; description?: string; - inputSchema?: ZodTypeAny; - outputSchema?: ZodTypeAny; + inputSchema?: ZodType; + outputSchema?: ZodType; annotations?: ToolAnnotations; } @@ -81,10 +88,7 @@ export function isReadOnly(tool: CapturedTool): boolean { export function inputJsonSchema(tool: CapturedTool): Record { const schema = tool.config.inputSchema; if (!schema) return { type: 'object', additionalProperties: false }; - return zodToJsonSchema(schema as ZodObject, { - strictUnions: true, - pipeStrategy: 'input', - }) as Record; + return toJsonSchema(schema, 'input'); } /** Coarse grouping, used for filtering searches and for listing what exists. */ @@ -232,10 +236,7 @@ export function describeTool(tool: CapturedTool): ToolDescriptor { // have given it, output shape included. ...(OUTPUT_SCHEMAS[tool.name] ? { - output_schema: zodToJsonSchema(OUTPUT_SCHEMAS[tool.name] as ZodObject, { - strictUnions: true, - pipeStrategy: 'output', - }) as Record, + output_schema: toJsonSchema(OUTPUT_SCHEMAS[tool.name], 'output'), } : {}), }; diff --git a/src/tool-search.ts b/src/tool-search.ts index 28cac5a..a166a3c 100644 --- a/src/tool-search.ts +++ b/src/tool-search.ts @@ -21,7 +21,7 @@ * clients that have no such mechanism. */ import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from "@modelcontextprotocol/server"; import { formatResult, invalidArguments } from './tool-result.js'; import { TOOL_CATEGORIES, @@ -110,7 +110,7 @@ export function registerToolSearch(server: McpServer, tools: CapturedTool[]) { 'Invoke a read-only CPZAI tool discovered with search_tools, passing its arguments exactly as its input_schema describes. Read-only tools only: tools that place orders, execute strategies, or write credentials and webhooks are advertised under their own names and must be called directly so you and the user see what is being approved. Returns the tool\'s own result unchanged.', inputSchema: z.object({ name: z.string().trim().min(1).max(128).describe('Tool name exactly as returned by search_tools.'), - arguments: z.record(z.unknown()).optional().describe('Arguments object matching that tool\'s input_schema. Omit for a tool that takes none.'), + arguments: z.record(z.string(), z.unknown()).optional().describe('Arguments object matching that tool\'s input_schema. Omit for a tool that takes none.'), }), annotations: readOnlyAnnotations, }, async (args) => { diff --git a/src/tools.ts b/src/tools.ts index f89e2bd..7917914 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from "@modelcontextprotocol/server"; import type { Request } from 'express'; import { callRestApi } from './api-client.js'; import { resolveAccessToken } from './oauth.js'; @@ -295,7 +295,7 @@ export function registerTools(server: McpServer, req: Request) { connection_name: z.string().describe('Display name for the connection'), api_key: z.string().optional().describe('The provider API key'), secret_token: z.string().optional().describe('Secondary secret, for providers that need a pair'), - configuration: z.record(z.unknown()).optional().describe('Non-secret provider settings'), + configuration: z.record(z.string(), z.unknown()).optional().describe('Non-secret provider settings'), }), // Writes a credential, so it is explicitly not read-only and not // idempotent: a second call for the same provider is a 409, not a no-op. diff --git a/tests/expanded-tools.test.ts b/tests/expanded-tools.test.ts index 6a6a16a..ca6a74e 100644 --- a/tests/expanded-tools.test.ts +++ b/tests/expanded-tools.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; +import { Client } from "@modelcontextprotocol/client"; const id = '5a5d40e6-53f9-4be6-9ebc-d0f83a7f1b71'; const entityId = '96043206-24a4-4d63-9cd7-04c51ccf7d7c'; diff --git a/tests/modern-protocol.test.ts b/tests/modern-protocol.test.ts new file mode 100644 index 0000000..c58d3a3 --- /dev/null +++ b/tests/modern-protocol.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import type { Request as ExpressRequest } from 'express'; +import { createMcpServer, type ServerOptions } from '../src/server.js'; + +const HEADERS = { 'x-cpz-key': 'test-key', 'x-cpz-secret': 'test-secret' }; +const MODERN = '2026-07-28'; + +function meta() { + return { + 'io.modelcontextprotocol/protocolVersion': MODERN, + 'io.modelcontextprotocol/clientInfo': { name: 'modern-test', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }; +} + +/** + * One stateless 2026-07-28 request: no initialize, `Mcp-Method` routing so a + * gateway can dispatch without parsing the body, identity in `_meta`. + */ +async function send(method: string, params: Record = {}, options: ServerOptions = {}, name?: string) { + const handler = createMcpHandler(() => createMcpServer({ headers: HEADERS } as unknown as ExpressRequest, options), { + legacy: 'stateless', + }); + const headers: Record = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': method, + }; + if (name) headers['mcp-name'] = name; + const response = await handler.fetch(new Request('https://mcp.test/mcp', { + method: 'POST', + headers, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: meta() } }), + })); + const text = await response.text(); + const payload = text.startsWith('data: ') || text.includes('\ndata: ') + ? JSON.parse(text.split('data: ').pop() as string) + : JSON.parse(text); + await handler.close?.(); + return { status: response.status, ...payload }; +} + +describe('the 2026-07-28 revision', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: [], count: 0 }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('answers tools/list with no handshake at all', async () => { + const { result, error } = await send('tools/list'); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('complete'); + expect(result.tools).toHaveLength(31); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('marks list results cacheable, and never with a shared cache', async () => { + const { result } = await send('tools/list'); + expect(result.ttlMs).toBe(60_000); + // tools/list is filtered per credential, so a public cache would hand one + // key's catalogue to another. + expect(result.cacheScope).toBe('private'); + }); + + it('serves server/discover, which the legacy era has no equivalent for', async () => { + const { result, error } = await send('server/discover'); + expect(error).toBeUndefined(); + expect(result.supportedVersions).toContain(MODERN); + expect(result.capabilities).toMatchObject({ tools: {}, resources: {}, prompts: {} }); + expect(result.instructions).toContain('CPZAI provides user-scoped trading'); + }); + + it('describes the compact surface in its own discover response', async () => { + const { result } = await send('server/discover', {}, { mode: 'compact' }); + expect(result.instructions).toContain('call_tool dispatches read-only tools only'); + const list = await send('tools/list', {}, { mode: 'compact' }); + expect(list.result.tools.map((tool: { name: string }) => tool.name)).toContain('search_tools'); + expect(list.result.tools).toHaveLength(15); + }); + + it('applies scope filtering on the modern path too', async () => { + const { result } = await send('tools/list', {}, { scopes: new Set(['data']) }); + const names = result.tools.map((tool: { name: string }) => tool.name); + expect(names).toHaveLength(10); + expect(names).not.toContain('place_order'); + }); + + it('routes a tool call by header and returns the same result shape', async () => { + const { result, error } = await send( + 'tools/call', + { name: 'search_tools', arguments: { query: 'unsettled cash flows' } }, + { mode: 'compact' }, + 'search_tools', + ); + expect(error).toBeUndefined(); + expect(result.resultType).toBe('complete'); + expect(result.structuredContent.tools.map((tool: { name: string }) => tool.name)).toContain('list_cash_flows'); + }); + + it('rejects a body whose method the routing header does not name', async () => { + const handler = createMcpHandler(() => createMcpServer({ headers: HEADERS } as unknown as ExpressRequest), { + legacy: 'stateless', + }); + const response = await handler.fetch(new Request('https://mcp.test/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: meta() } }), + })); + const body = await response.json() as { error?: { message: string } }; + expect(body.error?.message).toMatch(/Mcp-Method header is absent/); + await handler.close?.(); + }); +}); diff --git a/tests/scopes.test.ts b/tests/scopes.test.ts index 4e64543..8c74c37 100644 --- a/tests/scopes.test.ts +++ b/tests/scopes.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { Request } from 'express'; import { createMcpServer } from '../src/server.js'; import { clearScopeCache, isAllowed, resolveScopes, TOOL_SCOPES } from '../src/scopes.js'; diff --git a/tests/server.test.ts b/tests/server.test.ts index 77d0e69..a7845c7 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { Request } from 'express'; import { createMcpServer } from '../src/server.js'; @@ -28,7 +27,7 @@ describe('CPZAI MCP protocol', () => { }); it('negotiates tools, resources, prompts and workflow instructions without an upstream request', async () => { - expect(client.getServerVersion()?.version).toBe('1.3.0'); + expect(client.getServerVersion()?.version).toBe('1.4.0'); expect(client.getInstructions()).toContain('unknown outcome'); expect(client.getServerCapabilities()).toMatchObject({ tools: {}, resources: {}, prompts: {} }); const { tools } = await client.listTools(); diff --git a/tests/tool-search.test.ts b/tests/tool-search.test.ts index baabb4e..f14f601 100644 --- a/tests/tool-search.test.ts +++ b/tests/tool-search.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { Request } from 'express'; import { createMcpServer } from '../src/server.js'; diff --git a/tests/tools.test.ts b/tests/tools.test.ts index c142013..b1f6c5b 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer } from "@modelcontextprotocol/server"; import type { Request } from 'express'; import { registerTools } from '../src/tools.js';