Compare commits

...

8 Commits

Author SHA1 Message Date
Aiden Cline 23a8c34265 chore(core): merge latest v2 into MCP upgrade 2026-09-06 15:53:55 -05:00
Aiden Cline 80dc48e74a chore(core): merge v2 and preserve MCP session metadata 2026-08-28 16:26:57 -05:00
Aiden Cline 59468a56f8 feat(core): support modern MCP with legacy fallback 2026-08-28 01:28:57 -05:00
Aiden Cline 044e065775 fix(core): revert modern MCP implementation 2026-08-27 23:24:34 -05:00
Aiden Cline 9fa8680df4 feat(core): support modern MCP with legacy fallback 2026-08-27 23:15:20 -05:00
Aiden Cline 1a33f0df7b fix(core): preserve valid MCP output schemas in fallback 2026-08-27 22:48:56 -05:00
Aiden Cline 0992cd520e chore(core): merge v2 into MCP SDK upgrade 2026-08-27 19:56:36 -05:00
Aiden Cline eb7beecb9b chore(core): upgrade MCP SDK to v2 2026-08-27 18:36:43 -05:00
18 changed files with 2482 additions and 1052 deletions
+16 -102
View File
@@ -353,7 +353,8 @@
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -389,6 +390,7 @@
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@modelcontextprotocol/server": "2.0.0",
"@opencode-ai/http-recorder": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -1046,8 +1048,8 @@
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -1832,8 +1834,6 @@
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="],
"@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="],
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
@@ -1992,7 +1992,11 @@
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.1", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -3288,8 +3292,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -3446,8 +3448,6 @@
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
@@ -3490,8 +3490,6 @@
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
@@ -3606,9 +3604,7 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -3616,14 +3612,10 @@
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
@@ -3772,8 +3764,6 @@
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@@ -3846,8 +3836,6 @@
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3886,8 +3874,6 @@
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
@@ -3938,8 +3924,6 @@
"escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
@@ -3962,8 +3946,6 @@
"eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
@@ -3982,10 +3964,6 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"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=="],
"express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="],
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
"ext-list": ["ext-list@2.2.2", "", { "dependencies": { "mime-db": "^1.28.0" } }, "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA=="],
@@ -4032,8 +4010,6 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "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" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
@@ -4058,14 +4034,10 @@
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
@@ -4240,8 +4212,6 @@
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
@@ -4288,8 +4258,6 @@
"ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
@@ -4354,8 +4322,6 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -4606,12 +4572,8 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="],
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"mermaid": ["mermaid@11.17.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "fastdom": "1.0.12", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="],
@@ -4830,8 +4792,6 @@
"oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -4902,8 +4862,6 @@
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@@ -5026,8 +4984,6 @@
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
@@ -5050,10 +5006,6 @@
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="],
"react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
@@ -5204,8 +5156,6 @@
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -5240,8 +5190,6 @@
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"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=="],
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
@@ -5252,16 +5200,12 @@
"seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
"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=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -5384,8 +5328,6 @@
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -5516,8 +5458,6 @@
"toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
@@ -5560,8 +5500,6 @@
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@@ -5638,8 +5576,6 @@
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="],
@@ -5670,8 +5606,6 @@
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -6160,9 +6094,13 @@
"@mdx-js/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="],
"@modelcontextprotocol/client/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.9", "", {}, "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA=="],
"@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@npmcli/arborist/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
@@ -6424,8 +6362,6 @@
"babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
@@ -6496,8 +6432,6 @@
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"ffi-rs/@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OueBlUFBT9IwD9pQnoYs0UszRBEySskfrEPXXfvKfGjL/DXnfn6kUheQ3oIP6sSmshVGNQUwrTCPo6feAa4QjA=="],
@@ -6620,8 +6554,6 @@
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
@@ -6676,8 +6608,6 @@
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
@@ -7016,32 +6946,24 @@
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
"@octokit/auth-app/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="],
"@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="],
"@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="],
"@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -7052,8 +6974,6 @@
"@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
@@ -7836,20 +7756,14 @@
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="],
"@opencode-ai/enterprise/@tailwindcss/vite/@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
+1 -1
View File
@@ -175,9 +175,9 @@
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
+3 -1
View File
@@ -81,6 +81,7 @@
}
},
"devDependencies": {
"@modelcontextprotocol/server": "2.0.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
@@ -112,7 +113,8 @@
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/vercel": "2.0.39",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
+178 -90
View File
@@ -2,29 +2,25 @@ export * as McpClient from "./client.js"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ElicitationCompleteNotificationSchema,
ElicitRequestSchema,
Client,
LOG_LEVEL_META_KEY,
SdkHttpError,
StreamableHTTPClientTransport,
UnauthorizedError,
type OAuthClientProvider,
type Transport,
type Tool,
type Implementation,
type ElicitRequestFormParams,
type ElicitRequestParams,
type ElicitRequestURLParams,
type ElicitResult,
ListRootsRequestSchema,
ListToolsResultSchema,
PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
ToolListChangedNotificationSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
type SubscriptionFilter,
} from "@modelcontextprotocol/client"
import { DefaultJsonSchemaValidator } from "@modelcontextprotocol/client/_shims"
import { ListToolsResultSchema, ToolSchema } from "@modelcontextprotocol/core"
import { Cause, Effect, Exit, Schedule, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { McpStdio } from "./stdio.js"
@@ -34,11 +30,17 @@ const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
// only that field so a single bad schema doesn't blank out the whole tool list.
// Drop only structurally malformed outputSchema fields; compilation is checked after listing.
// All other tool fields and the response envelope keep their SDK validation.
const TolerantListToolsResult = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
tools: ToolSchema.extend({ outputSchema: ToolSchema.shape.outputSchema.catch(undefined) })
.transform((tool) => {
if (tool.outputSchema === undefined) delete tool.outputSchema
return tool
})
.array(),
})
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
}) {
@@ -113,7 +115,12 @@ export interface CallToolResult {
}
export type ElicitationFormParams = ElicitRequestFormParams
export type ElicitationParams = ElicitRequestParams
export type ElicitationParams =
| ElicitationFormParams
| (Omit<ElicitRequestURLParams, "elicitationId"> & {
/** Legacy servers may identify a URL request for an optional completion notification. */
readonly elicitationId?: string
})
export type ElicitationResult = ElicitResult
export interface ElicitationHandler {
@@ -136,7 +143,7 @@ export interface LogMessage {
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
export interface Connection {
/** Server-supplied usage instructions from the initialize result, if any. */
/** Server-supplied usage instructions from discovery or initialization, if any. */
readonly instructions: string | undefined
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
@@ -186,38 +193,61 @@ export const connect = Effect.fnUntraced(function* (
elicitation?: ElicitationHandler,
clientInfo: Implementation = { name: "opencode", version: "unknown" },
) {
const initialize = Effect.fnUntraced(function* (transport: Transport) {
const validator = new DefaultJsonSchemaValidator()
const startupTimeout = config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT
const connectClient = Effect.fnUntraced(function* (transport: Transport, probeTransport?: Transport) {
const client = new Client(clientInfo, {
versionNegotiation: {
mode: "auto",
// Silent legacy stdio servers must leave time for the real initialize handshake.
probe: { timeoutMs: config.type === "local" ? Math.min(3_000, startupTimeout / 2) : startupTimeout },
},
jsonSchemaValidator: validator,
capabilities: {
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
// https://github.com/anomalyco/opencode/issues/2308
roots: {},
},
})
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
)
client.setRequestHandler("roots/list", () => Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }))
if (elicitation) {
client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
client.setRequestHandler("elicitation/create", (request, ctx) =>
Effect.runPromise(
elicitation.create({
server,
params: request.params,
signal: ctx.mcpReq.signal,
}),
{ signal: ctx.mcpReq.signal },
),
)
client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
client.setNotificationHandler("notifications/elicitation/complete", (notification) =>
Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
)
}
const abort = new AbortController()
const connecting = client.connect(transport, { timeout: startupTimeout, signal: abort.signal, probeTransport })
yield* Effect.tryPromise({
try: (signal) =>
client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
catch: (error) => error,
}).pipe(Effect.onError(() => Effect.promise(() => transport.close()).pipe(Effect.ignore)))
try: () => connecting,
catch: toError,
}).pipe(
Effect.onError(() =>
Effect.promise(async () => {
abort.abort()
await transport.close().catch(() => {})
// Effect interruption stops waiting on the promise; join the SDK's probe cleanup too.
await connecting.catch(() => {})
}),
),
)
return client
})
const exit = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
const transport = yield* McpStdio.make({
const options: McpStdio.Options = {
server,
command,
args,
@@ -226,8 +256,10 @@ export const connect = Effect.fnUntraced(function* (
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...config.environment,
},
})
return yield* initialize(transport)
}
const transport = yield* McpStdio.make(options)
const probeTransport = yield* McpStdio.make(options)
return yield* connectClient(transport, probeTransport)
}
if (!URL.canParse(config.url))
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
@@ -238,7 +270,7 @@ export const connect = Effect.fnUntraced(function* (
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
if (addedCodemode) url.searchParams.set("codemode", "false")
const open = (url: URL) =>
initialize(
connectClient(
new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
@@ -248,13 +280,16 @@ export const connect = Effect.fnUntraced(function* (
return yield* open(url).pipe(
Effect.catch((error) => {
if (!addedCodemode || !(error instanceof StreamableHTTPError) || (error.code !== 400 && error.code !== 404))
if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
return Effect.fail(error)
// Some servers reject unknown query params. Retry once with the user's original URL.
return open(new URL(config.url))
}),
)
}).pipe(Effect.exit)
}).pipe(
Effect.timeoutOrElse({ duration: startupTimeout, orElse: () => Effect.fail(new Error("MCP startup timed out")) }),
Effect.exit,
)
if (Exit.isSuccess(exit)) {
const client = exit.value
// Closing the client closes the transport, which ends stdin and then kills through the spawner
@@ -262,48 +297,106 @@ export const connect = Effect.fnUntraced(function* (
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore))
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
const executionDeadline = Effect.timeoutOrElse({
duration: executionTimeout,
orElse: () => Effect.fail(new Error("Request timed out")),
})
const modern = client.getProtocolEra() === "modern"
const meta = modern ? { [LOG_LEVEL_META_KEY]: "debug" } : undefined
const definitions = new Map<string, Tool>()
if (modern) {
const capabilities = client.getServerCapabilities()
const filter = {
toolsListChanged: capabilities?.tools?.listChanged,
promptsListChanged: capabilities?.prompts?.listChanged,
resourcesListChanged: capabilities?.resources?.listChanged,
} satisfies SubscriptionFilter
if (filter.toolsListChanged || filter.promptsListChanged || filter.resourcesListChanged) {
yield* Effect.scoped(
Effect.gen(function* () {
const subscription = yield* Effect.acquireRelease(
Effect.tryPromise({
try: (signal) => client.listen(filter, { signal, timeout: catalogTimeout }),
catch: toError,
}),
(subscription) => Effect.promise(() => subscription.close()).pipe(Effect.ignore),
{ interruptible: true },
)
const honored = subscription.honoredFilter
if (
(filter.toolsListChanged && !honored.toolsListChanged) ||
(filter.promptsListChanged && !honored.promptsListChanged) ||
(filter.resourcesListChanged && !honored.resourcesListChanged)
) {
yield* Effect.logWarning("MCP server did not honor all catalog subscriptions", { server })
}
const reason = yield* Effect.promise(() => subscription.closed)
if (reason === "remote") return yield* Effect.fail(new Error("MCP catalog subscription disconnected"))
}),
).pipe(
Effect.tapError((error) =>
Effect.logWarning("MCP catalog subscription failed", { server, error: error.message }),
),
Effect.retry(Schedule.spaced("1 second")),
Effect.forkScoped,
)
}
}
return {
instructions: client.getInstructions()?.trim() || undefined,
tools: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.tools) return []
const tools = yield* Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
try {
return await client.listTools(params, { timeout: catalogTimeout })
} catch (error) {
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
timeout: catalogTimeout,
})
}
},
(result) => result.tools,
),
try: async (signal) => {
try {
return (await client.listTools(undefined, { signal, timeout: catalogTimeout })).tools
} catch (error) {
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
return paginate(
(cursor) =>
client.request(
{ method: "tools/list", params: cursor === undefined ? undefined : { cursor } },
TolerantListToolsResult,
{
signal,
timeout: catalogTimeout,
},
),
(result) => result.tools,
)
}
},
catch: toError,
}).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })),
)
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: "outputSchema" in tool ? tool.outputSchema : undefined,
}))
definitions.clear()
return tools.map((tool) => {
const definition: Tool = { ...tool }
// V2 compiles output validators at call time. Check them here so malformed schemas
// remain usable without catching and potentially replaying a failed tool invocation.
if (definition.outputSchema !== undefined) {
try {
validator.getValidator(definition.outputSchema)
} catch {
delete definition.outputSchema
}
}
definitions.set(tool.name, definition)
return {
name: definition.name,
description: definition.description,
inputSchema: definition.inputSchema,
outputSchema: definition.outputSchema,
}
})
}),
prompts: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.prompts) return []
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.prompts,
),
try: async (signal) => (await client.listPrompts(undefined, { signal, timeout: catalogTimeout })).prompts,
catch: toError,
}).pipe(
Effect.tapError((error) =>
@@ -324,12 +417,8 @@ export const connect = Effect.fnUntraced(function* (
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const resources = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.resources,
),
try: async (signal) =>
(await client.listResources(undefined, { signal, timeout: catalogTimeout })).resources,
catch: toError,
}).pipe(
Effect.tapError((error) =>
@@ -347,14 +436,8 @@ export const connect = Effect.fnUntraced(function* (
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const templates = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, {
timeout: catalogTimeout,
}),
(result) => result.resourceTemplates,
),
try: async (signal) =>
(await client.listResourceTemplates(undefined, { signal, timeout: catalogTimeout })).resourceTemplates,
catch: toError,
}).pipe(
Effect.tapError((error) =>
@@ -372,7 +455,8 @@ export const connect = Effect.fnUntraced(function* (
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return undefined
const result = yield* Effect.tryPromise({
try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }),
try: (signal) =>
client.readResource({ uri: input.uri, _meta: meta }, { signal, timeout: executionTimeout }),
catch: toError,
}).pipe(
Effect.tapError((error) =>
@@ -387,13 +471,17 @@ export const connect = Effect.fnUntraced(function* (
: { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType },
),
}
}),
}).pipe(executionDeadline),
prompt: (input) =>
Effect.tryPromise({
try: (signal) =>
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
client.getPrompt(
{ name: input.name, arguments: input.args ?? {}, _meta: meta },
{ signal, timeout: executionTimeout },
),
catch: toError,
}).pipe(
executionDeadline,
Effect.map((result) => ({
messages: result.messages.map((message) => ({ role: message.role, content: message.content })),
})),
@@ -405,14 +493,14 @@ export const connect = Effect.fnUntraced(function* (
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
_meta: input.sessionID === undefined ? meta : { ...meta, sessionID: input.sessionID },
},
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
{ signal, timeout: executionTimeout, onprogress: () => {}, toolDefinition: definitions.get(input.name) },
),
catch: toError,
}).pipe(
executionDeadline,
Effect.map((result) => ({
isError: result.isError === true,
structured: result.structuredContent,
@@ -437,19 +525,19 @@ export const connect = Effect.fnUntraced(function* (
client.onclose = callback
},
onLog: (callback) => {
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
client.setNotificationHandler("notifications/message", (notification) => callback(notification.params))
},
onToolsChanged: (callback) => {
if (!client.getServerCapabilities()?.tools?.listChanged) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
client.setNotificationHandler("notifications/tools/list_changed", async () => callback())
},
onPromptsChanged: (callback) => {
if (!client.getServerCapabilities()?.prompts?.listChanged) return
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
client.setNotificationHandler("notifications/prompts/list_changed", async () => callback())
},
onResourcesChanged: (callback) => {
if (!client.getServerCapabilities()?.resources?.listChanged) return
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => callback())
client.setNotificationHandler("notifications/resources/list_changed", async () => callback())
},
} satisfies Connection
}
+20 -5
View File
@@ -358,7 +358,15 @@ export const layer = (options?: Options) =>
const oauth = await readOAuthCredential()
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
},
saveClientInformation: async () => {},
saveClientInformation: async (client) => {
const oauth = await readOAuthCredential()
if (!oauth) return
await run(
credentials.update(credentialID, {
value: Credential.OAuth.make({ ...oauth, metadata: { ...oauth.metadata, client } }),
}),
)
},
codeVerifier: async () => undefined,
saveCodeVerifier: async () => {},
},
@@ -374,8 +382,11 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
if (input.params.mode === "url") {
const formID = Form.ID.create()
const key = input.server + "\u0000" + input.params.elicitationId
urlElicitations.set(key, formID)
const key =
input.params.elicitationId === undefined
? undefined
: input.server + "\u0000" + input.params.elicitationId
if (key !== undefined) urlElicitations.set(key, formID)
return yield* forms
.ask({
id: formID,
@@ -384,14 +395,18 @@ export const layer = (options?: Options) =>
metadata: {
kind: "mcp-elicitation",
server: input.server,
elicitationID: input.params.elicitationId,
...(input.params.elicitationId === undefined ? {} : { elicitationID: input.params.elicitationId }),
message: input.params.message,
},
fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
Effect.ensuring(
Effect.sync(() => {
if (key !== undefined) urlElicitations.delete(key)
}),
),
Effect.map(
(state): McpClient.ElicitationResult => ({
action: state.status === "answered" ? "accept" : "cancel",
+41 -20
View File
@@ -1,8 +1,14 @@
export * as McpOAuth from "./oauth.js"
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
import {
auth,
parseErrorResponse,
type FetchLike,
type OAuthClientProvider,
type OAuthDiscoveryState,
type StoredOAuthClientInformation,
type StoredOAuthTokens,
} from "@modelcontextprotocol/client"
import { Cause, Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
@@ -26,7 +32,7 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
if (operation && !response.ok) {
// Only retain the SDK's standard error code. Descriptions and raw bodies can echo credentials.
const error = yield* Effect.tryPromise(async () => parseErrorResponse(await response.clone().text())).pipe(
Effect.map((error) => error.errorCode),
Effect.map((error) => error.code),
Effect.orElseSucceed(() => "unreadable_response"),
)
yield* Effect.logWarning("mcp oauth request rejected", { ...result, error })
@@ -59,14 +65,16 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
return request
})
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
/** Persists the OAuth artifacts for one MCP server session. Discovery state must live as long as PKCE. */
export interface Store {
readonly tokens: () => Promise<OAuthTokens | undefined>
readonly saveTokens: (tokens: OAuthTokens) => Promise<void>
readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined>
readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void>
readonly tokens: () => Promise<StoredOAuthTokens | undefined>
readonly saveTokens: (tokens: StoredOAuthTokens) => Promise<void>
readonly clientInformation: () => Promise<StoredOAuthClientInformation | undefined>
readonly saveClientInformation: (info: StoredOAuthClientInformation) => Promise<void>
readonly codeVerifier: () => Promise<string | undefined>
readonly saveCodeVerifier: (verifier: string) => Promise<void>
readonly discoveryState?: () => Promise<OAuthDiscoveryState | undefined>
readonly saveDiscoveryState?: (state: OAuthDiscoveryState) => Promise<void>
}
export interface Options {
@@ -108,11 +116,16 @@ export const provider = (options: Options): OAuthClientProvider => {
// provider has no redirect to validate, so it omits it.
...(state !== undefined ? { state: () => state } : {}),
// Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist.
clientInformation: () =>
client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(),
clientInformation: async () => {
const stored = await options.store.clientInformation()
if (!client || (stored?.client_id === client.id && stored.client_secret === client.secret)) return stored
return { client_id: client.id, client_secret: client.secret }
},
saveClientInformation: (info) => options.store.saveClientInformation(info),
tokens: () => options.store.tokens(),
saveTokens: (tokens) => options.store.saveTokens(tokens),
discoveryState: options.store.discoveryState,
saveDiscoveryState: options.store.saveDiscoveryState,
redirectToAuthorization: (url) => options.onRedirect(url),
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
@@ -128,9 +141,10 @@ export const provider = (options: Options): OAuthClientProvider => {
/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */
export const memoryStore = (): Store => {
let tokens: OAuthTokens | undefined
let client: OAuthClientInformationMixed | undefined
let tokens: StoredOAuthTokens | undefined
let client: StoredOAuthClientInformation | undefined
let verifier: string | undefined
let discovery: OAuthDiscoveryState | undefined
return {
tokens: async () => tokens,
saveTokens: async (value) => {
@@ -144,19 +158,23 @@ export const memoryStore = (): Store => {
saveCodeVerifier: async (value) => {
verifier = value
},
discoveryState: async () => discovery,
saveDiscoveryState: async (value) => {
discovery = value
},
}
}
/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */
export const clientFromCredential = (credential: Credential.OAuth) =>
credential.metadata?.client as OAuthClientInformationMixed | undefined
credential.metadata?.client as StoredOAuthClientInformation | undefined
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
export const toCredential = (input: {
readonly methodID: Integration.MethodID
readonly serverUrl: string
readonly tokens: OAuthTokens
readonly client: OAuthClientInformationMixed | undefined
readonly tokens: StoredOAuthTokens
readonly client: StoredOAuthClientInformation | undefined
}) =>
Credential.OAuth.make({
type: "oauth",
@@ -168,17 +186,19 @@ export const toCredential = (input: {
metadata: {
serverUrl: input.serverUrl,
tokenType: input.tokens.token_type,
...(input.tokens.issuer !== undefined ? { issuer: input.tokens.issuer } : {}),
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
...(input.client ? { client: input.client } : {}),
},
})
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
export const toTokens = (credential: Credential.OAuth): StoredOAuthTokens => {
const metadata = credential.metadata ?? {}
return {
access_token: credential.access,
token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer",
...(typeof metadata.issuer === "string" ? { issuer: metadata.issuer } : {}),
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
@@ -204,7 +224,7 @@ export const authorize = (input: {
yield* Effect.logInfo("mcp oauth authorization started", fields)
const oauth = input.config.oauth || undefined
const store = memoryStore()
const code = yield* Deferred.make<string, Error>()
const code = yield* Deferred.make<{ code: string; iss: string | undefined }, Error>()
const redirect = oauth?.redirect_uri ? new URL(oauth.redirect_uri) : undefined
const redirectPath = redirect?.pathname ?? "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
@@ -231,7 +251,7 @@ export const authorize = (input: {
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code", "missing_code")
Effect.runFork(Deferred.succeed(code, value))
Effect.runFork(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined }))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
@@ -295,7 +315,8 @@ export const authorize = (input: {
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value,
authorizationCode: value.code,
iss: value.iss,
scope: oauth?.scope,
fetchFn,
}),
+5 -4
View File
@@ -1,8 +1,6 @@
export * as McpStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
import { ReadBuffer, serializeMessage, type Transport, type JSONRPCMessage } from "@modelcontextprotocol/client"
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
@@ -47,7 +45,8 @@ export const make = Effect.fnUntraced(function* (options: Options) {
// Outgoing frames are queued rather than written to `handle.stdin` directly: the sink closes the
// stream it is run with, and stdin must stay open across the whole session.
const outgoing = yield* Queue.bounded<string, Cause.Done>(OUTGOING_CAPACITY)
const buffer = new ReadBuffer()
// The per-frame limit below also allows a chunk containing several complete frames.
const buffer = new ReadBuffer({ maxBufferSize: Infinity })
const state: { phase: "ready" | "starting" | "open" | "closed"; handle?: ChildProcessHandle } = { phase: "ready" }
let startup: Promise<void> | undefined
let closing: Promise<void> | undefined
@@ -74,6 +73,8 @@ export const make = Effect.fnUntraced(function* (options: Options) {
))
const transport: Transport = {
// Stdio has one shared stream; cancellation uses a notification, not an HTTP stream abort.
hasPerRequestStream: false,
start: () => {
if (state.phase !== "ready") return Promise.reject(new Error("Stdio transport already started"))
state.phase = "starting"
@@ -1,10 +1,9 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { Server } from "@modelcontextprotocol/server"
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
const server = new Server({ name: "output-schema", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
server.setRequestHandler("tools/list", ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? {
+4 -5
View File
@@ -1,10 +1,9 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { Server } from "@modelcontextprotocol/server"
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
const server = new Server({ name: "prompts", version: "1.0.0" }, { capabilities: { prompts: {} } })
server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
server.setRequestHandler("prompts/list", ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? { prompts: [{ name: "second", description: "Second prompt" }] }
@@ -21,7 +20,7 @@ server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
),
)
server.setRequestHandler(GetPromptRequestSchema, ({ params }) =>
server.setRequestHandler("prompts/get", ({ params }) =>
Promise.resolve({
messages: [{ role: "user", content: { type: "text", text: params.arguments?.topic ?? "missing" } }],
}),
@@ -0,0 +1,66 @@
import { appendFileSync } from "node:fs"
import { Server, isJSONRPCRequest, inputRequired } from "@modelcontextprotocol/server"
import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio"
const mode = process.argv[2]
const log = process.argv[3]
const record = (method: string) => appendFileSync(log, JSON.stringify({ pid: process.pid, method }) + "\n")
record("spawn")
const transport = new StdioServerTransport()
const start = transport.start.bind(transport)
transport.start = async () => {
const receive = transport.onmessage
transport.onmessage = (message) => {
if ("method" in message) record(message.method)
if ("method" in message && message.method === "initialize" && mode === "legacy-hang") return
if (isJSONRPCRequest(message) && message.method === "server/discover") {
if (mode === "legacy-exit") process.exit(1)
if (mode === "legacy-silent" || mode === "stall") return
if (mode === "legacy-error") {
void transport.send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "Initialize first" } })
return
}
}
receive?.(message)
}
await start()
}
const factory = () => {
const server = new Server(
{ name: "stdio-protocol", version: "1" },
{
capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, resources: { listChanged: true } },
instructions: "stdio instructions",
},
)
server.setRequestHandler("tools/list", async () => ({ tools: [{ name: "echo", inputSchema: { type: "object" } }] }))
server.setRequestHandler("tools/call", async (request, ctx) => {
if (request.params.arguments?.slow) {
record("executing")
await new Promise<void>((resolve) =>
ctx.mcpReq.signal.addEventListener(
"abort",
() => {
record("aborted")
resolve()
},
{ once: true },
),
)
}
if (!ctx.mcpReq.inputResponses)
return inputRequired({ inputRequests: { roots: inputRequired.listRoots() }, requestState: "stdio-state" })
await server.sendToolListChanged()
await server.sendPromptListChanged()
await server.sendResourceListChanged()
return {
content: [{ type: "text", text: "stdio complete" }],
structuredContent: { ...ctx.mcpReq.inputResponses, sessionID: request.params._meta?.sessionID },
}
})
return server
}
if (mode.startsWith("legacy")) await factory().connect(transport)
else serveStdio(factory, { transport, legacy: mode === "dual" ? "serve" : "reject" })
+9 -18
View File
@@ -1,39 +1,30 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Server } from "@modelcontextprotocol/server"
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
const server = new Server(
{ name: "timeout", version: "1.0.0" },
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
server.setRequestHandler("tools/list", async () => {
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
})
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler(ListResourcesRequestSchema, async () => {
server.setRequestHandler("prompts/list", () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler("resources/list", async () => {
if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100)
return { resources: [{ name: "slow", uri: "test://slow" }] }
})
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] }))
server.setRequestHandler(CallToolRequestSchema, async () => {
server.setRequestHandler("resources/templates/list", () => Promise.resolve({ resourceTemplates: [] }))
server.setRequestHandler("tools/call", async () => {
await Bun.sleep(100)
return { content: [] }
})
server.setRequestHandler(GetPromptRequestSchema, async () => {
server.setRequestHandler("prompts/get", async () => {
await Bun.sleep(100)
return { messages: [] }
})
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
server.setRequestHandler("resources/read", async (request) => {
await Bun.sleep(100)
return { contents: [{ uri: request.params.uri, text: "slow" }] }
})
+173 -3
View File
@@ -1,10 +1,11 @@
import { afterAll, describe, expect, test } from "bun:test"
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { auth, IssuerMismatchError, refreshAuthorization } from "@modelcontextprotocol/client"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/core/integration"
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
import { Effect } from "effect"
import { Effect, Logger } from "effect"
import { it } from "./lib/effect"
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
afterAll(() => authServer.stop(true))
@@ -28,6 +29,80 @@ const authorize = (redirect_uri?: string) =>
)
describe("MCP OAuth", () => {
it.live("logs OAuth rejection codes without consuming the response or logging credentials", () =>
Effect.gen(function* () {
const body = { error: "invalid_grant", error_description: "rejected private-refresh-token" }
const server = yield* Effect.acquireRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch: () => Response.json(body, { status: 400 }) })),
(server) => Effect.promise(() => server.stop(true)),
)
const logged: unknown[] = []
const response = yield* Effect.gen(function* () {
const request = yield* McpOAuth.loggedFetch({ server: "test" })
return yield* Effect.promise(() =>
request(server.url, {
method: "POST",
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: "private-refresh-token" }),
}),
)
}).pipe(
Effect.provideService(Logger.CurrentLoggers, new Set([Logger.make((entry) => logged.push(entry.message))])),
)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toEqual(body)
expect(JSON.stringify(logged)).toContain("invalid_grant")
expect(JSON.stringify(logged)).not.toContain("private-refresh-token")
}),
)
test("retains issuer stamps for configured clients without overriding changed configuration", async () => {
const store = McpOAuth.memoryStore()
const provider = McpOAuth.provider({
redirectUrl: "http://127.0.0.1/callback",
client: { id: "client", secret: "secret" },
onRedirect: () => {},
store,
})
const client = { client_id: "client", client_secret: "secret", issuer: "https://auth.example.com" }
await store.saveClientInformation(client)
expect(await provider.clientInformation()).toEqual(client)
await store.saveClientInformation({ ...client, client_secret: "old-secret" })
expect(await provider.clientInformation()).toEqual({ client_id: "client", client_secret: "secret" })
})
test("round-trips issuer stamps on tokens and registered clients", () => {
const tokens = {
access_token: "access",
refresh_token: "refresh",
token_type: "Bearer",
scope: "read",
issuer: "https://auth.example.com",
}
const client = { client_id: "client", issuer: tokens.issuer }
const credential = McpOAuth.toCredential({
methodID: Integration.MethodID.make("oauth"),
serverUrl: "https://mcp.example.com",
tokens,
client,
})
expect(McpOAuth.toTokens(credential)).toEqual(tokens)
expect(McpOAuth.clientFromCredential(credential)).toEqual(client)
})
test("does not invent an issuer for legacy credentials", () => {
const tokens = { access_token: "access", token_type: "Bearer" }
const credential = McpOAuth.toCredential({
methodID: Integration.MethodID.make("oauth"),
serverUrl: "https://mcp.example.com",
tokens,
client: undefined,
})
expect(McpOAuth.toTokens(credential)).toEqual(tokens)
expect(credential.metadata).not.toHaveProperty("issuer")
})
test("completes interactive authorization through the loopback callback", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
@@ -112,7 +187,12 @@ describe("MCP OAuth", () => {
const result = await auth(oauthProvider, { serverUrl: server.url.href }).finally(() => server.stop(true))
expect(result).toBe("AUTHORIZED")
expect(await store.tokens()).toEqual({ access_token: "next", token_type: "Bearer", refresh_token: "refresh" })
expect(await store.tokens()).toEqual({
access_token: "next",
token_type: "Bearer",
refresh_token: "refresh",
issuer: server.url.href,
})
expect(tokenRequests).toHaveLength(1)
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
expect(tokenRequests[0]?.get("refresh_token")).toBe("refresh")
@@ -164,4 +244,94 @@ describe("MCP OAuth", () => {
test("rejects an invalid redirect URL", async () => {
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
})
;[
{ name: "accepts a matching issuer", iss: "matching", supported: true, state: true },
{ name: "accepts an omitted issuer when not required", iss: "missing", supported: false, state: true },
{ name: "rejects a mismatched issuer even when optional", iss: "mismatch", supported: false, state: true },
{ name: "rejects an omitted required issuer", iss: "missing", supported: true, state: true },
{ name: "rejects a mismatched state", iss: "matching", supported: true, state: false },
].forEach((input) => {
it.live(
input.name,
Effect.gen(function* () {
let discoveries = 0
const exchanges: URLSearchParams[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
const issuer = url.origin
if (url.pathname === "/.well-known/oauth-protected-resource")
return Response.json({
resource: `${issuer}/mcp`,
authorization_servers: [issuer],
scopes_supported: ["tools:read"],
})
if (url.pathname === "/.well-known/oauth-authorization-server") {
discoveries++
return Response.json({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
response_types_supported: ["code"],
scopes_supported: ["tools:read", "offline_access"],
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: input.supported,
})
}
if (url.pathname === "/token") {
exchanges.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "access", token_type: "Bearer", refresh_token: "refresh" })
}
return new Response(null, { status: 404 })
},
}),
),
(server) => Effect.sync(() => server.stop(true)),
)
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
url: new URL("/mcp", server.url).href,
oauth: { client_id: "client" },
}),
methodID: Integration.MethodID.make("oauth"),
})
const url = new URL(authorization.url)
expect(url.searchParams.get("scope")).toBe("tools:read offline_access")
expect(url.searchParams.get("prompt")).toBe("consent")
const callback = new URL(url.searchParams.get("redirect_uri") ?? "")
const state = url.searchParams.get("state")
expect(state).toBeTruthy()
callback.searchParams.set("code", "code")
callback.searchParams.set("state", input.state ? (state ?? "") : "incorrect")
if (input.iss !== "missing")
callback.searchParams.set("iss", input.iss === "matching" ? server.url.origin : "https://other.example.com")
const response = yield* Effect.promise(() => fetch(callback))
expect(response.status).toBe(input.state ? 200 : 400)
const result = yield* Effect.result(authorization.callback)
expect(discoveries).toBe(1)
if (!input.state) {
expect(result).toMatchObject({ _tag: "Failure", failure: { message: "OAuth state mismatch" } })
expect(exchanges).toHaveLength(0)
return
}
if (input.iss === "mismatch" || (input.iss === "missing" && input.supported)) {
expect(result).toMatchObject({ _tag: "Failure", failure: expect.any(IssuerMismatchError) })
expect(exchanges).toHaveLength(0)
return
}
expect(result).toMatchObject({
_tag: "Success",
success: { access: "access", metadata: { issuer: server.url.origin } },
})
expect(exchanges).toHaveLength(1)
expect(exchanges[0]?.get("code")).toBe("code")
expect(exchanges[0]?.get("code_verifier")).toBeTruthy()
}),
)
})
})
@@ -0,0 +1,133 @@
import path from "node:path"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { expect } from "bun:test"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Session } from "@opencode-ai/schema/session"
import { McpClient } from "@opencode-ai/core/mcp/client"
import { Effect, Fiber, Schedule, Schema } from "effect"
import { testEffect } from "./lib/effect"
import { hostEnvironmentLayer } from "./fixture/environment"
const it = testEffect(hostEnvironmentLayer)
const row = Schema.fromJsonString(Schema.Struct({ pid: Schema.Number, method: Schema.String }))
const read = (log: string) =>
Effect.promise(() => Bun.file(log).text()).pipe(
Effect.map((text) =>
text
.trim()
.split("\n")
.map((line) => Schema.decodeUnknownSync(row)(line)),
),
)
const fixture = Effect.acquireRelease(
Effect.promise(() => mkdtemp(path.join(tmpdir(), "opencode-mcp-protocol-"))),
(directory) => Effect.promise(() => rm(directory, { force: true, recursive: true })),
)
function config(mode: string, log: string, startup = 4_000, execution = 1_000) {
return new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-protocol-stdio.ts"), mode, log],
timeout: { startup, execution },
})
}
for (const mode of ["modern", "dual", "legacy-exit", "legacy-error", "legacy-silent"]) {
it.live(`stdio ${mode} uses a disposable probe and the correct live protocol`, () =>
Effect.gen(function* () {
const log = path.join(yield* fixture, "messages.jsonl")
const modern = mode === "modern" || mode === "dual"
yield* Effect.scoped(
Effect.gen(function* () {
const connection = yield* McpClient.connect("stdio", config(mode, log), import.meta.dir)
expect(connection.instructions).toBe("stdio instructions")
const toolsChanged = Promise.withResolvers<void>()
const promptsChanged = Promise.withResolvers<void>()
const resourcesChanged = Promise.withResolvers<void>()
connection.onToolsChanged(toolsChanged.resolve)
connection.onPromptsChanged(promptsChanged.resolve)
connection.onResourcesChanged(resourcesChanged.resolve)
expect((yield* connection.tools()).map((tool) => tool.name)).toEqual(["echo"])
const result = yield* connection.callTool({ name: "echo", sessionID: Session.ID.make("ses_mcp_stdio") })
expect(result.content).toEqual([{ type: "text", text: "stdio complete" }])
expect(result.structured).toMatchObject({ roots: { roots: [{ uri: expect.stringContaining("file://") }] } })
expect(result.structured).toHaveProperty("sessionID", "ses_mcp_stdio")
yield* Effect.promise(() =>
Promise.all([toolsChanged.promise, promptsChanged.promise, resourcesChanged.promise]),
).pipe(Effect.timeout("1 second"))
const requests = yield* read(log)
const pids = requests.filter((entry) => entry.method === "spawn").map((entry) => entry.pid)
expect(pids).toHaveLength(2)
expect(pids[0]).not.toBe(pids[1])
expect(requests.filter((entry) => entry.pid === pids[0]).map((entry) => entry.method)).toEqual([
"spawn",
"server/discover",
])
const live = requests.filter((entry) => entry.pid === pids[1]).map((entry) => entry.method)
expect(live.includes("server/discover")).toBe(false)
expect(live.includes("initialize")).toBe(!modern)
expect(live.includes("notifications/initialized")).toBe(!modern)
expect(live.includes("subscriptions/listen")).toBe(modern)
}),
)
for (const entry of (yield* read(log)).filter((entry) => entry.method === "spawn")) {
expect(() => process.kill(entry.pid, 0)).toThrow()
}
}),
)
}
it.live("stdio startup interruption reaps the probe without launching a session process", () =>
Effect.gen(function* () {
const log = path.join(yield* fixture, "messages.jsonl")
const fiber = yield* McpClient.connect("stdio", config("stall", log, 5_000), import.meta.dir).pipe(
Effect.forkScoped,
)
yield* Effect.promise(() => Bun.file(log).exists()).pipe(
Effect.repeat({ while: (exists) => !exists, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("2 seconds"),
)
yield* Fiber.interrupt(fiber)
const requests = yield* read(log)
const spawns = requests.filter((entry) => entry.method === "spawn")
expect(spawns).toHaveLength(1)
expect(requests.some((entry) => entry.method === "initialize")).toBe(false)
for (const entry of spawns) expect(() => process.kill(entry.pid, 0)).toThrow()
}),
)
it.live("modern stdio timeout sends cancellation and does not resume MRTR", () =>
Effect.gen(function* () {
const log = path.join(yield* fixture, "messages.jsonl")
yield* Effect.scoped(
Effect.gen(function* () {
const connection = yield* McpClient.connect("stdio", config("modern", log, 4_000, 150), import.meta.dir)
yield* connection.tools()
const error = yield* connection.callTool({ name: "echo", args: { slow: true } }).pipe(Effect.flip)
expect(error.message).toBe("Request timed out")
}),
)
const requests = yield* read(log)
expect(requests.filter((entry) => entry.method === "tools/call")).toHaveLength(1)
expect(requests.some((entry) => entry.method === "notifications/cancelled")).toBe(true)
expect(requests.some((entry) => entry.method === "aborted")).toBe(true)
}),
)
it.live("startup deadline closes both processes without cancelling initialize", () =>
Effect.gen(function* () {
const log = path.join(yield* fixture, "messages.jsonl")
const error = yield* McpClient.connect("stdio", config("legacy-hang", log, 1_500), import.meta.dir).pipe(
Effect.flip,
)
expect(error.message).toBe("MCP startup timed out")
const requests = yield* read(log)
expect(requests.filter((entry) => entry.method === "spawn")).toHaveLength(2)
expect(requests.some((entry) => entry.method === "initialize")).toBe(true)
expect(requests.some((entry) => entry.method === "notifications/cancelled")).toBe(false)
for (const entry of requests.filter((entry) => entry.method === "spawn"))
expect(() => process.kill(entry.pid, 0)).toThrow()
}),
)
+581
View File
@@ -0,0 +1,581 @@
import { expect } from "bun:test"
import { isJSONRPCRequest, type JSONRPCRequest } from "@modelcontextprotocol/client"
import { JSONRPCMessageSchema } from "@modelcontextprotocol/core"
import { createMcpHandler, Server } from "@modelcontextprotocol/server"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Session } from "@opencode-ai/schema/session"
import { McpClient } from "@opencode-ai/core/mcp/client"
import { Effect, Fiber } from "effect"
import { testEffect } from "./lib/effect"
import { hostEnvironmentLayer } from "./fixture/environment"
const it = testEffect(hostEnvironmentLayer)
const version = "2026-07-28"
const schema = { type: "object" as const, properties: { value: { type: "string" } }, required: ["value"] }
const tool = {
name: "echo",
inputSchema: { type: "object" as const, properties: { value: { type: "string", "x-mcp-header": "Value" } } },
outputSchema: schema,
}
function serve(respond: (message: JSONRPCRequest, request: Request) => Response | Promise<Response>) {
return Effect.acquireRelease(
Effect.sync(() => {
const requests: Array<{ method: string; message?: JSONRPCRequest; headers: Headers }> = []
const http = Bun.serve({
hostname: "127.0.0.1",
port: 0,
idleTimeout: 0,
async fetch(request) {
if (request.method !== "POST") {
requests.push({ method: request.method, headers: request.headers })
return new Response(null, { status: 405 })
}
const message = JSONRPCMessageSchema.parse(await request.clone().json())
requests.push({
method: "method" in message ? message.method : "response",
headers: request.headers,
...(isJSONRPCRequest(message) ? { message } : {}),
})
if (!isJSONRPCRequest(message)) return new Response(null, { status: 202 })
return respond(message, request)
},
})
return {
http,
requests,
config: new ConfigMCP.Remote({
type: "remote",
url: http.url.toString(),
oauth: false,
codemode: false,
timeout: { startup: 1_000 },
}),
}
}),
({ http }) => Effect.promise(() => http.stop(true)),
)
}
function result(message: JSONRPCRequest, value: Record<string, unknown>, modern = true) {
return Response.json({
jsonrpc: "2.0",
id: message.id,
result: { ...(modern ? { resultType: "complete", ttlMs: 0, cacheScope: "private" } : {}), ...value },
})
}
function discover(message: JSONRPCRequest) {
return result(message, {
supportedVersions: [version],
capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, resources: { listChanged: true } },
instructions: " modern instructions ",
ttlMs: 0,
cacheScope: "private",
})
}
for (const status of [200, 400]) {
for (const body of [
"not json",
JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse Error" } }),
]) {
it.live(`falls back on completed non-modern discovery: ${status} ${body}`, () =>
Effect.gen(function* () {
const server = yield* serve((message) => {
if (message.method === "server/discover")
return new Response(body, { status, headers: { "content-type": "application/json" } })
if (message.method === "initialize")
return result(
message,
{
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "legacy", version: "1" },
},
false,
)
return result(message, { tools: [tool] }, false)
})
const connection = yield* McpClient.connect("legacy", server.config, import.meta.dir)
expect((yield* connection.tools()).map((tool) => tool.name)).toEqual(["echo"])
expect(server.requests.filter((entry) => entry.method !== "GET").map((entry) => entry.method)).toEqual([
"server/discover",
"initialize",
"notifications/initialized",
"tools/list",
])
expect(server.requests.find((entry) => entry.method === "initialize")?.headers.has("mcp-method")).toBe(false)
}),
)
}
}
for (const status of [401, 403, 500]) {
it.live(`does not downgrade HTTP ${status} to legacy`, () =>
Effect.gen(function* () {
const server = yield* serve(() => new Response(null, { status }))
yield* McpClient.connect("failure", server.config, import.meta.dir).pipe(Effect.flip)
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover"])
}),
)
}
for (const code of [-32020, -32021, -32022]) {
it.live(`does not downgrade a recognized modern error ${code}`, () =>
Effect.gen(function* () {
const server = yield* serve((message) =>
Response.json(
{
jsonrpc: "2.0",
id: message.id,
error: { code, message: "Modern refusal", data: { supported: ["2099-01-01"], requested: version } },
},
{ status: 400 },
),
)
yield* McpClient.connect("failure", server.config, import.meta.dir).pipe(Effect.flip)
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover"])
}),
)
}
it.live("negotiates a supported modern version after a corrective response", () =>
Effect.gen(function* () {
let probes = 0
const server = yield* serve((message) => {
probes++
if (probes === 1)
return Response.json(
{
jsonrpc: "2.0",
id: message.id,
error: {
code: -32022,
message: "Use supported version",
data: { supported: [version], requested: version },
},
},
{ status: 400 },
)
return result(message, { supportedVersions: [version], capabilities: {} })
})
yield* McpClient.connect("modern", server.config, import.meta.dir)
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover", "server/discover"])
}),
)
it.live("does not initialize a modern-only server with no mutually supported version", () =>
Effect.gen(function* () {
const server = yield* serve((message) => result(message, { supportedVersions: ["2099-01-01"], capabilities: {} }))
yield* McpClient.connect("future", server.config, import.meta.dir).pipe(Effect.flip)
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover"])
}),
)
it.live("modern HTTP executes MRTR, mirrors headers, validates outputs, and renews subscriptions without GET", () =>
Effect.gen(function* () {
const subscribed = Promise.withResolvers<void>()
const renewed = Promise.withResolvers<void>()
const changes = Promise.withResolvers<void>()
const controllers: Array<ReadableStreamDefaultController<Uint8Array>> = []
let listenID: string | number = ""
let listens = 0
let inputCalls = 0
let notifications = 0
const server = yield* serve((message) => {
if (message.method === "server/discover") return discover(message)
if (message.method === "subscriptions/listen") {
listenID = message.id
listens++
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controllers.push(controller)
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/subscriptions/acknowledged", params: { _meta: { "io.modelcontextprotocol/subscriptionId": message.id }, notifications: message.params?.notifications } })}\n\n`,
),
)
if (listens === 1) subscribed.resolve()
if (listens === 2) renewed.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
if (message.method === "tools/list") return result(message, { tools: [tool] })
if (message.method === "prompts/list") return result(message, { prompts: [{ name: "hello" }] })
if (message.method === "resources/list")
return result(message, { resources: [{ name: "doc", uri: "docs://hello" }] })
if (message.method === "resources/templates/list")
return result(message, { resourceTemplates: [{ name: "doc", uriTemplate: "docs://{name}" }] })
if (message.method === "prompts/get")
return result(message, { messages: [{ role: "user", content: { type: "text", text: "hello" } }] })
if (message.method === "resources/read")
return result(message, { contents: [{ uri: "docs://hello", text: "hello" }] })
if (message.method === "tools/call") {
inputCalls++
if (!message.params?.inputResponses)
return Response.json({
jsonrpc: "2.0",
id: message.id,
result: {
resultType: "input_required",
inputRequests: {
roots: { method: "roots/list" },
form: {
method: "elicitation/create",
params: { mode: "form", message: "Confirm", requestedSchema: schema },
},
url: {
method: "elicitation/create",
params: { mode: "url", message: "Authorize", url: "https://example.com/auth" },
},
},
requestState: "opaque-state",
},
})
return result(message, {
content: [{ type: "text", text: "complete" }],
structuredContent: { value: message.params?.arguments && Reflect.get(message.params.arguments, "value") },
})
}
return new Response(null, { status: 404 })
})
const inputs: McpClient.ElicitationParams[] = []
const connection = yield* McpClient.connect(
"modern",
server.config,
import.meta.dir,
undefined,
{
create: ({ params }) =>
Effect.sync(() => {
inputs.push(params)
return params.mode === "url"
? { action: "accept" as const }
: { action: "accept" as const, content: { value: "yes" } }
}),
complete: () => Effect.void,
},
{ name: "opencode-test", version: "1" },
)
connection.onToolsChanged(() => {
notifications++
changes.resolve()
})
expect(connection.instructions).toBe("modern instructions")
expect(yield* connection.tools()).toHaveLength(1)
expect(yield* connection.prompts()).toHaveLength(1)
expect(yield* connection.resources()).toHaveLength(1)
expect(yield* connection.resourceTemplates()).toHaveLength(1)
expect((yield* connection.prompt({ name: "hello" })).messages).toHaveLength(1)
expect((yield* connection.readResource({ uri: "docs://hello" }))?.contents).toHaveLength(1)
expect(
(yield* connection.callTool({
name: "echo",
args: { value: " padded " },
sessionID: Session.ID.make("ses_mcp_modern"),
})).structured,
).toEqual({
value: " padded ",
})
expect(inputCalls).toBe(2)
expect(inputs).toHaveLength(2)
const urlInput = inputs.find((input) => input.mode === "url")
expect(urlInput).toMatchObject({ mode: "url", url: "https://example.com/auth" })
expect(urlInput).not.toHaveProperty("elicitationId")
const calls = server.requests.filter((entry) => entry.method === "tools/call")
expect(calls[1]?.message?.params).toMatchObject({
requestState: "opaque-state",
inputResponses: {
roots: { roots: [{ uri: expect.stringContaining("file://") }] },
form: { action: "accept", content: { value: "yes" } },
url: { action: "accept" },
},
})
for (const entry of calls) {
expect(entry.message?.params?._meta).toMatchObject({
sessionID: "ses_mcp_modern",
"io.modelcontextprotocol/logLevel": "debug",
})
expect(entry.headers.get("mcp-name")).toBe("echo")
expect(entry.headers.get("mcp-param-value")).toBe("=?base64?IHBhZGRlZCA=?=")
}
yield* connection.callTool({ name: "echo", args: { value: 123 } }).pipe(Effect.flip)
expect(inputCalls).toBe(4)
for (const entry of server.requests.filter((entry) => entry.method === "tools/call").slice(2)) {
expect(entry.message?.params?._meta).not.toHaveProperty("sessionID")
}
yield* Effect.promise(() => subscribed.promise)
controllers[0]?.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed", params: { _meta: { "io.modelcontextprotocol/subscriptionId": "not-our-subscription" } } })}\n\n`,
),
)
controllers[0]?.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed", params: { _meta: { "io.modelcontextprotocol/subscriptionId": listenID } } })}\n\n`,
),
)
yield* Effect.promise(() => changes.promise).pipe(Effect.timeout("2 seconds"))
expect(notifications).toBe(1)
controllers[0]?.enqueue(new TextEncoder().encode("id: legacy-resume-is-forbidden\n: keepalive\n\n"))
controllers[0]?.close()
yield* Effect.promise(() => renewed.promise).pipe(Effect.timeout("3 seconds"))
expect(listens).toBe(2)
expect(
server.requests.some((entry) =>
["GET", "initialize", "notifications/initialized", "notifications/cancelled"].includes(entry.method),
),
).toBe(false)
for (const entry of server.requests) {
expect(entry.headers.get("mcp-protocol-version")).toBe(version)
expect(entry.headers.get("mcp-method")).toBe(entry.method)
expect(entry.headers.has("mcp-session-id")).toBe(false)
expect(entry.message?.params?._meta).toMatchObject({
"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientInfo": { name: "opencode-test" },
})
}
}),
)
it.live("cancels HTTP discovery without falling back or leaving a request alive", () =>
Effect.gen(function* () {
const started = Promise.withResolvers<void>()
const aborted = Promise.withResolvers<void>()
const server = yield* serve((_message, request) => {
started.resolve()
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
return new Response(new ReadableStream(), { headers: { "content-type": "text/event-stream" } })
})
const fiber = yield* McpClient.connect("cancel", server.config, import.meta.dir).pipe(Effect.forkScoped)
yield* Effect.promise(() => started.promise)
yield* Fiber.interrupt(fiber)
yield* Effect.promise(() => aborted.promise).pipe(Effect.timeout("1 second"))
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover"])
}),
)
for (const legacy of ["reject", "stateless"] as const) {
it.live(`connects to SDK HTTP hosting with legacy=${legacy}`, () =>
Effect.gen(function* () {
const handler = createMcpHandler(
() => {
const server = new Server(
{ name: "modern-sdk", version: "1" },
{ capabilities: { tools: {}, logging: {} }, instructions: "sdk instructions" },
)
server.setRequestHandler("tools/list", async () => ({ tools: [tool] }))
server.setRequestHandler("tools/call", async (_request, ctx) => {
await ctx.mcpReq.log("info", "modern log")
return { content: [{ type: "text", text: "sdk" }], structuredContent: { value: "sdk" } }
})
return server
},
{ legacy },
)
yield* Effect.addFinalizer(() => Effect.promise(() => handler.close()))
const server = yield* serve((_message, request) => handler.fetch(request))
const connection = yield* McpClient.connect("sdk", server.config, import.meta.dir)
const logs: McpClient.LogMessage[] = []
connection.onLog((message) => logs.push(message))
expect(connection.instructions).toBe("sdk instructions")
expect(yield* connection.tools()).toHaveLength(1)
expect((yield* connection.callTool({ name: "echo", args: { value: "sdk" } })).structured).toEqual({
value: "sdk",
})
expect(logs).toMatchObject([{ level: "info", data: "modern log" }])
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover", "tools/list", "tools/call"])
}),
)
}
for (const format of ["json", "sse", "html", "empty"]) {
it.live(`legacy fallback accepts completed ${format} discovery replies`, () =>
Effect.gen(function* () {
const server = yield* serve((message) => {
if (message.method === "server/discover") {
if (format === "empty") return new Response(null, { status: 202 })
if (format === "html")
return new Response("<html>Not found</html>", { headers: { "content-type": "text/html" } })
const error = JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse Error" } })
return new Response(format === "sse" ? `data: ${error}\n\n` : error, {
headers: { "content-type": format === "sse" ? "text/event-stream" : "application/json" },
})
}
return result(
message,
{ protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "legacy", version: "1" } },
false,
)
})
yield* McpClient.connect("legacy", server.config, import.meta.dir)
expect(server.requests.filter((entry) => entry.method === "initialize")).toHaveLength(1)
}),
)
}
it.live("modern catalogs preserve schema validation and HTTP header-declaration filtering", () =>
Effect.gen(function* () {
const server = yield* serve((message) => {
if (message.method === "server/discover")
return result(message, { supportedVersions: [version], capabilities: { tools: {} } })
if (message.method === "tools/list")
return result(
message,
message.params?.cursor
? { tools: [tool] }
: {
tools: [
{ name: "plain", inputSchema: { type: "object" } },
{
name: "bad-header",
inputSchema: { type: "object", properties: { value: { type: "array", "x-mcp-header": "Bad" } } },
},
],
nextCursor: "second",
},
)
return result(message, { content: [], structuredContent: { value: 123 } })
})
const connection = yield* McpClient.connect("schemas", server.config, import.meta.dir)
const tools = yield* connection.tools()
expect(tools.map((tool) => tool.name)).toEqual(["plain", "echo"])
expect(tools[0]?.outputSchema).toBeUndefined()
expect(tools[1]?.outputSchema).toEqual(schema)
expect((yield* connection.callTool({ name: "plain" })).structured).toEqual({ value: 123 })
const error = yield* connection.callTool({ name: "echo" }).pipe(Effect.flip)
expect(error.message).toContain("does not match")
expect(server.requests.filter((entry) => entry.method === "tools/list")).toHaveLength(2)
expect(server.requests.filter((entry) => entry.method === "tools/call")).toHaveLength(2)
}),
)
it.live("modern HTTP execution timeout aborts the stream without cancellation POSTs or replay", () =>
Effect.gen(function* () {
const aborted = Promise.withResolvers<void>()
const server = yield* serve((message, request) => {
if (message.method === "server/discover")
return result(message, { supportedVersions: [version], capabilities: { tools: {} } })
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
return new Response(new ReadableStream(), { headers: { "content-type": "text/event-stream" } })
})
const connection = yield* McpClient.connect(
"timeout",
new ConfigMCP.Remote({ ...server.config, timeout: { execution: 50, startup: 1_000 } }),
import.meta.dir,
)
const error = yield* connection.callTool({ name: "slow" }).pipe(Effect.flip)
expect(error.message).toBe("Request timed out")
yield* Effect.promise(() => aborted.promise).pipe(Effect.timeout("1 second"))
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover", "tools/call"])
}),
)
it.live("modern HTTP 404 does not initialize or replay even with a configured session header", () =>
Effect.gen(function* () {
const server = yield* serve((message) =>
message.method === "server/discover"
? result(message, { supportedVersions: [version], capabilities: { tools: {} } })
: new Response("Missing method", { status: 404 }),
)
const connection = yield* McpClient.connect(
"404",
new ConfigMCP.Remote({ ...server.config, headers: { "mcp-session-id": "stale" } }),
import.meta.dir,
)
yield* connection.callTool({ name: "missing" }).pipe(Effect.flip)
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover", "tools/call"])
expect(server.requests.every((entry) => !entry.headers.has("mcp-session-id"))).toBe(true)
}),
)
it.live("MRTR execution deadline interrupts pending elicitation and never sends its retry", () =>
Effect.gen(function* () {
const interrupted = Promise.withResolvers<void>()
const server = yield* serve((message) =>
message.method === "server/discover"
? result(message, { supportedVersions: [version], capabilities: { tools: {} } })
: Response.json({
jsonrpc: "2.0",
id: message.id,
result: {
resultType: "input_required",
inputRequests: {
question: {
method: "elicitation/create",
params: { mode: "form", message: "Wait", requestedSchema: schema },
},
},
requestState: "state",
},
}),
)
const connection = yield* McpClient.connect(
"elicitation-timeout",
new ConfigMCP.Remote({ ...server.config, timeout: { execution: 50, startup: 1_000 } }),
import.meta.dir,
undefined,
{
create: () => Effect.never.pipe(Effect.ensuring(Effect.sync(() => interrupted.resolve()))),
complete: () => Effect.void,
},
)
const error = yield* connection.callTool({ name: "wait" }).pipe(Effect.flip)
expect(error.message).toBe("Request timed out")
yield* Effect.promise(() => interrupted.promise).pipe(Effect.timeout("1 second"))
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover", "tools/call"])
}),
)
for (const acknowledge of [false, true]) {
it.live(`scope closure cancels the HTTP subscription stream (acknowledged=${acknowledge})`, () =>
Effect.gen(function* () {
const listening = Promise.withResolvers<void>()
const aborted = Promise.withResolvers<void>()
const server = yield* serve((message, request) => {
if (message.method === "server/discover") return discover(message)
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
if (acknowledge)
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/subscriptions/acknowledged", params: { _meta: { "io.modelcontextprotocol/subscriptionId": message.id }, notifications: message.params?.notifications } })}\n\n`,
),
)
listening.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
})
yield* Effect.scoped(
Effect.gen(function* () {
yield* McpClient.connect("subscription-close", server.config, import.meta.dir)
yield* Effect.promise(() => listening.promise)
}),
).pipe(Effect.timeout("1 second"))
yield* Effect.promise(() => aborted.promise).pipe(Effect.timeout("1 second"))
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover", "subscriptions/listen"])
}),
)
}
it.live("an interrupted modern response fails promptly without legacy SSE resumption", () =>
Effect.gen(function* () {
const server = yield* serve((message) =>
message.method === "server/discover"
? result(message, { supportedVersions: [version], capabilities: { tools: {} } })
: new Response("id: not-resumable\n: heartbeat\n\n", { headers: { "content-type": "text/event-stream" } }),
)
const connection = yield* McpClient.connect("interrupted", server.config, import.meta.dir)
const error = yield* connection.callTool({ name: "interrupted" }).pipe(Effect.flip, Effect.timeout("1 second"))
expect(error.message).toBe("MCP response stream ended before a result")
expect(server.requests.map((entry) => entry.method)).toEqual(["server/discover", "tools/call"])
}),
)
+441 -41
View File
@@ -1,18 +1,14 @@
import path from "node:path"
import fs from "node:fs/promises"
import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { Client, InMemoryTransport, ProtocolError, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
import { JSONRPCMessageSchema } from "@modelcontextprotocol/core"
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
createMcpHandler,
inputRequired,
Server,
WebStandardStreamableHTTPServerTransport,
} from "@modelcontextprotocol/server"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
@@ -100,6 +96,7 @@ function resourceServer(
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
resourceLists: 0,
resourceReads: 0,
templateLists: 0,
toolLists: 0,
toolCalls: [] as Array<{
@@ -120,7 +117,7 @@ function resourceServer(
},
},
)
protocol.setRequestHandler(ListToolsRequestSchema, () => {
protocol.setRequestHandler("tools/list", () => {
state.toolLists += 1
return Promise.resolve({
tools: input.emptyElicitation
@@ -131,8 +128,8 @@ function resourceServer(
})
})
if (input.emptyElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, async () => {
const result = await protocol.elicitInput({
protocol.setRequestHandler("tools/call", async (_request, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: "form",
message: "Confirm",
requestedSchema: { type: "object", properties: {} },
@@ -144,8 +141,8 @@ function resourceServer(
})
}
if (input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, async () => {
const result = await protocol.elicitInput({
protocol.setRequestHandler("tools/call", async (_request, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: "url",
message: "Authorize access",
url: "https://example.com/authorize",
@@ -158,7 +155,7 @@ function resourceServer(
})
}
if (!input.emptyElicitation && !input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, (request) => {
protocol.setRequestHandler("tools/call", (request) => {
state.toolCalls.push({
name: request.params.name,
arguments: request.params.arguments,
@@ -169,17 +166,20 @@ function resourceServer(
})
}
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
protocol.setRequestHandler("resources/list", (request) => {
state.resourceLists += 1
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
protocol.setRequestHandler("resources/templates/list", (request) => {
state.templateLists += 1
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
protocol.setRequestHandler("resources/read", () => {
state.resourceReads += 1
return Promise.resolve({ contents: state.contents })
})
}
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
@@ -213,6 +213,44 @@ function resourceServer(
)
}
function modernUrlElicitationServer() {
return Effect.acquireRelease(
Effect.sync(() => {
const handler = createMcpHandler(
() => {
const server = new Server({ name: "url-elicitation", version: "1" }, { capabilities: { tools: {} } })
server.setRequestHandler("tools/list", async () => ({
tools: [{ name: "url-elicitation", inputSchema: { type: "object" } }],
}))
server.setRequestHandler("tools/call", async (_request, ctx) => {
if (!ctx.mcpReq.inputResponses)
return inputRequired({
inputRequests: {
authorization: inputRequired.elicitUrl({
message: "Authorize access",
url: "https://example.com/authorize",
}),
},
})
return { content: [], structuredContent: ctx.mcpReq.inputResponses.authorization }
})
return server
},
{ legacy: "reject" },
)
const http = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => handler.fetch(request) })
return {
url: http.url.toString(),
close: async () => {
await handler.close()
await http.stop(true)
},
}
}),
(server) => Effect.promise(server.close),
)
}
function resourceMcpLayer(
server: string | typeof ConfigMCP.Server.Type,
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
@@ -455,9 +493,11 @@ test("passes session IDs as MCP request metadata", async () => {
})
test("preserves output schema validation across paginated tool discovery", async () => {
const cursors: Array<string | undefined> = []
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
Promise.resolve(
server.setRequestHandler("tools/list", ({ params }) => {
cursors.push(params?.cursor)
return Promise.resolve(
params?.cursor === "page-2"
? {
tools: [
@@ -486,12 +526,12 @@ test("preserves output schema validation across paginated tool discovery", async
],
nextCursor: "page-2",
},
),
)
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
)
})
server.setRequestHandler("tools/call", ({ params }) =>
Promise.resolve({
content: [],
structuredContent: { value: params.name === "first" ? 42 : 1 },
structuredContent: { value: params.name === "first" ? 42 : "invalid" },
}),
)
@@ -500,12 +540,15 @@ test("preserves output schema validation across paginated tool discovery", async
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
try {
const first = await client.listTools()
const second = await client.listTools({ cursor: first.nextCursor })
expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
const result = await client.listTools()
expect(cursors).toEqual([undefined, "page-2"])
expect(result.tools.map((tool) => tool.name)).toEqual(["first", "second"])
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
"Structured content does not match the tool's output schema",
)
await expect(client.callTool({ name: "second", arguments: {} })).rejects.toThrow(
"Structured content does not match the tool's output schema",
)
} finally {
await Promise.all([client.close(), server.close()])
}
@@ -548,6 +591,244 @@ test("retains output schemas across paginated MCP discovery", async () => {
])
})
testEffect(Layer.empty).live("removes only malformed output schemas during paginated MCP discovery", () =>
Effect.gen(function* () {
const cursors: unknown[] = []
const executed: string[] = []
const outputSchema = {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
}
const malformed = [null, [], { $schema: 42 }].map((outputSchema, index) => ({
name: `malformed-${index}`,
description: "Still callable",
inputSchema: { type: "object" as const },
outputSchema,
}))
const server = yield* resourceServer({
respond: async (request) => {
if (request.method !== "POST") return
const message = JSONRPCMessageSchema.parse(await request.clone().json())
if (!("id" in message) || !("method" in message)) return
if (message.method === "tools/list") {
cursors.push(message.params?.cursor)
const page = Number(message.params?.cursor ?? 0)
return Response.json({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [
{ name: `valid-${page}`, inputSchema: { type: "object" }, outputSchema },
...(page === 1 ? malformed : []),
],
nextCursor: page < 2 ? String(page + 1) : undefined,
},
})
}
if (message.method === "tools/call" && typeof message.params?.name === "string") {
executed.push(message.params.name)
return Response.json({
jsonrpc: "2.0",
id: message.id,
result: { content: [], structuredContent: { value: 42 } },
})
}
},
})
const connection = yield* connect(
"mixed-output-schemas",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
const tools = yield* connection.tools()
expect(cursors).toEqual([undefined, "1", undefined, "1", "2"])
expect(tools).toEqual([
{ name: "valid-0", description: undefined, inputSchema: { type: "object" }, outputSchema },
{ name: "valid-1", description: undefined, inputSchema: { type: "object" }, outputSchema },
...malformed.map((tool) => ({ ...tool, outputSchema: undefined })),
{ name: "valid-2", description: undefined, inputSchema: { type: "object" }, outputSchema },
])
for (const tool of malformed) {
expect(yield* connection.callTool({ name: tool.name })).toMatchObject({ structured: { value: 42 } })
}
for (const name of ["valid-0", "valid-1", "valid-2"]) {
expect((yield* connection.callTool({ name }).pipe(Effect.flip)).message).toContain(
"Structured content does not match the tool's output schema",
)
}
expect(executed).toEqual([...malformed.map((tool) => tool.name), "valid-0", "valid-1", "valid-2"])
}),
)
for (const entry of [
{ field: "name", tool: { name: 42 } },
{ field: "inputSchema", tool: { inputSchema: { type: "string" } } },
{ field: "nextCursor", envelope: { nextCursor: 42 } },
{ field: "_meta", envelope: { _meta: 42 } },
]) {
testEffect(Layer.empty).live(`rejects malformed ${entry.field} during MCP output-schema fallback`, () =>
Effect.gen(function* () {
const cursors: unknown[] = []
const server = yield* resourceServer({
respond: async (request) => {
if (request.method !== "POST") return
const message = JSONRPCMessageSchema.parse(await request.clone().json())
if (!("id" in message) || !("method" in message) || message.method !== "tools/list") return
cursors.push(message.params?.cursor)
return Response.json({
jsonrpc: "2.0",
id: message.id,
result: {
// Trigger fallback first, then verify it still rejects unrelated malformed data.
tools: [
{
name: "malformed",
inputSchema: { type: "object" },
outputSchema: null,
...(cursors.length > 1 ? entry.tool : {}),
},
],
...(cursors.length > 1 ? entry.envelope : {}),
},
})
},
})
const connection = yield* connect(
"malformed-tool-list",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect((yield* connection.tools().pipe(Effect.flip)).message).toContain(entry.field)
expect(cursors).toEqual([undefined, undefined])
}),
)
}
testEffect(Layer.empty).live("isolates unresolved output schemas without replaying tools on a 2025-11-25 server", () =>
Effect.gen(function* () {
const executed: string[] = []
const cursors: unknown[] = []
const versions: Array<string | null> = []
// Speak the older wire protocol directly so both ends cannot silently upgrade together.
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: async (request) => {
if (request.method !== "POST") return new Response(null, { status: 405 })
const message = JSONRPCMessageSchema.parse(await request.json())
if (!("id" in message) || !("method" in message)) return new Response(null, { status: 202 })
if (message.method === "initialize") {
return Response.json(
{
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "legacy-output-schema", version: "1.0.0" },
},
},
{ headers: { "mcp-session-id": "legacy" } },
)
}
versions.push(request.headers.get("mcp-protocol-version"))
if (message.method === "tools/list") {
cursors.push(message.params?.cursor)
return Response.json({
jsonrpc: "2.0",
id: message.id,
result:
message.params?.cursor === "page-2"
? {
tools: [
{
name: "unresolved",
inputSchema: { type: "object" },
outputSchema: { type: "object", properties: { value: { $ref: "#/$defs/Missing" } } },
},
{ name: "server-error", inputSchema: { type: "object" } },
{
name: "dependent",
inputSchema: { type: "object" },
outputSchema: { type: "object", $ref: "https://example.com/mcp-output" },
},
],
}
: {
tools: [
{
name: "valid",
inputSchema: { type: "object" },
outputSchema: {
$id: "https://example.com/mcp-output",
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
},
},
],
nextCursor: "page-2",
},
})
}
if (message.method === "tools/call" && typeof message.params?.name === "string") {
executed.push(message.params.name)
return Response.json({
jsonrpc: "2.0",
id: message.id,
...(message.params.name === "server-error"
? {
error: {
code: -32602,
message: "Invalid outputSchema for tool server-error: can't resolve reference",
},
}
: {
result: {
content: [],
structuredContent: { value: message.params.name === "dependent" ? "ok" : 42 },
},
}),
})
}
return new Response(null, { status: 400 })
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const connection = yield* connect(
"legacy-output-schema",
new ConfigMCP.Remote({ type: "remote", url: server.url.toString(), oauth: false }),
import.meta.dir,
)
const tools = yield* connection.tools()
expect(tools.map((tool) => tool.name)).toEqual(["valid", "unresolved", "server-error", "dependent"])
expect(cursors).toEqual([undefined, "page-2"])
expect(tools[0]?.outputSchema).toEqual({
$id: "https://example.com/mcp-output",
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
})
expect(tools[1]?.outputSchema).toBeUndefined()
expect(tools[3]?.outputSchema).toEqual({ type: "object", $ref: "https://example.com/mcp-output" })
expect(yield* connection.callTool({ name: "dependent" })).toMatchObject({ structured: { value: "ok" } })
expect(yield* connection.callTool({ name: "unresolved" })).toMatchObject({ structured: { value: 42 } })
expect((yield* connection.callTool({ name: "valid" }).pipe(Effect.flip)).message).toContain(
"Structured content does not match the tool's output schema",
)
const error = yield* connection.callTool({ name: "server-error" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(ProtocolError)
expect(error.message).toContain("Invalid outputSchema for tool server-error: can't resolve reference")
expect(executed).toEqual(["dependent", "unresolved", "valid", "server-error"])
expect(versions[0]).toBe("2026-07-28")
expect(new Set(versions.slice(1))).toEqual(new Set(["2025-11-25"]))
}),
)
test("lists paginated prompts and invokes them through the MCP client", async () => {
const result = await Effect.runPromise(
Effect.scoped(
@@ -598,13 +879,14 @@ test("spawns local MCP servers through the location environment", async () => {
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
)
expect(spawns).toHaveLength(1)
const command = spawns[0]
if (!command || !ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard process command")
expect(command.command).toBe(process.execPath)
expect(command.options.cwd).toBe(cwd)
expect(command.options.extendEnv).toBe(true)
expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
expect(spawns).toHaveLength(2)
for (const command of spawns) {
if (!ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard process command")
expect(command.command).toBe(process.execPath)
expect(command.options.cwd).toBe(cwd)
expect(command.options.extendEnv).toBe(true)
expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
}
})
test("reports a local MCP server as failed when the location has no execution plane", async () => {
@@ -930,7 +1212,8 @@ for (const { status, query } of [400, 404].flatMap((status) =>
expect(server.state.initializations).toBe(2)
expect(new URL(server.state.urls[0]).searchParams.get("codemode")).toBe("false")
expect(new Set(server.state.urls.slice(1))).toEqual(new Set([config.url]))
expect(server.state.urls[1]).toBe(server.state.urls[0])
expect(new Set(server.state.urls.slice(2))).toEqual(new Set([config.url]))
expect(new Set(headers)).toEqual(new Set(["preserved"]))
expect(server.state.toolLists).toBe(1)
expect(server.state.resourceLists).toBe(1)
@@ -968,10 +1251,11 @@ for (const entry of [
const error = yield* connect("resources", config, import.meta.dir).pipe(Effect.flip)
expect(error).toBeInstanceOf(McpClient.ConnectError)
expect(server.state.initializations).toBe(entry.attempts)
expect(server.state.urls).toHaveLength(entry.attempts)
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
if (entry.attempts === 2) expect(server.state.urls[1]).toBe(config.url)
const stoppedAtDiscovery = [401, 403, 500].includes(entry.status)
expect(server.state.initializations).toBe(stoppedAtDiscovery ? 0 : entry.attempts)
expect(server.state.urls).toHaveLength(stoppedAtDiscovery ? 1 : entry.attempts * 2)
if (entry.query || entry.codemode === false) expect(new Set(server.state.urls)).toEqual(new Set([config.url]))
if (entry.attempts === 2) expect(server.state.urls[2]).toBe(config.url)
}),
)
}
@@ -996,6 +1280,86 @@ for (const status of [400, 404]) {
)
}
for (const cancellation of [undefined, "execution", "catalog"] as const) {
testEffect(Layer.empty).live(
cancellation
? `does not replay cancelled MCP ${cancellation} requests after session recovery`
: "coalesces expired MCP session recovery and replays concurrent requests",
() =>
Effect.gen(function* () {
const recovering = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const rejected = Promise.withResolvers<void>()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
release.resolve()
rejected.resolve()
}),
)
const replacement = yield* resourceServer()
replacement.state.contents = [{ uri: "docs://recovered", text: "recovered" }]
let expired = false
let expiredRequests = 0
let session: string | null = null
const server = yield* resourceServer({
respond: async (request) => {
expect(request.headers.get("x-mcp-test")).toBe("preserved")
if (!expired) {
session = request.headers.get("mcp-session-id") ?? session
return
}
if (request.headers.get("mcp-session-id") === session) {
if (request.method === "POST") {
expiredRequests += 1
// Both requests must use the expired session before either triggers recovery.
if (expiredRequests === 2) rejected.resolve()
await rejected.promise
}
return new Response(null, { status: 404 })
}
if (request.method === "POST" && !request.headers.has("mcp-session-id")) {
recovering.resolve()
await release.promise
}
return fetch(new Request(replacement.url, request))
},
})
const connection = yield* connect(
"recovery",
new ConfigMCP.Remote({
type: "remote",
url: server.url,
oauth: false,
headers: { "x-mcp-test": "preserved" },
}),
import.meta.dir,
)
expect(session).not.toBeNull()
expired = true
const request: Effect.Effect<unknown, Error> =
cancellation === "catalog" ? connection.resources() : connection.readResource({ uri: "docs://recovered" })
const pending = yield* request.pipe(Effect.forkScoped)
const templates = yield* connection.resourceTemplates().pipe(Effect.forkScoped)
yield* Effect.promise(() => recovering.promise)
if (cancellation) yield* Fiber.interrupt(pending)
release.resolve()
expect(yield* Fiber.join(templates)).toEqual([])
if (!cancellation) {
expect(yield* Fiber.join(pending)).toEqual({
contents: [{ type: "text", uri: "docs://recovered", text: "recovered", mimeType: undefined }],
})
}
expect(server.state.initializations).toBe(2)
expect(expiredRequests).toBe(2)
expect(replacement.state.initializations).toBe(1)
expect(replacement.state.resourceReads).toBe(cancellation ? 0 : 1)
expect(replacement.state.resourceLists).toBe(0)
expect(replacement.state.templateLists).toBe(1)
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
}),
)
}
test("lists, reads, and reports MCP resource changes", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1029,6 +1393,8 @@ test("lists, reads, and reports MCP resource changes", async () => {
{ name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
])
expect(server.state.resourceLists).toBe(2)
expect(server.state.templateLists).toBe(2)
expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
@@ -1150,6 +1516,40 @@ test("acknowledges completed MCP URL elicitations without returning internal con
)
})
for (const era of ["legacy", "modern"] as const) {
for (const action of ["accept", "cancel"] as const) {
testEffect(Layer.empty).live(
`${era} URL elicitation uses the shared Form ${action} flow without a completion notification`,
() =>
Effect.gen(function* () {
const server = yield* era === "legacy"
? resourceServer({ urlElicitation: true })
: modernUrlElicitationServer()
const created = yield* Deferred.make<Form.Info>()
const result = yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const forms = yield* Form.Service
const call = yield* service
.callTool({ server: "resources", name: "url-elicitation" })
.pipe(Effect.forkScoped)
const form = yield* Deferred.await(created)
expect(form.fields).toEqual([
{ key: "elicitation", type: "external", url: "https://example.com/authorize" },
])
if (era === "legacy") expect(form.metadata).toHaveProperty("elicitationID", "elicitation-test")
if (era === "modern") expect(form.metadata).not.toHaveProperty("elicitationID")
if (action === "accept") yield* forms.reply({ id: form.id, answer: { elicitation: true } })
if (action === "cancel") yield* forms.cancel(form.id)
return yield* Fiber.join(call)
}).pipe(
Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
)
expect(result.structured).toEqual({ action })
}),
)
}
}
test("loads and reads MCP resources", async () => {
await Effect.runPromise(
Effect.scoped(
+4 -3
View File
@@ -56,12 +56,13 @@ it.live(
yield* list(".")
expect(yield* loaded).toEqual([{ directory: current }])
// Automatic negotiation starts a disposable probe before the live MCP process.
expect(
yield* count.pipe(
Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }),
Effect.repeat({ while: (n) => n < 2, schedule: Schedule.spaced("25 millis") }),
Effect.timeout("5 seconds"),
),
).toBe(1)
).toBe(2)
yield* list("..")
const sibling = yield* list("../sibling")
@@ -73,7 +74,7 @@ it.live(
yield* list("../sibling/nested")
yield* list("../sibling")
expect(yield* loaded).toEqual([{ directory: current }])
expect(yield* count).toBe(1)
expect(yield* count).toBe(2)
}),
15_000,
)
@@ -0,0 +1,804 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 635f1c0..1245600 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -1250,8 +1250,12 @@ async function exchangeAuthorization(authorizationServerUrl, { metadata, clientI
* @returns Promise resolving to OAuth tokens (preserves original `refresh_token` if not replaced)
* @throws {Error} When token refresh fails or authentication is invalid
*/
+const refreshes = new Map();
async function refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken, resource, addClientAuthentication, fetchFn }) {
- return {
+ const key = `${authorizationServerUrl}\0${clientInformation.client_id}\0${refreshToken}`;
+ const current = refreshes.get(key);
+ if (current) return current;
+ const refresh = (async () => ({
refresh_token: refreshToken,
...await executeTokenRequest(authorizationServerUrl, {
metadata,
@@ -1264,7 +1268,13 @@ async function refreshAuthorization(authorizationServerUrl, { metadata, clientIn
resource,
fetchFn
})
- };
+ }))();
+ refreshes.set(key, refresh);
+ try {
+ return await refresh;
+ } finally {
+ if (refreshes.get(key) === refresh) refreshes.delete(key);
+ }
}
/**
* Unified token fetching that works with any grant type via {@linkcode OAuthClientProvider.prepareTokenRequest | prepareTokenRequest()}.
@@ -2288,17 +2298,20 @@ function isAuthSeamEscape(error) {
//#region src/client/probeClassifier.ts
/** The `-32022` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */
const UNSUPPORTED_PROTOCOL_VERSION = -32022;
+// Only completed malformed discovery replies carry this marker, never network/auth failures.
+var DiscoveryReplyError = class extends Error {
+ constructor(body) {
+ super("Invalid server/discover response");
+ this.body = body;
+ }
+};
/**
* Deliberately not probe-recognized in either direction: deployed servers
* overload `-32001` (the SDK-conventional `Session not found` body on a 2025
-* stateful server), and the spec-assigned `-32020` (`HeaderMismatch`) /
-* `-32021` (`MissingRequiredClientCapability`) are not era evidence — all
-* fall into the conservative legacy default.
+* stateful server), so it falls into the conservative legacy default.
*/
const NOT_PROBE_RECOGNIZED = new Set([
- -32001,
- -32020,
- -32021
+ -32001
]);
/**
* Classify a single probe outcome. Pure: no I/O, no state — loop-guard and
@@ -2335,7 +2348,7 @@ function classifyResult(result, context) {
version: overlap,
discover: parsed.value
};
- if (context.fallbackAvailable) return { kind: "legacy" };
+ if (context.fallbackAvailable && supportedVersions.some((version) => !require_src.isModernProtocolVersion(version))) return { kind: "legacy" };
return {
kind: "error",
error: new require_src.UnsupportedProtocolVersionError({
@@ -2369,6 +2382,10 @@ function classifyRpcError(outcome, context) {
error
};
}
+ if (code === require_src.HEADER_MISMATCH_ERROR_CODE || code === require_src.ProtocolErrorCode.MissingRequiredClientCapability) return {
+ kind: "error",
+ error: require_src.ProtocolError.fromError(code, message, data)
+ };
if (NOT_PROBE_RECOGNIZED.has(code)) return { kind: "legacy" };
return { kind: "legacy" };
}
@@ -2484,7 +2501,7 @@ function detectProbeEnvironment() {
* across bundles); everything else is treated like HTTP.
*/
function detectProbeTransportKind(transport) {
- return "stderr" in transport && "pid" in transport ? "stdio" : "http";
+ return transport.hasPerRequestStream === false || "stderr" in transport && "pid" in transport ? "stdio" : "http";
}
/**
* Temporary ownership of a raw transport for the negotiation exchange, before
@@ -2557,23 +2574,32 @@ var ProbeWindow = class ProbeWindow {
* Send one probe request and await its reply. Probe ids are strings, so they
* never collide with Protocol's numeric ids (e.g. on a shared stdio pipe).
*/
- async exchange(buildRequest, timeoutMs) {
+ async exchange(buildRequest, timeoutMs, signal) {
const id = `server-discover-probe-${++this._probeCounter}`;
return new Promise((resolve) => {
+ const requestAbort = new AbortController();
let settled = false;
const settle = (reply) => {
if (settled) return;
settled = true;
clearTimeout(timer);
+ signal?.removeEventListener("abort", abort);
+ requestAbort.abort();
if (this._pending?.id === id) this._pending = void 0;
resolve(reply);
};
const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
+ const abort = () => settle({ kind: "send-error", error: signal.reason });
+ signal?.addEventListener("abort", abort, { once: true });
+ if (signal?.aborted) return abort();
this._pending = {
id,
resolve: settle
};
- this._transport.send(buildRequest(id)).catch((error) => settle({
+ this._transport.send(buildRequest(id), {
+ requestSignal: requestAbort.signal,
+ onDiscoveryReplyError: (error) => settle({ kind: "send-error", error })
+ }).catch((error) => settle({
kind: "send-error",
error
}));
@@ -2658,6 +2684,10 @@ function normalizeReply(reply, timeoutMs) {
};
case "send-error": {
const error = reply.error;
+ if (error instanceof DiscoveryReplyError) {
+ const rpc = parseJsonRpcErrorBody(error.body);
+ return rpc ? { kind: "rpc-error", ...rpc } : { kind: "result", result: null };
+ }
if (isAuthSeamEscape(error) || error instanceof UnauthorizedError || error instanceof Error && error.name === "UnauthorizedError") return {
kind: "auth-required",
error
@@ -2691,6 +2721,7 @@ function normalizeReply(reply, timeoutMs) {
* window is detached and the transport's `start` is left untouched.
*/
async function negotiateEra(negotiation, deps) {
+ deps.signal?.throwIfAborted();
const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs;
const maxRetries = Math.max(0, negotiation.probe.maxRetries ?? 0);
const clientModernVersions = negotiation.kind === "pin" ? [negotiation.version] : negotiation.modernVersions;
@@ -2701,7 +2732,8 @@ async function negotiateEra(negotiation, deps) {
let correctiveUsed = false;
let timeoutRetriesRemaining = maxRetries;
for (;;) {
- const reply = await window.exchange((id) => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs);
+ const reply = await window.exchange((id) => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs, deps.signal);
+ deps.signal?.throwIfAborted();
if (reply.kind === "timeout" && timeoutRetriesRemaining > 0) {
timeoutRetriesRemaining--;
continue;
@@ -3200,6 +3232,7 @@ var Client = class extends require_src.Protocol {
* ```
*/
async connect(transport, options) {
+ transport.onsessionexpired = () => this._legacyHandshake(transport);
if (options?.prior != null) return this._connectFromPrior(transport, validatePrior(options.prior), options);
const negotiation = resolveVersionNegotiation(this._versionNegotiation, this._supportedProtocolVersionsOption);
if (negotiation.kind !== "legacy") return this._connectNegotiated(transport, negotiation, options);
@@ -3275,10 +3308,20 @@ var Client = class extends require_src.Protocol {
clientInfo: this._clientInfo,
capabilities: this._capabilities,
environment: detectProbeEnvironment(),
- defaultTimeoutMs: options?.timeout ?? require_src.DEFAULT_REQUEST_TIMEOUT_MSEC
+ defaultTimeoutMs: options?.timeout ?? require_src.DEFAULT_REQUEST_TIMEOUT_MSEC,
+ signal: options?.signal
};
const stdioParams = transportKind === "stdio" ? readStdioServerParams(transport) : void 0;
- result = stdioParams === void 0 ? await negotiateEra(negotiation, {
+ // The caller supplies a disposable probe on the same execution plane as its live transport.
+ if (options?.probeTransport) {
+ const sibling = options.probeTransport;
+ try {
+ result = await negotiateEra(negotiation, { ...baseDeps, transport: sibling, transportKind: "stdio", disposableProbe: true });
+ } finally {
+ await sibling.close();
+ }
+ options?.signal?.throwIfAborted();
+ } else result = stdioParams === void 0 ? await negotiateEra(negotiation, {
...baseDeps,
transport,
transportKind
@@ -3875,6 +3918,8 @@ var Client = class extends require_src.Protocol {
resolveOpening = resolve;
rejectOpening = reject;
});
+ // Cancellation can reject the ack waiter while transport.send is still opening HTTP.
+ opening.catch(() => {});
let resolveClosed;
const closed = new Promise((resolve) => {
resolveClosed = resolve;
@@ -3900,6 +3945,7 @@ var Client = class extends require_src.Protocol {
};
const wireTeardown = async () => {
requestAbort.abort();
+ if (this.transport?.hasPerRequestStream === true) return;
await this.notification({
method: "notifications/cancelled",
params: { requestId: listenId }
@@ -3985,6 +4031,10 @@ var Client = class extends require_src.Protocol {
* being silently swallowed.
*/
_onnotification(raw, extra) {
+ if (this.getProtocolEra() === "modern" && (Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) || raw.method === "notifications/resources/updated")) {
+ const id = raw.params?._meta?.[_modelcontextprotocol_core_internal.SUBSCRIPTION_ID_META_KEY];
+ if (typeof id !== "string" || !this._listenState.has(id)) return;
+ }
const evicted = Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) ? LIST_CHANGED_EVICTIONS[raw.method] : void 0;
if (raw.method === "notifications/resources/updated") {
const uri = raw.params?.uri;
@@ -5227,16 +5277,22 @@ var StreamableHTTPClientTransport = class {
if (!event.data) continue;
if (!event.event || event.event === "message") try {
const message = _modelcontextprotocol_core_internal.JSONRPCMessageSchema.parse(JSON.parse(event.data));
+ if (options.onDiscoveryReplyError && (!(require_src.isJSONRPCResultResponse(message) || require_src.isJSONRPCErrorResponse(message)) || message.id !== options.discoveryRequestId)) throw new DiscoveryReplyError(event.data);
if (require_src.isJSONRPCResultResponse(message) || require_src.isJSONRPCErrorResponse(message)) {
receivedResponse = true;
if (replayMessageId !== void 0) message.id = replayMessageId;
}
this.onmessage?.(message);
} catch (error) {
+ if (options.onDiscoveryReplyError) {
+ options.onDiscoveryReplyError(new DiscoveryReplyError(event.data));
+ return;
+ }
this.onerror?.(error);
}
}
- if ((isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) this._scheduleReconnection({
+ if (options.onDiscoveryReplyError && !receivedResponse && !isIntentionalAbort()) options.onDiscoveryReplyError(new DiscoveryReplyError());
+ if (!options.modern && (isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) this._scheduleReconnection({
resumptionToken: lastEventId,
onresumptiontoken,
replayMessageId,
@@ -5246,8 +5302,9 @@ var StreamableHTTPClientTransport = class {
else if (!isIntentionalAbort()) onRequestStreamEnd?.();
} catch (error) {
if (isIntentionalAbort()) return;
+ options.onDiscoveryReplyError?.(error);
this.onerror?.(/* @__PURE__ */ new Error(`SSE stream disconnected: ${error}`));
- if ((isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) try {
+ if (!options.modern && (isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) try {
this._scheduleReconnection({
resumptionToken: lastEventId,
onresumptiontoken,
@@ -5296,8 +5353,26 @@ var StreamableHTTPClientTransport = class {
async send(message, options) {
return this._send(message, options, false);
}
- async _send(message, options, isAuthRetry, stepUpRetries = 0) {
+ async _recoverSession(expiredSessionId) {
+ if (this._sessionRecovery) return this._sessionRecovery;
+ if (this._sessionId !== expiredSessionId) return;
+ this._sessionId = void 0;
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.());
try {
+ await this._sessionRecovery;
+ } catch (error) {
+ this._sessionId = void 0;
+ await this.close();
+ throw error;
+ } finally {
+ this._sessionRecovery = void 0;
+ }
+ }
+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) {
+ try {
+ if (this._sessionRecovery && !require_src.isInitializeRequest(message) && !require_src.isInitializedNotification(message)) await this._sessionRecovery;
+ options?.requestSignal?.throwIfAborted();
+ if (options?.isRequestActive?.() === false) throw new Error("Request is no longer active");
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
this._startOrAuthSse({
@@ -5310,7 +5385,8 @@ var StreamableHTTPClientTransport = class {
const headers = await this._commonHeaders();
this._applyBodyDerivedHeaders(headers, message);
const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message);
- if (isHandshake) headers.delete("mcp-session-id");
+ if (isHandshake || this._isModernEnvelopedRequest(message)) headers.delete("mcp-session-id");
+ const requestSessionId = headers.get("mcp-session-id") ?? void 0;
if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) {
if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue;
headers.set(name, value);
@@ -5351,7 +5427,7 @@ var StreamableHTTPClientTransport = class {
throw markAuthSeamEscape(error);
}
await response.text?.().catch(() => {});
- return this._send(message, options, true, stepUpRetries);
+ return this._send(message, options, true, stepUpRetries, isSessionRetry);
}
await response.text?.().catch(() => {});
if (isAuthRetry) throw markAuthSeamEscape(new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", {
@@ -5361,6 +5437,10 @@ var StreamableHTTPClientTransport = class {
throw markAuthSeamEscape(new UnauthorizedError());
}
const text = await response.text?.().catch(() => null);
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !require_src.isInitializedNotification(message)) {
+ await this._recoverSession(requestSessionId);
+ return this._send(message, options, isAuthRetry, stepUpRetries, true);
+ }
if (response.status === 403) {
const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response);
if (error === "insufficient_scope") {
@@ -5371,7 +5451,7 @@ var StreamableHTTPClientTransport = class {
statusText: response.statusText,
text
}, stepUpRetries) !== "AUTHORIZED") throw markAuthSeamEscape(new UnauthorizedError());
- return this._send(message, options, isAuthRetry, stepUpRetries + 1);
+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry);
}
}
if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try {
@@ -5388,8 +5468,10 @@ var StreamableHTTPClientTransport = class {
text
});
}
+ const discovery = !Array.isArray(message) && message.method === "server/discover" && this._protocolVersion === void 0;
if (response.status === 202) {
await response.text?.().catch(() => {});
+ if (discovery) throw new DiscoveryReplyError();
if (require_src.isInitializedNotification(message)) this._startOrAuthSse({ resumptionToken: void 0 }).catch((error) => this.onerror?.(error));
return;
}
@@ -5397,16 +5479,27 @@ var StreamableHTTPClientTransport = class {
const contentType = response.headers.get("content-type");
const responseMediaType = require_src.mediaTypeEssence(contentType);
if (hasRequests) if (responseMediaType === "text/event-stream") this._handleSseStream(response.body, {
+ modern: this._isModernEnvelopedRequest(message),
+ ...(discovery && { onDiscoveryReplyError: options?.onDiscoveryReplyError, discoveryRequestId: message.id }),
onresumptiontoken,
requestSignal: options?.requestSignal,
onRequestStreamEnd: options?.onRequestStreamEnd
}, false);
else if (responseMediaType === "application/json") {
+ if (discovery) {
+ const text = await response.text();
+ let parsed;
+ try { parsed = _modelcontextprotocol_core_internal.JSONRPCMessageSchema.parse(JSON.parse(text)); } catch { throw new DiscoveryReplyError(text); }
+ if (!(require_src.isJSONRPCResultResponse(parsed) || require_src.isJSONRPCErrorResponse(parsed)) || parsed.id !== message.id) throw new DiscoveryReplyError(text);
+ this.onmessage?.(parsed);
+ return;
+ }
const data = await response.json();
const responseMessages = Array.isArray(data) ? data.map((msg) => _modelcontextprotocol_core_internal.JSONRPCMessageSchema.parse(msg)) : [_modelcontextprotocol_core_internal.JSONRPCMessageSchema.parse(data)];
for (const msg of responseMessages) this.onmessage?.(msg);
} else {
- await response.text?.().catch(() => {});
+ const text = await response.text();
+ if (discovery) throw new DiscoveryReplyError(text);
throw new require_src.SdkError(require_src.SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, { contentType });
}
else await response.text?.().catch(() => {});
diff --git a/dist/index.d.cts b/dist/index.d.cts
index 2d74180..6fde6de 100644
--- a/dist/index.d.cts
+++ b/dist/index.d.cts
@@ -1777,6 +1777,8 @@ type ClientOptions = ProtocolOptions & {
* cached-era-verdict knob.
*/
type ConnectOptions = RequestOptions & {
+ /** Optional unstarted stdio probe. Negotiation closes it before starting the live transport. */
+ probeTransport?: Transport;
/**
* A cached era verdict, taking precedence over `versionNegotiation`:
* `{ kind: 'modern', discover }` adopts a prior {@linkcode DiscoverResult}
diff --git a/dist/index.d.mts b/dist/index.d.mts
index d54447a..9b9ff7a 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -1777,6 +1777,8 @@ type ClientOptions = ProtocolOptions & {
* cached-era-verdict knob.
*/
type ConnectOptions = RequestOptions & {
+ /** Optional unstarted stdio probe. Negotiation closes it before starting the live transport. */
+ probeTransport?: Transport;
/**
* A cached era verdict, taking precedence over `versionNegotiation`:
* `{ kind: 'modern', discover }` adopts a prior {@linkcode DiscoverResult}
diff --git a/dist/index.mjs b/dist/index.mjs
index f02ce3c..c3a4c3f 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1247,8 +1247,12 @@ async function exchangeAuthorization(authorizationServerUrl, { metadata, clientI
* @returns Promise resolving to OAuth tokens (preserves original `refresh_token` if not replaced)
* @throws {Error} When token refresh fails or authentication is invalid
*/
+const refreshes = new Map();
async function refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken, resource, addClientAuthentication, fetchFn }) {
- return {
+ const key = `${authorizationServerUrl}\0${clientInformation.client_id}\0${refreshToken}`;
+ const current = refreshes.get(key);
+ if (current) return current;
+ const refresh = (async () => ({
refresh_token: refreshToken,
...await executeTokenRequest(authorizationServerUrl, {
metadata,
@@ -1261,7 +1265,13 @@ async function refreshAuthorization(authorizationServerUrl, { metadata, clientIn
resource,
fetchFn
})
- };
+ }))();
+ refreshes.set(key, refresh);
+ try {
+ return await refresh;
+ } finally {
+ if (refreshes.get(key) === refresh) refreshes.delete(key);
+ }
}
/**
* Unified token fetching that works with any grant type via {@linkcode OAuthClientProvider.prepareTokenRequest | prepareTokenRequest()}.
@@ -2285,17 +2295,20 @@ function isAuthSeamEscape(error) {
//#region src/client/probeClassifier.ts
/** The `-32022` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */
const UNSUPPORTED_PROTOCOL_VERSION = -32022;
+// Only completed malformed discovery replies carry this marker, never network/auth failures.
+var DiscoveryReplyError = class extends Error {
+ constructor(body) {
+ super("Invalid server/discover response");
+ this.body = body;
+ }
+};
/**
* Deliberately not probe-recognized in either direction: deployed servers
* overload `-32001` (the SDK-conventional `Session not found` body on a 2025
-* stateful server), and the spec-assigned `-32020` (`HeaderMismatch`) /
-* `-32021` (`MissingRequiredClientCapability`) are not era evidence — all
-* fall into the conservative legacy default.
+* stateful server), so it falls into the conservative legacy default.
*/
const NOT_PROBE_RECOGNIZED = new Set([
- -32001,
- -32020,
- -32021
+ -32001
]);
/**
* Classify a single probe outcome. Pure: no I/O, no state — loop-guard and
@@ -2332,7 +2345,7 @@ function classifyResult(result, context) {
version: overlap,
discover: parsed.value
};
- if (context.fallbackAvailable) return { kind: "legacy" };
+ if (context.fallbackAvailable && supportedVersions.some((version) => !isModernProtocolVersion(version))) return { kind: "legacy" };
return {
kind: "error",
error: new UnsupportedProtocolVersionError({
@@ -2366,6 +2379,10 @@ function classifyRpcError(outcome, context) {
error
};
}
+ if (code === HEADER_MISMATCH_ERROR_CODE || code === ProtocolErrorCode.MissingRequiredClientCapability) return {
+ kind: "error",
+ error: ProtocolError.fromError(code, message, data)
+ };
if (NOT_PROBE_RECOGNIZED.has(code)) return { kind: "legacy" };
return { kind: "legacy" };
}
@@ -2481,7 +2498,7 @@ function detectProbeEnvironment() {
* across bundles); everything else is treated like HTTP.
*/
function detectProbeTransportKind(transport) {
- return "stderr" in transport && "pid" in transport ? "stdio" : "http";
+ return transport.hasPerRequestStream === false || "stderr" in transport && "pid" in transport ? "stdio" : "http";
}
/**
* Temporary ownership of a raw transport for the negotiation exchange, before
@@ -2554,23 +2571,32 @@ var ProbeWindow = class ProbeWindow {
* Send one probe request and await its reply. Probe ids are strings, so they
* never collide with Protocol's numeric ids (e.g. on a shared stdio pipe).
*/
- async exchange(buildRequest, timeoutMs) {
+ async exchange(buildRequest, timeoutMs, signal) {
const id = `server-discover-probe-${++this._probeCounter}`;
return new Promise((resolve) => {
+ const requestAbort = new AbortController();
let settled = false;
const settle = (reply) => {
if (settled) return;
settled = true;
clearTimeout(timer);
+ signal?.removeEventListener("abort", abort);
+ requestAbort.abort();
if (this._pending?.id === id) this._pending = void 0;
resolve(reply);
};
const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
+ const abort = () => settle({ kind: "send-error", error: signal.reason });
+ signal?.addEventListener("abort", abort, { once: true });
+ if (signal?.aborted) return abort();
this._pending = {
id,
resolve: settle
};
- this._transport.send(buildRequest(id)).catch((error) => settle({
+ this._transport.send(buildRequest(id), {
+ requestSignal: requestAbort.signal,
+ onDiscoveryReplyError: (error) => settle({ kind: "send-error", error })
+ }).catch((error) => settle({
kind: "send-error",
error
}));
@@ -2655,6 +2681,10 @@ function normalizeReply(reply, timeoutMs) {
};
case "send-error": {
const error = reply.error;
+ if (error instanceof DiscoveryReplyError) {
+ const rpc = parseJsonRpcErrorBody(error.body);
+ return rpc ? { kind: "rpc-error", ...rpc } : { kind: "result", result: null };
+ }
if (isAuthSeamEscape(error) || error instanceof UnauthorizedError || error instanceof Error && error.name === "UnauthorizedError") return {
kind: "auth-required",
error
@@ -2688,6 +2718,7 @@ function normalizeReply(reply, timeoutMs) {
* window is detached and the transport's `start` is left untouched.
*/
async function negotiateEra(negotiation, deps) {
+ deps.signal?.throwIfAborted();
const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs;
const maxRetries = Math.max(0, negotiation.probe.maxRetries ?? 0);
const clientModernVersions = negotiation.kind === "pin" ? [negotiation.version] : negotiation.modernVersions;
@@ -2698,7 +2729,8 @@ async function negotiateEra(negotiation, deps) {
let correctiveUsed = false;
let timeoutRetriesRemaining = maxRetries;
for (;;) {
- const reply = await window.exchange((id) => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs);
+ const reply = await window.exchange((id) => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), timeoutMs, deps.signal);
+ deps.signal?.throwIfAborted();
if (reply.kind === "timeout" && timeoutRetriesRemaining > 0) {
timeoutRetriesRemaining--;
continue;
@@ -3197,6 +3229,7 @@ var Client = class extends Protocol {
* ```
*/
async connect(transport, options) {
+ transport.onsessionexpired = () => this._legacyHandshake(transport);
if (options?.prior != null) return this._connectFromPrior(transport, validatePrior(options.prior), options);
const negotiation = resolveVersionNegotiation(this._versionNegotiation, this._supportedProtocolVersionsOption);
if (negotiation.kind !== "legacy") return this._connectNegotiated(transport, negotiation, options);
@@ -3272,10 +3305,20 @@ var Client = class extends Protocol {
clientInfo: this._clientInfo,
capabilities: this._capabilities,
environment: detectProbeEnvironment(),
- defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC
+ defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC,
+ signal: options?.signal
};
const stdioParams = transportKind === "stdio" ? readStdioServerParams(transport) : void 0;
- result = stdioParams === void 0 ? await negotiateEra(negotiation, {
+ // The caller supplies a disposable probe on the same execution plane as its live transport.
+ if (options?.probeTransport) {
+ const sibling = options.probeTransport;
+ try {
+ result = await negotiateEra(negotiation, { ...baseDeps, transport: sibling, transportKind: "stdio", disposableProbe: true });
+ } finally {
+ await sibling.close();
+ }
+ options?.signal?.throwIfAborted();
+ } else result = stdioParams === void 0 ? await negotiateEra(negotiation, {
...baseDeps,
transport,
transportKind
@@ -3872,6 +3915,8 @@ var Client = class extends Protocol {
resolveOpening = resolve;
rejectOpening = reject;
});
+ // Cancellation can reject the ack waiter while transport.send is still opening HTTP.
+ opening.catch(() => {});
let resolveClosed;
const closed = new Promise((resolve) => {
resolveClosed = resolve;
@@ -3897,6 +3942,7 @@ var Client = class extends Protocol {
};
const wireTeardown = async () => {
requestAbort.abort();
+ if (this.transport?.hasPerRequestStream === true) return;
await this.notification({
method: "notifications/cancelled",
params: { requestId: listenId }
@@ -3982,6 +4028,10 @@ var Client = class extends Protocol {
* being silently swallowed.
*/
_onnotification(raw, extra) {
+ if (this.getProtocolEra() === "modern" && (Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) || raw.method === "notifications/resources/updated")) {
+ const id = raw.params?._meta?.[SUBSCRIPTION_ID_META_KEY];
+ if (typeof id !== "string" || !this._listenState.has(id)) return;
+ }
const evicted = Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) ? LIST_CHANGED_EVICTIONS[raw.method] : void 0;
if (raw.method === "notifications/resources/updated") {
const uri = raw.params?.uri;
@@ -5224,16 +5274,22 @@ var StreamableHTTPClientTransport = class {
if (!event.data) continue;
if (!event.event || event.event === "message") try {
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
+ if (options.onDiscoveryReplyError && (!(isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) || message.id !== options.discoveryRequestId)) throw new DiscoveryReplyError(event.data);
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
receivedResponse = true;
if (replayMessageId !== void 0) message.id = replayMessageId;
}
this.onmessage?.(message);
} catch (error) {
+ if (options.onDiscoveryReplyError) {
+ options.onDiscoveryReplyError(new DiscoveryReplyError(event.data));
+ return;
+ }
this.onerror?.(error);
}
}
- if ((isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) this._scheduleReconnection({
+ if (options.onDiscoveryReplyError && !receivedResponse && !isIntentionalAbort()) options.onDiscoveryReplyError(new DiscoveryReplyError());
+ if (!options.modern && (isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) this._scheduleReconnection({
resumptionToken: lastEventId,
onresumptiontoken,
replayMessageId,
@@ -5243,8 +5299,9 @@ var StreamableHTTPClientTransport = class {
else if (!isIntentionalAbort()) onRequestStreamEnd?.();
} catch (error) {
if (isIntentionalAbort()) return;
+ options.onDiscoveryReplyError?.(error);
this.onerror?.(/* @__PURE__ */ new Error(`SSE stream disconnected: ${error}`));
- if ((isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) try {
+ if (!options.modern && (isReconnectable || hasPrimingEvent) && !receivedResponse && this._abortController && !isIntentionalAbort()) try {
this._scheduleReconnection({
resumptionToken: lastEventId,
onresumptiontoken,
@@ -5293,8 +5350,26 @@ var StreamableHTTPClientTransport = class {
async send(message, options) {
return this._send(message, options, false);
}
- async _send(message, options, isAuthRetry, stepUpRetries = 0) {
+ async _recoverSession(expiredSessionId) {
+ if (this._sessionRecovery) return this._sessionRecovery;
+ if (this._sessionId !== expiredSessionId) return;
+ this._sessionId = void 0;
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.());
try {
+ await this._sessionRecovery;
+ } catch (error) {
+ this._sessionId = void 0;
+ await this.close();
+ throw error;
+ } finally {
+ this._sessionRecovery = void 0;
+ }
+ }
+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) {
+ try {
+ if (this._sessionRecovery && !isInitializeRequest(message) && !isInitializedNotification(message)) await this._sessionRecovery;
+ options?.requestSignal?.throwIfAborted();
+ if (options?.isRequestActive?.() === false) throw new Error("Request is no longer active");
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
this._startOrAuthSse({
@@ -5307,7 +5382,8 @@ var StreamableHTTPClientTransport = class {
const headers = await this._commonHeaders();
this._applyBodyDerivedHeaders(headers, message);
const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message);
- if (isHandshake) headers.delete("mcp-session-id");
+ if (isHandshake || this._isModernEnvelopedRequest(message)) headers.delete("mcp-session-id");
+ const requestSessionId = headers.get("mcp-session-id") ?? void 0;
if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) {
if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue;
headers.set(name, value);
@@ -5348,7 +5424,7 @@ var StreamableHTTPClientTransport = class {
throw markAuthSeamEscape(error);
}
await response.text?.().catch(() => {});
- return this._send(message, options, true, stepUpRetries);
+ return this._send(message, options, true, stepUpRetries, isSessionRetry);
}
await response.text?.().catch(() => {});
if (isAuthRetry) throw markAuthSeamEscape(new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", {
@@ -5358,6 +5434,10 @@ var StreamableHTTPClientTransport = class {
throw markAuthSeamEscape(new UnauthorizedError());
}
const text = await response.text?.().catch(() => null);
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitializedNotification(message)) {
+ await this._recoverSession(requestSessionId);
+ return this._send(message, options, isAuthRetry, stepUpRetries, true);
+ }
if (response.status === 403) {
const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response);
if (error === "insufficient_scope") {
@@ -5368,7 +5448,7 @@ var StreamableHTTPClientTransport = class {
statusText: response.statusText,
text
}, stepUpRetries) !== "AUTHORIZED") throw markAuthSeamEscape(new UnauthorizedError());
- return this._send(message, options, isAuthRetry, stepUpRetries + 1);
+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry);
}
}
if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try {
@@ -5385,8 +5465,10 @@ var StreamableHTTPClientTransport = class {
text
});
}
+ const discovery = !Array.isArray(message) && message.method === "server/discover" && this._protocolVersion === void 0;
if (response.status === 202) {
await response.text?.().catch(() => {});
+ if (discovery) throw new DiscoveryReplyError();
if (isInitializedNotification(message)) this._startOrAuthSse({ resumptionToken: void 0 }).catch((error) => this.onerror?.(error));
return;
}
@@ -5394,16 +5476,27 @@ var StreamableHTTPClientTransport = class {
const contentType = response.headers.get("content-type");
const responseMediaType = mediaTypeEssence(contentType);
if (hasRequests) if (responseMediaType === "text/event-stream") this._handleSseStream(response.body, {
+ modern: this._isModernEnvelopedRequest(message),
+ ...(discovery && { onDiscoveryReplyError: options?.onDiscoveryReplyError, discoveryRequestId: message.id }),
onresumptiontoken,
requestSignal: options?.requestSignal,
onRequestStreamEnd: options?.onRequestStreamEnd
}, false);
else if (responseMediaType === "application/json") {
+ if (discovery) {
+ const text = await response.text();
+ let parsed;
+ try { parsed = JSONRPCMessageSchema.parse(JSON.parse(text)); } catch { throw new DiscoveryReplyError(text); }
+ if (!(isJSONRPCResultResponse(parsed) || isJSONRPCErrorResponse(parsed)) || parsed.id !== message.id) throw new DiscoveryReplyError(text);
+ this.onmessage?.(parsed);
+ return;
+ }
const data = await response.json();
const responseMessages = Array.isArray(data) ? data.map((msg) => JSONRPCMessageSchema.parse(msg)) : [JSONRPCMessageSchema.parse(data)];
for (const msg of responseMessages) this.onmessage?.(msg);
} else {
- await response.text?.().catch(() => {});
+ const text = await response.text();
+ if (discovery) throw new DiscoveryReplyError(text);
throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, { contentType });
}
else await response.text?.().catch(() => {});
diff --git a/dist/src-D_zzAWoS.mjs b/dist/src-D_zzAWoS.mjs
index 6ac3d53..9bc225a 100644
--- a/dist/src-D_zzAWoS.mjs
+++ b/dist/src-D_zzAWoS.mjs
@@ -6096,7 +6096,7 @@ var Protocol = class {
const cancel = (reason) => {
if (responseReceived) return;
this._progressHandlers.delete(messageId);
- if (requestAbort === void 0) this._transport?.send(this._envelopeOutbound({
+ if (requestAbort === void 0 && request.method !== "initialize") this._transport?.send(this._envelopeOutbound({
jsonrpc: "2.0",
method: "notifications/cancelled",
params: {
@@ -6108,7 +6108,7 @@ var Protocol = class {
resumptionToken,
onresumptiontoken
}).catch((error) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error}`)));
- else requestAbort.abort();
+ else requestAbort?.abort();
reject(reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)));
};
this._responseHandlers.set(messageId, (response) => {
@@ -6153,7 +6153,11 @@ var Protocol = class {
resumptionToken,
onresumptiontoken,
headers,
- requestSignal: requestAbort?.signal
+ requestSignal: requestAbort?.signal,
+ onRequestStreamEnd: requestAbort === void 0 ? void 0 : () => {
+ if (!responseReceived) reject(new SdkError(SdkErrorCode.ConnectionClosed, "MCP response stream ended before a result"));
+ },
+ isRequestActive: () => this._responseHandlers.has(messageId)
}).catch((error) => {
this._progressHandlers.delete(messageId);
reject(error);
diff --git a/dist/src-NAgB4Mp8.cjs b/dist/src-NAgB4Mp8.cjs
index e8f2499..4efefe5 100644
--- a/dist/src-NAgB4Mp8.cjs
+++ b/dist/src-NAgB4Mp8.cjs
@@ -6097,7 +6097,7 @@ var Protocol = class {
const cancel = (reason) => {
if (responseReceived) return;
this._progressHandlers.delete(messageId);
- if (requestAbort === void 0) this._transport?.send(this._envelopeOutbound({
+ if (requestAbort === void 0 && request.method !== "initialize") this._transport?.send(this._envelopeOutbound({
jsonrpc: "2.0",
method: "notifications/cancelled",
params: {
@@ -6109,7 +6109,7 @@ var Protocol = class {
resumptionToken,
onresumptiontoken
}).catch((error) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error}`)));
- else requestAbort.abort();
+ else requestAbort?.abort();
reject(reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)));
};
this._responseHandlers.set(messageId, (response) => {
@@ -6154,7 +6154,11 @@ var Protocol = class {
resumptionToken,
onresumptiontoken,
headers,
- requestSignal: requestAbort?.signal
+ requestSignal: requestAbort?.signal,
+ onRequestStreamEnd: requestAbort === void 0 ? void 0 : () => {
+ if (!responseReceived) reject(new SdkError(SdkErrorCode.ConnectionClosed, "MCP response stream ended before a result"));
+ },
+ isRequestActive: () => this._responseHandlers.has(messageId)
}).catch((error) => {
this._progressHandlers.delete(messageId);
reject(error);
@@ -1,755 +0,0 @@
diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..b4a3a3b33bc97206c6835e2ee221cc13456210e4 100644
--- a/dist/cjs/client/auth.d.ts
+++ b/dist/cjs/client/auth.d.ts
@@ -205,6 +205,15 @@ export declare function selectClientAuthMethod(clientInformation: OAuthClientInf
* @returns A Promise that resolves to an OAuthError instance
*/
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+export declare function determineScope(options: {
+ requestedScope?: string;
+ resourceMetadata?: OAuthProtectedResourceMetadata;
+ authServerMetadata?: AuthorizationServerMetadata;
+ clientMetadata: OAuthClientMetadata;
+}): string | undefined;
/**
* Orchestrates the full auth flow with a server.
*
diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js
index c2e4fa91d26f5336889f6afa416147db75fc4872..152eed7cbb6e39ce4d711cf28a3e8d8fcf6d699d 100644
--- a/dist/cjs/client/auth.js
+++ b/dist/cjs/client/auth.js
@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0;
exports.selectClientAuthMethod = selectClientAuthMethod;
exports.parseErrorResponse = parseErrorResponse;
exports.auth = auth;
+exports.determineScope = determineScope;
exports.isHttpsUrl = isHttpsUrl;
exports.selectResourceURL = selectResourceURL;
exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams;
@@ -186,6 +187,19 @@ async function auth(provider, options) {
throw error;
}
}
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
+ if (effectiveScope &&
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
+ !effectiveScope.split(' ').includes('offline_access') &&
+ clientMetadata.grant_types?.includes('refresh_token')) {
+ effectiveScope = `${effectiveScope} offline_access`;
+ }
+ return effectiveScope;
+}
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
// Check if the provider has cached discovery state to skip discovery
const cachedState = await provider.discoveryState?.();
@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
});
}
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
- // Apply scope selection strategy (SEP-835):
- // 1. WWW-Authenticate scope (passed via `scope` param)
- // 2. PRM scopes_supported
- // 3. Client metadata scope (user-configured fallback)
- // The resolved scope is used consistently for both DCR and the authorization request.
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
+ const resolvedScope = determineScope({
+ requestedScope: scope,
+ resourceMetadata,
+ authServerMetadata: metadata,
+ clientMetadata: provider.clientMetadata
+ });
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
if (scope) {
authorizationUrl.searchParams.set('scope', scope);
}
- if (scope?.includes('offline_access')) {
+ if (scope?.split(' ').includes('offline_access')) {
// if the request includes the OIDC-only "offline_access" scope,
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
@@ -837,21 +851,38 @@ async function exchangeAuthorization(authorizationServerUrl, { metadata, clientI
* @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced)
* @throws {Error} When token refresh fails or authentication is invalid
*/
+const refreshes = new Map();
async function refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken, resource, addClientAuthentication, fetchFn }) {
- const tokenRequestParams = new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: refreshToken
- });
- const tokens = await executeTokenRequest(authorizationServerUrl, {
- metadata,
- tokenRequestParams,
- clientInformation,
- addClientAuthentication,
- resource,
- fetchFn
- });
- // Preserve original refresh token if server didn't return a new one
- return { refresh_token: refreshToken, ...tokens };
+ const key = `${authorizationServerUrl}\0${clientInformation.client_id}\0${refreshToken}`;
+ const current = refreshes.get(key);
+ if (current) {
+ return current;
+ }
+ const refresh = (async () => {
+ const tokenRequestParams = new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken
+ });
+ const tokens = await executeTokenRequest(authorizationServerUrl, {
+ metadata,
+ tokenRequestParams,
+ clientInformation,
+ addClientAuthentication,
+ resource,
+ fetchFn
+ });
+ // Preserve original refresh token if server didn't return a new one
+ return { refresh_token: refreshToken, ...tokens };
+ })();
+ refreshes.set(key, refresh);
+ try {
+ return await refresh;
+ }
+ finally {
+ if (refreshes.get(key) === refresh) {
+ refreshes.delete(key);
+ }
+ }
}
/**
* Unified token fetching that works with any grant type via provider.prepareTokenRequest().
diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts
index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644
--- a/dist/cjs/client/index.d.ts
+++ b/dist/cjs/client/index.d.ts
@@ -428,6 +428,8 @@ export declare class Client<RequestT extends Request = Request, NotificationT ex
*
* For task-based execution with streaming behavior, use client.experimental.tasks.callToolStream() instead.
*/
+ callTool(params: CallToolRequest['params'], resultSchema?: undefined, options?: RequestOptions): Promise<SchemaOutput<typeof CallToolResultSchema>>;
+ callTool<T extends typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema>(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise<SchemaOutput<T>>;
callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{
[x: string]: unknown;
content: ({
diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js
index 6ac1da14dc7f6211ae70f7711c124b76098816d8..8a0200720454eac591e174f6a948212af8f852fc 100644
--- a/dist/cjs/client/index.js
+++ b/dist/cjs/client/index.js
@@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol {
}
async connect(transport, options) {
await super.connect(transport);
+ transport.onsessionexpired = async () => {
+ await this._initialize(transport);
+ };
// When transport sessionId is already set this means we are trying to reconnect.
// In this case we don't need to initialize again.
if (transport.sessionId !== undefined) {
return;
}
try {
- const result = await this.request({
- method: 'initialize',
- params: {
- protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION,
- capabilities: this._capabilities,
- clientInfo: this._clientInfo
- }
- }, types_js_1.InitializeResultSchema, options);
- if (result === undefined) {
- throw new Error(`Server sent invalid initialize result: ${result}`);
- }
- if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
- }
- this._serverCapabilities = result.capabilities;
- this._serverVersion = result.serverInfo;
- // HTTP transports must set the protocol version in each header after initialization.
- if (transport.setProtocolVersion) {
- transport.setProtocolVersion(result.protocolVersion);
- }
- this._instructions = result.instructions;
- await this.notification({
- method: 'notifications/initialized'
- });
- // Set up list changed handlers now that we know server capabilities
- if (this._pendingListChangedConfig) {
- this._setupListChangedHandlers(this._pendingListChangedConfig);
- this._pendingListChangedConfig = undefined;
- }
+ await this._initialize(transport, options);
}
catch (error) {
// Disconnect if initialization fails.
@@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol {
throw error;
}
}
+ async _initialize(transport, options) {
+ const result = await this.request({
+ method: 'initialize',
+ params: {
+ protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION,
+ capabilities: this._capabilities,
+ clientInfo: this._clientInfo
+ }
+ }, types_js_1.InitializeResultSchema, options);
+ if (result === undefined) {
+ throw new Error(`Server sent invalid initialize result: ${result}`);
+ }
+ if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
+ }
+ this._serverCapabilities = result.capabilities;
+ this._serverVersion = result.serverInfo;
+ // HTTP transports must set the protocol version in each header after initialization.
+ if (transport.setProtocolVersion) {
+ transport.setProtocolVersion(result.protocolVersion);
+ }
+ this._instructions = result.instructions;
+ await this.notification({
+ method: 'notifications/initialized'
+ });
+ // Set up list changed handlers now that we know server capabilities
+ if (this._pendingListChangedConfig) {
+ this._setupListChangedHandlers(this._pendingListChangedConfig);
+ this._pendingListChangedConfig = undefined;
+ }
+ }
/**
* After initialization has completed, this will be populated with the server's reported capabilities.
*/
@@ -540,10 +546,12 @@ class Client extends protocol_js_1.Protocol {
* Cache validators for tool output schemas.
* Called after listTools() to pre-compile validators for better performance.
*/
- cacheToolMetadata(tools) {
- this._cachedToolOutputValidators.clear();
- this._cachedKnownTaskTools.clear();
- this._cachedRequiredTaskTools.clear();
+ cacheToolMetadata(tools, reset = true) {
+ if (reset) {
+ this._cachedToolOutputValidators.clear();
+ this._cachedKnownTaskTools.clear();
+ this._cachedRequiredTaskTools.clear();
+ }
for (const tool of tools) {
// If the tool has an outputSchema, create and cache the validator
if (tool.outputSchema) {
@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol {
async listTools(params, options) {
const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options);
// Cache the tools and their output schemas for future validation
- this.cacheToolMetadata(result.tools);
+ this.cacheToolMetadata(result.tools, params?.cursor === undefined);
return result;
}
/**
diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js
index a29a7d3a0f14d9cd800ef5b296485237350c666f..a55e7ed79d18c5fb913227d5b8e5ca6f44cb51e4 100644
--- a/dist/cjs/client/streamableHttp.js
+++ b/dist/cjs/client/streamableHttp.js
@@ -204,7 +204,7 @@ class StreamableHTTPClientTransport {
if (!event.event || event.event === 'message') {
try {
const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data));
- if ((0, types_js_1.isJSONRPCResultResponse)(message)) {
+ if ((0, types_js_1.isJSONRPCResultResponse)(message) || (0, types_js_1.isJSONRPCErrorResponse)(message)) {
// Mark that we received a response - no need to reconnect for this request
receivedResponse = true;
if (replayMessageId !== undefined) {
@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport {
this.onclose?.();
}
async send(message, options) {
+ return this._send(message, options, false);
+ }
+ async _recoverSession(expiredSessionId) {
+ if (this._sessionRecovery) {
+ await this._sessionRecovery;
+ return true;
+ }
+ if (this._sessionId !== expiredSessionId)
+ return true;
+ this._sessionId = undefined;
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.());
try {
+ await this._sessionRecovery;
+ }
+ catch (error) {
+ this._sessionId = undefined;
+ await this.close();
+ throw error;
+ }
+ finally {
+ this._sessionRecovery = undefined;
+ }
+ return true;
+ }
+ async _send(message, options, isSessionRetry) {
+ try {
+ if (this._sessionRecovery && !(0, types_js_1.isInitializeRequest)(message) && !(0, types_js_1.isInitializedNotification)(message)) {
+ await this._sessionRecovery;
+ if (options?.isRequestActive?.() === false) {
+ throw new Error('Request is no longer active');
+ }
+ }
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
// If we have at last event ID, we need to reconnect the SSE stream
@@ -298,6 +329,7 @@ class StreamableHTTPClientTransport {
return;
}
const headers = await this._commonHeaders();
+ const requestSessionId = headers.get('mcp-session-id') ?? undefined;
headers.set('content-type', 'application/json');
headers.set('accept', 'application/json, text/event-stream');
const init = {
@@ -310,11 +342,20 @@ class StreamableHTTPClientTransport {
const response = await (this._fetch ?? fetch)(this._url, init);
// Handle session ID received during initialization
const sessionId = response.headers.get('mcp-session-id');
- if (sessionId) {
+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) {
this._sessionId = sessionId;
}
if (!response.ok) {
const text = await response.text().catch(() => null);
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !(0, types_js_1.isInitializedNotification)(message)) {
+ const recovered = await this._recoverSession(requestSessionId);
+ if (options?.isRequestActive?.() === false) {
+ throw new Error('Request is no longer active');
+ }
+ if (recovered) {
+ return this._send(message, options, true);
+ }
+ }
if (response.status === 401 && this._authProvider) {
// Prevent infinite recursion when server returns 401 after successful auth
if (this._hasCompletedAuthFlow) {
@@ -335,7 +376,7 @@ class StreamableHTTPClientTransport {
// Mark that we completed auth flow
this._hasCompletedAuthFlow = true;
// Purposely _not_ awaited, so we don't call onerror twice
- return this.send(message);
+ return this._send(message, options, isSessionRetry);
}
if (response.status === 403 && this._authProvider) {
const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response);
@@ -362,7 +403,7 @@ class StreamableHTTPClientTransport {
if (result !== 'AUTHORIZED') {
throw new auth_js_1.UnauthorizedError();
}
- return this.send(message);
+ return this._send(message, options, isSessionRetry);
}
}
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
diff --git a/dist/cjs/shared/protocol.js b/dist/cjs/shared/protocol.js
index 3617e787f0ba70447c99501aee7aa67584d89758..4ee4d158391558fdc1f977f5134b7cacfc45e8c3 100644
--- a/dist/cjs/shared/protocol.js
+++ b/dist/cjs/shared/protocol.js
@@ -744,7 +744,12 @@ class Protocol {
}
else {
// No related task - send through transport normally
- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => {
+ this._transport.send(jsonrpcRequest, {
+ relatedRequestId,
+ resumptionToken,
+ onresumptiontoken,
+ isRequestActive: () => this._responseHandlers.has(messageId)
+ }).catch(error => {
this._cleanupTimeout(messageId);
reject(error);
});
diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..b4a3a3b33bc97206c6835e2ee221cc13456210e4 100644
--- a/dist/esm/client/auth.d.ts
+++ b/dist/esm/client/auth.d.ts
@@ -205,6 +205,15 @@ export declare function selectClientAuthMethod(clientInformation: OAuthClientInf
* @returns A Promise that resolves to an OAuthError instance
*/
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+export declare function determineScope(options: {
+ requestedScope?: string;
+ resourceMetadata?: OAuthProtectedResourceMetadata;
+ authServerMetadata?: AuthorizationServerMetadata;
+ clientMetadata: OAuthClientMetadata;
+}): string | undefined;
/**
* Orchestrates the full auth flow with a server.
*
diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js
index e183040fc2bba22ca1ccc784984f3310854403b7..1fef5ff7926604d74d8bfae100a2be01040a0e99 100644
--- a/dist/esm/client/auth.js
+++ b/dist/esm/client/auth.js
@@ -161,6 +161,19 @@ export async function auth(provider, options) {
throw error;
}
}
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
+ if (effectiveScope &&
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
+ !effectiveScope.split(' ').includes('offline_access') &&
+ clientMetadata.grant_types?.includes('refresh_token')) {
+ effectiveScope = `${effectiveScope} offline_access`;
+ }
+ return effectiveScope;
+}
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
// Check if the provider has cached discovery state to skip discovery
const cachedState = await provider.discoveryState?.();
@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
});
}
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
- // Apply scope selection strategy (SEP-835):
- // 1. WWW-Authenticate scope (passed via `scope` param)
- // 2. PRM scopes_supported
- // 3. Client metadata scope (user-configured fallback)
- // The resolved scope is used consistently for both DCR and the authorization request.
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
+ const resolvedScope = determineScope({
+ requestedScope: scope,
+ resourceMetadata,
+ authServerMetadata: metadata,
+ clientMetadata: provider.clientMetadata
+ });
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli
if (scope) {
authorizationUrl.searchParams.set('scope', scope);
}
- if (scope?.includes('offline_access')) {
+ if (scope?.split(' ').includes('offline_access')) {
// if the request includes the OIDC-only "offline_access" scope,
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
@@ -812,21 +825,38 @@ export async function exchangeAuthorization(authorizationServerUrl, { metadata,
* @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced)
* @throws {Error} When token refresh fails or authentication is invalid
*/
+const refreshes = new Map();
export async function refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken, resource, addClientAuthentication, fetchFn }) {
- const tokenRequestParams = new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: refreshToken
- });
- const tokens = await executeTokenRequest(authorizationServerUrl, {
- metadata,
- tokenRequestParams,
- clientInformation,
- addClientAuthentication,
- resource,
- fetchFn
- });
- // Preserve original refresh token if server didn't return a new one
- return { refresh_token: refreshToken, ...tokens };
+ const key = `${authorizationServerUrl}\0${clientInformation.client_id}\0${refreshToken}`;
+ const current = refreshes.get(key);
+ if (current) {
+ return current;
+ }
+ const refresh = (async () => {
+ const tokenRequestParams = new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken
+ });
+ const tokens = await executeTokenRequest(authorizationServerUrl, {
+ metadata,
+ tokenRequestParams,
+ clientInformation,
+ addClientAuthentication,
+ resource,
+ fetchFn
+ });
+ // Preserve original refresh token if server didn't return a new one
+ return { refresh_token: refreshToken, ...tokens };
+ })();
+ refreshes.set(key, refresh);
+ try {
+ return await refresh;
+ }
+ finally {
+ if (refreshes.get(key) === refresh) {
+ refreshes.delete(key);
+ }
+ }
}
/**
* Unified token fetching that works with any grant type via provider.prepareTokenRequest().
diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts
index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644
--- a/dist/esm/client/index.d.ts
+++ b/dist/esm/client/index.d.ts
@@ -428,6 +428,8 @@ export declare class Client<RequestT extends Request = Request, NotificationT ex
*
* For task-based execution with streaming behavior, use client.experimental.tasks.callToolStream() instead.
*/
+ callTool(params: CallToolRequest['params'], resultSchema?: undefined, options?: RequestOptions): Promise<SchemaOutput<typeof CallToolResultSchema>>;
+ callTool<T extends typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema>(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise<SchemaOutput<T>>;
callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{
[x: string]: unknown;
content: ({
diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js
index 49b12c6cd918c457420fef7ad5528a9443d1a191..98c214181d9c4c1b197c53dfa79059788e2042e8 100644
--- a/dist/esm/client/index.js
+++ b/dist/esm/client/index.js
@@ -284,41 +284,16 @@ export class Client extends Protocol {
}
async connect(transport, options) {
await super.connect(transport);
+ transport.onsessionexpired = async () => {
+ await this._initialize(transport);
+ };
// When transport sessionId is already set this means we are trying to reconnect.
// In this case we don't need to initialize again.
if (transport.sessionId !== undefined) {
return;
}
try {
- const result = await this.request({
- method: 'initialize',
- params: {
- protocolVersion: LATEST_PROTOCOL_VERSION,
- capabilities: this._capabilities,
- clientInfo: this._clientInfo
- }
- }, InitializeResultSchema, options);
- if (result === undefined) {
- throw new Error(`Server sent invalid initialize result: ${result}`);
- }
- if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
- }
- this._serverCapabilities = result.capabilities;
- this._serverVersion = result.serverInfo;
- // HTTP transports must set the protocol version in each header after initialization.
- if (transport.setProtocolVersion) {
- transport.setProtocolVersion(result.protocolVersion);
- }
- this._instructions = result.instructions;
- await this.notification({
- method: 'notifications/initialized'
- });
- // Set up list changed handlers now that we know server capabilities
- if (this._pendingListChangedConfig) {
- this._setupListChangedHandlers(this._pendingListChangedConfig);
- this._pendingListChangedConfig = undefined;
- }
+ await this._initialize(transport, options);
}
catch (error) {
// Disconnect if initialization fails.
@@ -326,6 +301,37 @@ export class Client extends Protocol {
throw error;
}
}
+ async _initialize(transport, options) {
+ const result = await this.request({
+ method: 'initialize',
+ params: {
+ protocolVersion: LATEST_PROTOCOL_VERSION,
+ capabilities: this._capabilities,
+ clientInfo: this._clientInfo
+ }
+ }, InitializeResultSchema, options);
+ if (result === undefined) {
+ throw new Error(`Server sent invalid initialize result: ${result}`);
+ }
+ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
+ }
+ this._serverCapabilities = result.capabilities;
+ this._serverVersion = result.serverInfo;
+ // HTTP transports must set the protocol version in each header after initialization.
+ if (transport.setProtocolVersion) {
+ transport.setProtocolVersion(result.protocolVersion);
+ }
+ this._instructions = result.instructions;
+ await this.notification({
+ method: 'notifications/initialized'
+ });
+ // Set up list changed handlers now that we know server capabilities
+ if (this._pendingListChangedConfig) {
+ this._setupListChangedHandlers(this._pendingListChangedConfig);
+ this._pendingListChangedConfig = undefined;
+ }
+ }
/**
* After initialization has completed, this will be populated with the server's reported capabilities.
*/
@@ -536,10 +542,12 @@ export class Client extends Protocol {
* Cache validators for tool output schemas.
* Called after listTools() to pre-compile validators for better performance.
*/
- cacheToolMetadata(tools) {
- this._cachedToolOutputValidators.clear();
- this._cachedKnownTaskTools.clear();
- this._cachedRequiredTaskTools.clear();
+ cacheToolMetadata(tools, reset = true) {
+ if (reset) {
+ this._cachedToolOutputValidators.clear();
+ this._cachedKnownTaskTools.clear();
+ this._cachedRequiredTaskTools.clear();
+ }
for (const tool of tools) {
// If the tool has an outputSchema, create and cache the validator
if (tool.outputSchema) {
@@ -565,7 +573,7 @@ export class Client extends Protocol {
async listTools(params, options) {
const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options);
// Cache the tools and their output schemas for future validation
- this.cacheToolMetadata(result.tools);
+ this.cacheToolMetadata(result.tools, params?.cursor === undefined);
return result;
}
/**
diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js
index 624172aa24ae255a67c083f9c19053343e4a0581..f92c889456cab12de963959716846fb9770ed71d 100644
--- a/dist/esm/client/streamableHttp.js
+++ b/dist/esm/client/streamableHttp.js
@@ -1,5 +1,5 @@
import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js';
-import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
+import { isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
import { EventSourceParserStream } from 'eventsource-parser/stream';
// Default reconnection options for StreamableHTTP connections
@@ -200,7 +200,7 @@ export class StreamableHTTPClientTransport {
if (!event.event || event.event === 'message') {
try {
const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
- if (isJSONRPCResultResponse(message)) {
+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
// Mark that we received a response - no need to reconnect for this request
receivedResponse = true;
if (replayMessageId !== undefined) {
@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport {
this.onclose?.();
}
async send(message, options) {
+ return this._send(message, options, false);
+ }
+ async _recoverSession(expiredSessionId) {
+ if (this._sessionRecovery) {
+ await this._sessionRecovery;
+ return true;
+ }
+ if (this._sessionId !== expiredSessionId)
+ return true;
+ this._sessionId = undefined;
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.());
try {
+ await this._sessionRecovery;
+ }
+ catch (error) {
+ this._sessionId = undefined;
+ await this.close();
+ throw error;
+ }
+ finally {
+ this._sessionRecovery = undefined;
+ }
+ return true;
+ }
+ async _send(message, options, isSessionRetry) {
+ try {
+ if (this._sessionRecovery && !isInitializeRequest(message) && !isInitializedNotification(message)) {
+ await this._sessionRecovery;
+ if (options?.isRequestActive?.() === false) {
+ throw new Error('Request is no longer active');
+ }
+ }
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
// If we have at last event ID, we need to reconnect the SSE stream
@@ -294,6 +325,7 @@ export class StreamableHTTPClientTransport {
return;
}
const headers = await this._commonHeaders();
+ const requestSessionId = headers.get('mcp-session-id') ?? undefined;
headers.set('content-type', 'application/json');
headers.set('accept', 'application/json, text/event-stream');
const init = {
@@ -306,11 +338,20 @@ export class StreamableHTTPClientTransport {
const response = await (this._fetch ?? fetch)(this._url, init);
// Handle session ID received during initialization
const sessionId = response.headers.get('mcp-session-id');
- if (sessionId) {
+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) {
this._sessionId = sessionId;
}
if (!response.ok) {
const text = await response.text().catch(() => null);
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitializedNotification(message)) {
+ const recovered = await this._recoverSession(requestSessionId);
+ if (options?.isRequestActive?.() === false) {
+ throw new Error('Request is no longer active');
+ }
+ if (recovered) {
+ return this._send(message, options, true);
+ }
+ }
if (response.status === 401 && this._authProvider) {
// Prevent infinite recursion when server returns 401 after successful auth
if (this._hasCompletedAuthFlow) {
@@ -331,7 +372,7 @@ export class StreamableHTTPClientTransport {
// Mark that we completed auth flow
this._hasCompletedAuthFlow = true;
// Purposely _not_ awaited, so we don't call onerror twice
- return this.send(message);
+ return this._send(message, options, isSessionRetry);
}
if (response.status === 403 && this._authProvider) {
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
@@ -358,7 +399,7 @@ export class StreamableHTTPClientTransport {
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
}
- return this.send(message);
+ return this._send(message, options, isSessionRetry);
}
}
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
diff --git a/dist/esm/shared/protocol.js b/dist/esm/shared/protocol.js
index bfa2b7120a0f50c569364ea5264e6f811076f44f..dec477d16a0fd796854542c1144279a6e86567f2 100644
--- a/dist/esm/shared/protocol.js
+++ b/dist/esm/shared/protocol.js
@@ -740,7 +740,12 @@ export class Protocol {
}
else {
// No related task - send through transport normally
- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => {
+ this._transport.send(jsonrpcRequest, {
+ relatedRequestId,
+ resumptionToken,
+ onresumptiontoken,
+ isRequestActive: () => this._responseHandlers.has(messageId)
+ }).catch(error => {
this._cleanupTimeout(messageId);
reject(error);
});