mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 18:10:46 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23a8c34265 | |||
| 80dc48e74a | |||
| 59468a56f8 | |||
| 044e065775 | |||
| 9fa8680df4 | |||
| 1a33f0df7b | |||
| 0992cd520e | |||
| eb7beecb9b |
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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" })
|
||||
@@ -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" }] }
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user