From 982a9044c515482e7792039be1db9c71cb572745 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:53:35 -0500 Subject: [PATCH] fix(mcp): restore legacy SDK compatibility (#39373) --- bun.lock | 83 ++- bunfig.toml | 2 +- package.json | 4 +- packages/opencode/package.json | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 151 +++-- packages/opencode/src/mcp/auth.ts | 4 - packages/opencode/src/mcp/catalog.ts | 135 ++-- packages/opencode/src/mcp/index.ts | 82 +-- packages/opencode/src/mcp/oauth-callback.ts | 12 +- packages/opencode/src/mcp/oauth-provider.ts | 85 +-- .../routes/instance/httpapi/groups/mcp.ts | 1 - .../routes/instance/httpapi/handlers/mcp.ts | 2 +- packages/opencode/src/session/tools.ts | 2 +- packages/opencode/src/tool/code-mode.ts | 25 +- .../test/fixture/mcp-lifecycle-stdio.ts | 7 +- .../test/fixture/mcp-session-recovery.ts | 14 +- packages/opencode/test/mcp/catalog.test.ts | 66 +- packages/opencode/test/mcp/headers.test.ts | 25 +- packages/opencode/test/mcp/lifecycle.test.ts | 31 +- .../test/mcp/oauth-auto-connect.test.ts | 8 +- .../opencode/test/mcp/oauth-browser.test.ts | 6 +- .../opencode/test/mcp/oauth-callback.test.ts | 2 +- .../opencode/test/mcp/oauth-provider.test.ts | 41 ++ .../test/mcp/session-recovery.test.ts | 20 - .../test/tool/code-mode-integration.test.ts | 17 +- packages/opencode/test/tool/code-mode.test.ts | 2 +- packages/opencode/test/tool/registry.test.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 - packages/sdk/js/src/v2/gen/types.gen.ts | 1 - ...@modelcontextprotocol%2Fclient@2.0.0.patch | 214 ------ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 629 ++++++++++++++++++ 31 files changed, 1082 insertions(+), 596 deletions(-) delete mode 100644 patches/@modelcontextprotocol%2Fclient@2.0.0.patch create mode 100644 patches/@modelcontextprotocol%2Fsdk@1.29.0.patch diff --git a/bun.lock b/bun.lock index 7a8f2c16a36..96458854bc3 100644 --- a/bun.lock +++ b/bun.lock @@ -592,7 +592,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "1.29.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -666,7 +666,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@modelcontextprotocol/server": "2.0.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -1073,8 +1072,8 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", @@ -1676,6 +1675,8 @@ "@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@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=="], @@ -1830,11 +1831,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@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=="], + "@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=="], "@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=="], @@ -3394,7 +3391,7 @@ "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], - "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -3406,6 +3403,8 @@ "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=="], + "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -3710,6 +3709,8 @@ "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "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=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], @@ -4094,6 +4095,8 @@ "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=="], @@ -5002,6 +5005,8 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "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=="], @@ -5950,13 +5955,15 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@modelcontextprotocol/client/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], - "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/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=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -6232,8 +6239,6 @@ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "body-parser/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -6316,8 +6321,6 @@ "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], - "express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -6466,6 +6469,8 @@ "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=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -6794,28 +6799,58 @@ "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "@modelcontextprotocol/sdk/express/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=="], + + "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "@modelcontextprotocol/sdk/express/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=="], + "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], "@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -6828,10 +6863,14 @@ "@octokit/graphql/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@octokit/graphql/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="], "@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], @@ -7340,6 +7379,10 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -7348,14 +7391,20 @@ "@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/bunfig.toml b/bunfig.toml index 649db19f1de..c506ff57c4b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@modelcontextprotocol/client", "@modelcontextprotocol/core", "@modelcontextprotocol/server", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index 30630fdf2fe..e479eb74062 100644 --- a/package.json +++ b/package.json @@ -155,8 +155,8 @@ "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", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch" + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d2155eb4ba2..8f7af65a8f9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -30,7 +30,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@modelcontextprotocol/server": "2.0.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -81,7 +80,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "1.29.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c9f74e2bc03..c2d2ee2f3b7 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -2,10 +2,13 @@ import { cmd } from "./cmd" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { effectCmd } from "../effect-cmd" import { Cause } from "effect" -import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { CLIENT_OPTIONS, MCP } from "../../mcp" +import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" @@ -728,53 +731,107 @@ export const McpDebugCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Testing connection...") - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - let authorizationUrl: URL | undefined - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - callbackPort: oauthConfig?.callbackPort, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async (url) => { - authorizationUrl = url - }, - }, - auth, - ) - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, - requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, - }) - const client = new Client({ name: "opencode-debug", version: InstallationVersion }, CLIENT_OPTIONS) - + // Test basic HTTP connectivity first try { - await client.connect(transport) - spinner.stop("SDK connection successful") - prompts.log.success( - `Connected using MCP ${client.getNegotiatedProtocolVersion() ?? "unknown"} (${client.getProtocolEra() ?? "unknown"})`, - ) - const serverInfo = client.getServerVersion() - if (serverInfo) prompts.log.info(`Server info: ${JSON.stringify(serverInfo)}`) - } catch (error) { - if (error instanceof UnauthorizedError) { - spinner.stop("OAuth required") - prompts.log.info(`OAuth flow triggered: ${error.message}`) - if (authorizationUrl) prompts.log.info(`Authorization URL: ${authorizationUrl}`) - const clientInfo = await authProvider.clientInformation() - if (clientInfo) prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - if (!clientInfo) prompts.log.info("No client ID - dynamic registration will be attempted") - } else { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + ...serverConfig.headers, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, + }, + id: 1, + }), + }) + + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) } - } finally { - await client.close().catch(() => {}) + + if (response.status === 401) { + prompts.log.info("Initial unauthenticated check returned 401, so this server requires OAuth") + + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) + + prompts.log.info("Testing OAuth flow (without completing authorization)...") + + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, + requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, + }) + + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, + }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) + + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) + } else { + prompts.log.info("No client ID - dynamic registration will be attempted") + } + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) + } + } + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) + } + } catch { + // Not JSON, ignore + } + } else { + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } + } + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) } prompts.outro("Debug complete") diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 543b4fb0e33..808aa302962 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -11,7 +11,6 @@ export const Tokens = Schema.Struct({ refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), scope: Schema.mutableKey(Schema.optional(Schema.String)), - issuer: Schema.mutableKey(Schema.optional(Schema.String)), }) export type Tokens = Schema.Schema.Type @@ -20,9 +19,6 @@ export const ClientInfo = Schema.Struct({ clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), - redirectUris: Schema.mutableKey(Schema.optional(Schema.Array(Schema.String))), - issuer: Schema.mutableKey(Schema.optional(Schema.String)), - configPreRegistered: Schema.mutableKey(Schema.optional(Schema.Boolean)), }) export type ClientInfo = Schema.Schema.Type diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 226cd6bb258..3f985eeb94d 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -1,57 +1,77 @@ -import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { + CallToolResultSchema, + ListToolsResultSchema, + ToolSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { Effect } from "effect" const DEFAULT_TIMEOUT = 30_000 +const MAX_LIST_PAGES = 1_000 -export interface McpTool { - readonly def: MCPToolDef - readonly client: Client - readonly timeout?: number -} +const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), +}) -export async function callTool( - tool: McpTool, - args: Record, - signal?: AbortSignal, -): Promise { - const result = await tool.client.callTool( - { name: tool.def.name, arguments: args }, - { - resetTimeoutOnProgress: true, - signal, - timeout: tool.timeout, - // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. - onprogress: () => {}, - }, - ) - if (result.isError) - throw new Error( - result.content - .flatMap((item) => (item.type === "text" ? [item.text] : [])) - .filter((text) => text.trim()) - .join("\n\n") || "MCP tool returned an error", - ) - return result +export async function paginate( + list: (cursor?: string) => Promise, + items: (result: R) => T[], +) { + const result: T[] = [] + const cursors = new Set() + let cursor: string | undefined + + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const page = await list(cursor) + result.push(...items(page)) + if (page.nextCursor === undefined) return result + if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`) + cursors.add(page.nextCursor) + cursor = page.nextCursor + } + + throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) } export function defs(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } -export function convertTool(tool: McpTool): Tool { +export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { const inputSchema: JSONSchema7 = { - ...(tool.def.inputSchema as JSONSchema7), + ...(mcpTool.inputSchema as JSONSchema7), type: "object", - properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"], + properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"], additionalProperties: false, } return dynamicTool({ - description: tool.def.description ?? "", + description: mcpTool.description ?? "", inputSchema: jsonSchema(inputSchema), execute: async (args: unknown, options) => { - const result = await callTool(tool, (args || {}) as Record, options.abortSignal) + const result = await client.callTool( + { + name: mcpTool.name, + arguments: (args || {}) as Record, + }, + CallToolResultSchema, + { + resetTimeoutOnProgress: true, + signal: options.abortSignal, + timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, + }, + ) + if (result.isError) + throw new Error( + result.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) return result return { @@ -98,26 +118,53 @@ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name) -export async function prompts(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.prompts) return [] - return (await client.listPrompts(undefined, { timeout })).prompts +export function prompts(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.prompts) return Promise.resolve([]) + return paginate( + (cursor) => client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.prompts, + ) } -export async function resources(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.resources) return [] - return (await client.listResources(undefined, { timeout })).resources +export function resources(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResources(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resources, + ) } -export async function resourceTemplates(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.resources) return [] - return (await client.listResourceTemplates(undefined, { timeout })).resourceTemplates +export function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resourceTemplates, + ) } function listTools(client: Client, timeout: number) { return Effect.tryPromise({ - try: async () => (await client.listTools(undefined, { timeout })).tools, + try: () => + paginate( + async (cursor) => { + const params = cursor === undefined ? undefined : { cursor } + try { + return await client.listTools(params, { timeout }) + } catch (error) { + if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error + return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) + } + }, + (result) => result.tools, + ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + export * as McpCatalog from "./catalog" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 939c4420472..05f12fa2ee4 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -3,18 +3,18 @@ import { pathToFileURL } from "node:url" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { - Client, - type ClientOptions, - StreamableHTTPClientTransport, - SSEClientTransport, - UnauthorizedError, - RegistrationRejectedError, - SdkHttpError, + ListRootsRequestSchema, type LoggingMessageNotification, + LoggingMessageNotificationSchema, type Tool as MCPToolDef, -} from "@modelcontextprotocol/client" -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio" + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js" import { Config } from "@/config/config" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { NamedError } from "@opencode-ai/core/util/error" @@ -36,7 +36,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event" import { McpBrowser } from "./browser" const DEFAULT_TIMEOUT = 30_000 -export const CLIENT_OPTIONS = { +const CLIENT_OPTIONS = { capabilities: { // https://github.com/anomalyco/opencode/issues/11948 // sampling: {}, @@ -47,8 +47,6 @@ export const CLIENT_OPTIONS = { // https://github.com/anomalyco/opencode/issues/28567 // tasks: {}, }, - versionNegotiation: { mode: "auto" }, - listMaxPages: 1_000, } satisfies ClientOptions export const Resource = Schema.Struct({ @@ -72,19 +70,13 @@ export class NotFoundError extends Schema.TaggedErrorClass()("MCP name: Schema.String, }) {} -type MCPClient = Client & { onToolsChanged?: (error: Error | null) => void } +type MCPClient = Client function createClient(directory: string) { - const client: MCPClient = new Client( - { name: "opencode", version: InstallationVersion }, - { - ...CLIENT_OPTIONS, - listChanged: { - tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) }, - }, - }, + const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) + client.setRequestHandler(ListRootsRequestSchema, () => + Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), ) - client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(directory).href }] })) return client } @@ -162,7 +154,12 @@ export interface ServerInstructions { } /** An MCP tool in its native shape; consumers adapt it to their own tool format. */ -export type McpTool = McpCatalog.McpTool +export interface McpTool { + /** Shared cached definition; consumers must copy rather than mutate it. */ + readonly def: MCPToolDef + readonly client: MCPClient + readonly timeout?: number +} export interface Interface { readonly status: () => Effect.Effect> @@ -193,11 +190,7 @@ export interface Interface { mcpName: string, onAuthorization?: (authorizationUrl: string) => void, ) => Effect.Effect - readonly finishAuth: ( - mcpName: string, - authorizationCode: string, - iss?: string, - ) => Effect.Effect + readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect readonly hasStoredTokens: (mcpName: string) => Effect.Effect @@ -298,18 +291,11 @@ const layer = Layer.effect( Effect.map((client) => ({ client, transportName: name })), Effect.catch((error) => { const lastError = error instanceof Error ? error : new Error(String(error)) - const registrationRejected = - error instanceof RegistrationRejectedError || - lastError.message.includes("registration") || - lastError.message.includes("client_id") const isAuthError = - error instanceof UnauthorizedError || - registrationRejected || - (authProvider && error instanceof SdkHttpError && error.status === 401) || - (authProvider && lastError.message.includes("OAuth")) + error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth")) if (isAuthError) { - if (registrationRejected) { + if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { lastStatus = { status: "needs_client_registration" as const, error: "Server does not support dynamic client registration. Please provide clientId in config.", @@ -468,16 +454,12 @@ const layer = Layer.effect( ) } - client.setNotificationHandler("notifications/message", (notification) => + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => bridge.promise(serverLog(name, notification.params)), ) if (!client.getServerCapabilities()?.tools) return - client.onToolsChanged = async (error) => { - if (error) { - await bridge.promise(Effect.logWarning("failed to refresh MCP tools", { server: name, error: error.message })) - return - } + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (s.clients[name] !== client || s.status[name]?.status !== "connected") return const listed = await bridge.promise(McpCatalog.defs(client, timeout)) @@ -486,7 +468,7 @@ const layer = Layer.effect( s.defs[name] = listed await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) - } + }) } function serverLog(name: string, params: LoggingMessageNotification["params"]) { @@ -922,7 +904,7 @@ const layer = Layer.effect( }), ) - const callback = yield* Effect.promise(() => callbackPromise) + const code = yield* Effect.promise(() => callbackPromise) const storedState = yield* auth.getOAuthState(mcpName) if (storedState !== result.oauthState) { @@ -930,20 +912,16 @@ const layer = Layer.effect( throw new Error("OAuth state mismatch - potential CSRF attack") } yield* auth.clearOAuthState(mcpName) - return yield* finishAuth(mcpName, callback.code, callback.iss) + return yield* finishAuth(mcpName, code) }) - const finishAuth = Effect.fn("MCP.finishAuth")(function* ( - mcpName: string, - authorizationCode: string, - iss?: string, - ) { + const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { yield* requireMcpConfig(mcpName) const pending = pendingOAuthTransports.get(mcpName) if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) const error = yield* Effect.tryPromise({ - try: () => pending.transport.finishAuth(authorizationCode, iss), + try: () => pending.transport.finishAuth(authorizationCode), catch: (error) => error, }).pipe( Effect.match({ diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 71f6ec95399..84007902b8c 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -9,13 +9,8 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1" let currentPort = OAUTH_CALLBACK_PORT let currentPath = OAUTH_CALLBACK_PATH -export interface AuthorizationCallback { - code: string - iss?: string -} - interface PendingAuth { - resolve: (callback: AuthorizationCallback) => void + resolve: (code: string) => void reject: (error: Error) => void timeout: ReturnType } @@ -54,7 +49,6 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). } const code = url.searchParams.get("code") - const iss = url.searchParams.get("iss") ?? undefined const state = url.searchParams.get("state") const error = url.searchParams.get("error") const errorDescription = url.searchParams.get("error_description") @@ -101,7 +95,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). clearTimeout(pending.timeout) pendingAuths.delete(state) cleanupStateIndex(state) - pending.resolve({ code, iss }) + pending.resolve(code) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(OauthCallbackPage.success({ provider: "MCP" })) @@ -136,7 +130,7 @@ export async function ensureRunning(redirectUri?: string): Promise { }) } -export function waitForCallback(oauthState: string, mcpName?: string): Promise { +export function waitForCallback(oauthState: string, mcpName?: string): Promise { if (mcpName) mcpNameToState.set(mcpName, oauthState) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index a3f99a55f5b..596bfe1d551 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -1,9 +1,10 @@ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { - OAuthClientProvider, OAuthClientMetadata, - StoredOAuthTokens, - StoredOAuthClientInformation, -} from "@modelcontextprotocol/client" + OAuthTokens, + OAuthClientInformation, + OAuthClientInformationFull, +} from "@modelcontextprotocol/sdk/shared/auth.js" import { Effect } from "effect" import { McpAuth } from "./auth" @@ -22,14 +23,6 @@ export interface McpOAuthCallbacks { onRedirect: (url: URL) => void | Promise } -function registrationMetadata(info: StoredOAuthClientInformation) { - return { - clientIdIssuedAt: "client_id_issued_at" in info ? info.client_id_issued_at : undefined, - clientSecretExpiresAt: "client_secret_expires_at" in info ? info.client_secret_expires_at : undefined, - redirectUris: "redirect_uris" in info ? info.redirect_uris : undefined, - } -} - export class McpOAuthProvider implements OAuthClientProvider { constructor( protected mcpName: string, @@ -59,21 +52,18 @@ export class McpOAuthProvider implements OAuthClientProvider { } } - async clientInformation(): Promise { - const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) + async clientInformation(): Promise { if (this.config.clientId) { - const issuer = entry?.clientInfo?.clientId === this.config.clientId ? entry.clientInfo.issuer : undefined return { client_id: this.config.clientId, client_secret: this.config.clientSecret, - ...(issuer !== undefined ? { issuer } : {}), } } // Check stored client info (from dynamic registration) // Use getForUrl to validate credentials are for the current server URL + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (entry?.clientInfo) { - if (entry.clientInfo.configPreRegistered) return undefined // Check if client secret has expired if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { return undefined @@ -81,14 +71,6 @@ export class McpOAuthProvider implements OAuthClientProvider { return { client_id: entry.clientInfo.clientId, client_secret: entry.clientInfo.clientSecret, - ...(entry.clientInfo.clientIdIssuedAt !== undefined - ? { client_id_issued_at: entry.clientInfo.clientIdIssuedAt } - : {}), - ...(entry.clientInfo.clientSecretExpiresAt !== undefined - ? { client_secret_expires_at: entry.clientInfo.clientSecretExpiresAt } - : {}), - redirect_uris: entry.clientInfo.redirectUris ? [...entry.clientInfo.redirectUris] : [this.redirectUrl], - ...(entry.clientInfo.issuer !== undefined ? { issuer: entry.clientInfo.issuer } : {}), } } @@ -96,36 +78,22 @@ export class McpOAuthProvider implements OAuthClientProvider { return undefined } - async saveClientInformation(info: StoredOAuthClientInformation): Promise { - if (this.config.clientId && info.client_id === this.config.clientId) { - await Effect.runPromise( - this.auth.updateClientInfo( - this.mcpName, - { clientId: info.client_id, issuer: info.issuer, configPreRegistered: true }, - this.serverUrl, - ), - ) - return - } - - const metadata = registrationMetadata(info) + async saveClientInformation(info: OAuthClientInformationFull): Promise { await Effect.runPromise( this.auth.updateClientInfo( this.mcpName, { clientId: info.client_id, clientSecret: info.client_secret, - clientIdIssuedAt: metadata.clientIdIssuedAt, - clientSecretExpiresAt: metadata.clientSecretExpiresAt, - redirectUris: metadata.redirectUris ? [...metadata.redirectUris] : [this.redirectUrl], - issuer: info.issuer, + clientIdIssuedAt: info.client_id_issued_at, + clientSecretExpiresAt: info.client_secret_expires_at, }, this.serverUrl, ), ) } - async tokens(): Promise { + async tokens(): Promise { // Use getForUrl to validate tokens are for the current server URL const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (!entry?.tokens) return undefined @@ -138,20 +106,18 @@ export class McpOAuthProvider implements OAuthClientProvider { ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000)) : undefined, scope: entry.tokens.scope, - issuer: entry.tokens.issuer, } } - async saveTokens(tokens: StoredOAuthTokens): Promise { + async saveTokens(tokens: OAuthTokens): Promise { await Effect.runPromise( this.auth.updateTokens( this.mcpName, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined, + expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, scope: tokens.scope, - issuer: tokens.issuer, }, this.serverUrl, ), @@ -215,10 +181,10 @@ export class McpOAuthProvider implements OAuthClientProvider { } export class McpOAuthPendingProvider extends McpOAuthProvider { - private pendingClientInfo?: StoredOAuthClientInformation - private pendingTokens?: StoredOAuthTokens + private pendingClientInfo?: OAuthClientInformationFull + private pendingTokens?: OAuthTokens - override async clientInformation(): Promise { + override async clientInformation(): Promise { if (!this.config.clientId) return this.pendingClientInfo return { client_id: this.config.clientId, @@ -226,15 +192,15 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { } } - override async saveClientInformation(info: StoredOAuthClientInformation): Promise { + override async saveClientInformation(info: OAuthClientInformationFull): Promise { this.pendingClientInfo = info } - override async tokens(): Promise { + override async tokens(): Promise { return this.pendingTokens } - override async saveTokens(tokens: StoredOAuthTokens): Promise { + override async saveTokens(tokens: OAuthTokens): Promise { this.pendingTokens = tokens } @@ -245,7 +211,6 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { async commit(): Promise { if (!this.pendingTokens) return - const pendingMetadata = this.pendingClientInfo ? registrationMetadata(this.pendingClientInfo) : undefined await Effect.runPromise( this.auth.set( this.mcpName, @@ -253,22 +218,16 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { tokens: { accessToken: this.pendingTokens.access_token, refreshToken: this.pendingTokens.refresh_token, - expiresAt: - this.pendingTokens.expires_in !== undefined - ? Date.now() / 1000 + this.pendingTokens.expires_in - : undefined, + expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, scope: this.pendingTokens.scope, - issuer: this.pendingTokens.issuer, }, clientInfo: this.pendingClientInfo && !this.config.clientId ? { clientId: this.pendingClientInfo.client_id, clientSecret: this.pendingClientInfo.client_secret, - clientIdIssuedAt: pendingMetadata?.clientIdIssuedAt, - clientSecretExpiresAt: pendingMetadata?.clientSecretExpiresAt, - redirectUris: pendingMetadata?.redirectUris ? [...pendingMetadata.redirectUris] : [this.redirectUrl], - issuer: this.pendingClientInfo.issuer, + clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at, + clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, } : undefined, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index ca56d10b4e4..a6fb064d73e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -20,7 +20,6 @@ export const AuthStartResponse = Schema.Struct({ }) export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, - iss: Schema.optional(Schema.String), }) export const AuthRemoveResponse = Schema.Struct({ success: Schema.Literal(true), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index 3a367a84e79..cdf0cc1e70e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -38,7 +38,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler payload: typeof AuthCallbackPayload.Type }) { return yield* mcp - .finishAuth(ctx.params.name, ctx.payload.code, ctx.payload.iss) + .finishAuth(ctx.params.name, ctx.payload.code) .pipe( Effect.catchTag("MCP.NotFoundError", (error) => Effect.fail( diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index d93fb66f2d0..0f401c7562f 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -388,7 +388,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (flags.experimentalCodeMode) return tools for (const [key, entry] of Object.entries(yield* mcp.tools())) { - const item = McpCatalog.convertTool(entry) + const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout) const execute = item.execute if (!execute) continue diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index a046b4093d8..332d4b43f15 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -1,5 +1,5 @@ import * as Tool from "./tool" -import { type CallToolResult } from "@modelcontextprotocol/client" +import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Schema } from "effect" import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode" import { MCP } from "@/mcp" @@ -145,7 +145,28 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: ) const result: CallToolResult = yield* Effect.gen(function* () { yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort)) + // Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns. + return yield* Effect.promise(async () => { + const raw = await input.entry.tool.client.callTool( + { name: input.entry.tool.def.name, arguments: input.args }, + CallToolResultSchema, + { + resetTimeoutOnProgress: true, + signal: input.ctx.abort, + timeout: input.entry.tool.timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, + }, + ) + if (raw.isError) + throw new Error( + raw.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) + return raw + }) }).pipe( Effect.withSpan("Tool.execute", { attributes: { diff --git a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts index 6260c7e3d4d..b01ed921cfd 100644 --- a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts +++ b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts @@ -1,5 +1,6 @@ -import { Server } from "@modelcontextprotocol/server" -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" if (process.argv.includes("--hang")) { const pidFile = process.env.MCP_LIFECYCLE_PID_FILE @@ -10,7 +11,7 @@ if (process.argv.includes("--hang")) { const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } }) -server.setRequestHandler("tools/list", () => +server.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [ { diff --git a/packages/opencode/test/fixture/mcp-session-recovery.ts b/packages/opencode/test/fixture/mcp-session-recovery.ts index 381c187f004..c20fb5aa587 100644 --- a/packages/opencode/test/fixture/mcp-session-recovery.ts +++ b/packages/opencode/test/fixture/mcp-session-recovery.ts @@ -1,11 +1,10 @@ -import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" const posts: Array<{ method: string; session: string | null }> = [] -const concurrent = process.env.MCP_RECOVERY_CONCURRENT === "1" let initializeCount = 0 let pingCount = 0 -let replacementStarted!: () => void -const replacement = new Promise((resolve) => (replacementStarted = resolve)) const server = Bun.serve({ port: 0, async fetch(request) { @@ -18,7 +17,6 @@ const server = Bun.serve({ if (message.method === "initialize") { initializeCount++ - if (initializeCount === 2) replacementStarted() return Response.json( { jsonrpc: "2.0", @@ -36,8 +34,7 @@ const server = Bun.serve({ if (message.method === "notifications/initialized") return new Response(null, { status: 202 }) pingCount++ - if (concurrent && pingCount === 2) await replacement - if (pingCount <= (concurrent ? 2 : 1)) return new Response("Session not found", { status: 404 }) + if (pingCount === 1) return new Response("Session not found", { status: 404 }) return Response.json({ jsonrpc: "2.0", id: message.id, result: {} }) }, }) @@ -45,8 +42,7 @@ const client = new Client({ name: "test", version: "1" }) try { await client.connect(new StreamableHTTPClientTransport(server.url)) - if (concurrent) await Promise.all([client.ping(), client.ping()]) - else await client.ping() + await client.ping() process.stdout.write(JSON.stringify(posts)) } finally { await client.close() diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 3d77cb22b75..7b0d6403bb1 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" -import { Client, InMemoryTransport } from "@modelcontextprotocol/client" -import { Server } from "@modelcontextprotocol/server" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { McpCatalog } from "@/mcp/catalog" import { Effect } from "effect" @@ -28,10 +30,7 @@ describe("McpCatalog.convertTool", () => { test("preserves content when structuredContent is also present", async () => { const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } - const converted = McpCatalog.convertTool({ - def: mcpTool(), - client: clientReturning({ content, structuredContent }), - }) + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) const output = await converted.execute?.({}, options) @@ -40,10 +39,7 @@ describe("McpCatalog.convertTool", () => { test("falls back to structuredContent only when content is absent", async () => { const structuredContent = { results: [{ title: "one" }] } - const converted = McpCatalog.convertTool({ - def: mcpTool(), - client: clientReturning({ content: [], structuredContent }), - }) + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) const output = await converted.execute?.({}, options) @@ -54,52 +50,18 @@ describe("McpCatalog.convertTool", () => { }) }) -describe("McpCatalog.callTool", () => { - test("forwards the request options", async () => { - const controller = new AbortController() - let request: unknown - let options: unknown - const client = { - callTool: async (input: unknown, config: unknown) => { - request = input - options = config - return { content: [] } - }, - } as unknown as Client - - await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal) - - expect(request).toEqual({ name: "screenshot", arguments: { value: true } }) - expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 }) - expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function") - }) - - test("throws text returned by an MCP tool error", async () => { - const client = clientReturning({ - isError: true, - content: [ - { type: "image", data: "AAAA", mimeType: "image/png" }, - { type: "text", text: "first" }, - { type: "text", text: "second" }, - ], - }) - - await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond") - }) -}) - test("preserves output schema validation across paginated tool discovery", async () => { const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler("tools/list", ({ params }) => + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => Promise.resolve( params?.cursor === "page-2" ? { tools: [ { name: "second", - inputSchema: { type: "object" as const }, + inputSchema: { type: "object" }, outputSchema: { - type: "object" as const, + type: "object", properties: { value: { type: "number" } }, required: ["value"], }, @@ -110,9 +72,9 @@ test("preserves output schema validation across paginated tool discovery", async tools: [ { name: "first", - inputSchema: { type: "object" as const }, + inputSchema: { type: "object" }, outputSchema: { - type: "object" as const, + type: "object", properties: { value: { type: "string" } }, required: ["value"], }, @@ -122,7 +84,7 @@ test("preserves output schema validation across paginated tool discovery", async }, ), ) - server.setRequestHandler("tools/call", ({ params }) => + server.setRequestHandler(CallToolRequestSchema, ({ params }) => Promise.resolve({ content: [], structuredContent: { value: params.name === "first" ? 42 : 1 }, @@ -136,7 +98,9 @@ test("preserves output schema validation across paginated tool discovery", async try { const tools = await Effect.runPromise(McpCatalog.defs(client)) expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) - await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(/output schema/i) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) } finally { await Promise.all([client.close(), server.close()]) } diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 323aea2e478..31cfc20d51c 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" -import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -11,7 +13,7 @@ const serve = Effect.acquireRelease( Effect.promise(async () => { const requests: Headers[] = [] const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -36,11 +38,6 @@ const serve = Effect.acquireRelease( (server) => Effect.promise(server.close), ) -const serveUnauthorized = Effect.acquireRelease( - Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("Unauthorized", { status: 401 }) })), - (server) => Effect.sync(() => server.stop(true)), -) - describe("mcp.headers", () => { it.instance("headers are passed to transports when oauth is enabled (default)", () => Effect.gen(function* () { @@ -102,18 +99,4 @@ describe("mcp.headers", () => { } }), ) - - it.instance("reports 401 as failed when oauth is explicitly disabled", () => - Effect.gen(function* () { - const server = yield* serveUnauthorized - const mcp = yield* MCP.Service - const result = yield* mcp.add("unauthorized-no-oauth", { - type: "remote", - url: server.url.toString(), - oauth: false, - }) - - expect(result.status).toMatchObject({ "unauthorized-no-oauth": { status: "failed" } }) - }), - ) }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index b0018987797..80c8fd22f88 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,12 +1,18 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" import { - Server, - WebStandardStreamableHTTPServerTransport, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, type ServerCapabilities, type Tool, -} from "@modelcontextprotocol/server" +} from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -60,35 +66,35 @@ function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructio }) if (capabilities.tools) { - protocol.setRequestHandler("tools/list", (request) => { + protocol.setRequestHandler(ListToolsRequestSchema, (request) => { if (state.listToolsError) throw new Error(state.listToolsError) const page = state.toolPages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor }) }) } if (capabilities.prompts) { - protocol.setRequestHandler("prompts/list", (request) => { + protocol.setRequestHandler(ListPromptsRequestSchema, (request) => { const page = state.promptPages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler("prompts/get", async () => { + protocol.setRequestHandler(GetPromptRequestSchema, async () => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] } }) } if (capabilities.resources) { - protocol.setRequestHandler("resources/list", (request) => { + protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { const page = state.resourcePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler("resources/templates/list", (request) => { + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resourceTemplates: page?.items ?? state.resourceTemplates, nextCursor: page?.nextCursor, }) }) - protocol.setRequestHandler("resources/read", async (request) => { + protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { contents: [{ uri: request.params.uri, text: "resource result" }] } }) @@ -139,7 +145,7 @@ function hangingLifecycleServer() { return Effect.acquireRelease( Effect.promise(async () => { const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -278,7 +284,7 @@ it.instance("follows cursors when listing tools, prompts, resources, and templat }), ) -it.instance("accepts empty cursors and terminates on repeated cursors", () => +it.instance("accepts empty cursors and rejects repeated cursors", () => Effect.gen(function* () { const empty = yield* lifecycleServer({ capabilities: { prompts: {} } }) empty.state.promptPages = { @@ -295,8 +301,7 @@ it.instance("accepts empty cursors and terminates on repeated cursors", () => const result = yield* mcp.add("looping-cursor", remote(looping.url)) expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"]) - expect(statusName(result.status, "looping-cursor")).toBe("connected") - expect(Object.keys(yield* mcp.tools())).toEqual([]) + expect(statusName(result.status, "looping-cursor")).toBe("failed") }), ) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 735dfaf8bed..5f8889068c3 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,5 +1,7 @@ import { expect } from "bun:test" -import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -38,13 +40,13 @@ function serveOAuthMcp(options: OAuthMcpOptions = {}) { let requiresAuth = true if (capabilities === "tools") { - protocol.setRequestHandler("tools/list", () => { + protocol.setRequestHandler(ListToolsRequestSchema, () => { listToolsCalls++ return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] }) }) } if (capabilities === "resources") { - protocol.setRequestHandler("resources/list", () => + protocol.setRequestHandler(ListResourcesRequestSchema, () => Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }), ) } diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 507b59b691d..9573805a9a1 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,5 +1,7 @@ import { expect } from "bun:test" -import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Option } from "effect" import { Config } from "../../src/config/config" @@ -39,7 +41,7 @@ const serveOAuthMcp = Effect.acquireRelease( Effect.promise(async () => { const requests: Array<{ pathname: string; headers: Headers }> = [] const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index b7db18034fe..1666a37142b 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -74,7 +74,7 @@ describe("McpOAuthCallback.ensureRunning", () => { const response = await fetch(`${redirectUri}?code=code&state=success`) expect(response.status).toBe(200) - expect(await callback).toEqual({ code: "code", iss: undefined }) + expect(await callback).toBe("code") expect(McpOAuthCallback.isRunning()).toBe(false) }) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts index 64c2cb66877..249c49e8f91 100644 --- a/packages/opencode/test/mcp/oauth-provider.test.ts +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe } from "bun:test" +import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js" import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider" import type { McpAuth } from "../../src/mcp/auth" @@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => { expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") }) }) + +describe("MCP OAuth scope selection", () => { + test("adds offline_access when the authorization server and client support refresh tokens", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read", "offline_access"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read offline_access") + }) + + test("does not add unsupported authorization server scopes", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read") + }) +}) diff --git a/packages/opencode/test/mcp/session-recovery.test.ts b/packages/opencode/test/mcp/session-recovery.test.ts index f7c5787a4e9..65865082200 100644 --- a/packages/opencode/test/mcp/session-recovery.test.ts +++ b/packages/opencode/test/mcp/session-recovery.test.ts @@ -24,24 +24,4 @@ describe("mcp session recovery", () => { { method: "ping", session: "replacement" }, ]) }) - - test("retries a concurrent stale response after recovery completes", async () => { - const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], { - cwd: path.join(import.meta.dir, "../.."), - env: { ...process.env, MCP_RECOVERY_CONCURRENT: "1" }, - stdout: "pipe", - stderr: "pipe", - }) - const [code, stdout, stderr] = await Promise.all([ - child.exited, - Bun.readableStreamToText(child.stdout), - Bun.readableStreamToText(child.stderr), - ]) - - expect(code, stderr).toBe(0) - const posts = JSON.parse(stdout) as Array<{ method: string; session: string | null }> - expect(posts.filter((post) => post.method === "initialize").map((post) => post.session)).toEqual([null, null]) - expect(posts.filter((post) => post.method === "ping" && post.session === "expired")).toHaveLength(2) - expect(posts.filter((post) => post.method === "ping" && post.session === "replacement")).toHaveLength(2) - }) }) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 32cb420681a..671acd89622 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -8,14 +8,15 @@ import { Session } from "@/session/session" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" import { MessageID, SessionID } from "@/session/schema" -import { Server } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" import { - InMemoryTransport, + CallToolRequestSchema, LATEST_PROTOCOL_VERSION, - type CallToolResult, - type Client, + ListToolsRequestSchema, type Tool as MCPToolDef, -} from "@modelcontextprotocol/client" +} from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit, Layer } from "effect" const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" @@ -99,7 +100,7 @@ const TOOL_DEFS: MCPToolDef[] = [ }, ] as MCPToolDef[] -function handleCall(name: string, args: Record): CallToolResult { +function handleCall(name: string, args: Record) { switch (name) { case "get_text": return { content: [{ type: "text", text: `hello ${args.name}` }] } @@ -121,8 +122,8 @@ let description: string async function buildTool() { const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler("tools/list", async () => ({ tools: TOOL_DEFS })) - server.setRequestHandler("tools/call", async (req) => + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS })) + server.setRequestHandler(CallToolRequestSchema, async (req) => handleCall(req.params.name, (req.params.arguments ?? {}) as Record), ) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index cc32d2a5f3e..34b3faa610d 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode" -import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c0810a6d7ed..c8c5fac5955 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -20,7 +20,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { MCP } from "@/mcp" -import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index babdbc9c517..9ed0084aac8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2328,7 +2328,6 @@ export class Auth2 extends HeyApiClient { directory?: string workspace?: string code?: string - iss?: string }, options?: Options, ) { @@ -2341,7 +2340,6 @@ export class Auth2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "code" }, - { in: "body", key: "iss" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f0db3236eab..42d224780d3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8563,7 +8563,6 @@ export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartRespo export type McpAuthCallbackData = { body?: { code: string - iss?: string } path: { name: string diff --git a/patches/@modelcontextprotocol%2Fclient@2.0.0.patch b/patches/@modelcontextprotocol%2Fclient@2.0.0.patch deleted file mode 100644 index 833adea8a9a..00000000000 --- a/patches/@modelcontextprotocol%2Fclient@2.0.0.patch +++ /dev/null @@ -1,214 +0,0 @@ -diff --git a/dist/index.cjs b/dist/index.cjs -index 635f1c0..9214f0b 100644 ---- a/dist/index.cjs -+++ b/dist/index.cjs -@@ -3211,6 +3211,7 @@ var Client = class extends require_src.Protocol { - */ - async _connectPlainLegacy(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - if (transport.sessionId !== void 0) { - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); -@@ -3227,6 +3228,7 @@ var Client = class extends require_src.Protocol { - * the handshake; its completion sets the negotiated (legacy) version. - */ - async _legacyHandshake(transport, options) { -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - const legacyVersions = require_src.legacyProtocolVersions(this._supportedProtocolVersions); - try { - const offeredVersion = legacyVersions[0]; -@@ -3265,6 +3267,7 @@ var Client = class extends require_src.Protocol { - await super.connect(transport); - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); -+ if (negotiatedProtocolVersion !== void 0 && !require_src.isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); - return; - } - this._resetConnectionState(); -@@ -5294,10 +5297,32 @@ var StreamableHTTPClientTransport = class { - } - } - async send(message, options) { -- return this._send(message, options, false); -+ return this._send(message, options, false, 0, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) return this._sessionRecovery; -+ if (!this.onsessionexpired) return false; -+ if (this._sessionId !== expiredSessionId) return true; -+ this._sessionId = void 0; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); -+ try { -+ return 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) { -+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { - try { -+ const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message); -+ const isInitialized = Array.isArray(message) ? message.some((m) => require_src.isInitializedNotification(m)) : require_src.isInitializedNotification(message); -+ if (this._sessionRecovery && !isHandshake && !isInitialized) { -+ await this._sessionRecovery; -+ options?.requestSignal?.throwIfAborted(); -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - this._startOrAuthSse({ -@@ -5309,8 +5334,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"); -+ 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); -@@ -5332,8 +5357,14 @@ var StreamableHTTPClientTransport = class { - signal - }; - const response = await (this._fetch ?? fetch)(this._url, init); -- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; -+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; - if (!response.ok) { -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { -+ if (await this._recoverSession(requestSessionId)) { -+ options?.requestSignal?.throwIfAborted(); -+ return this._send(message, options, isAuthRetry, stepUpRetries, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - if (response.headers.has("www-authenticate")) { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); -@@ -5351,7 +5382,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", { -@@ -5371,7 +5402,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 { -diff --git a/dist/index.mjs b/dist/index.mjs -index f02ce3c..0a5a649 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -3208,6 +3208,7 @@ var Client = class extends Protocol { - */ - async _connectPlainLegacy(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - if (transport.sessionId !== void 0) { - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); -@@ -3224,6 +3225,7 @@ var Client = class extends Protocol { - * the handshake; its completion sets the negotiated (legacy) version. - */ - async _legacyHandshake(transport, options) { -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); - try { - const offeredVersion = legacyVersions[0]; -@@ -3262,6 +3264,7 @@ var Client = class extends Protocol { - await super.connect(transport); - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); -+ if (negotiatedProtocolVersion !== void 0 && !isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); - return; - } - this._resetConnectionState(); -@@ -5291,10 +5294,32 @@ var StreamableHTTPClientTransport = class { - } - } - async send(message, options) { -- return this._send(message, options, false); -+ return this._send(message, options, false, 0, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) return this._sessionRecovery; -+ if (!this.onsessionexpired) return false; -+ if (this._sessionId !== expiredSessionId) return true; -+ this._sessionId = void 0; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); -+ try { -+ return 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) { -+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { - try { -+ const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message); -+ const isInitialized = Array.isArray(message) ? message.some((m) => isInitializedNotification(m)) : isInitializedNotification(message); -+ if (this._sessionRecovery && !isHandshake && !isInitialized) { -+ await this._sessionRecovery; -+ options?.requestSignal?.throwIfAborted(); -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - this._startOrAuthSse({ -@@ -5306,8 +5331,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"); -+ 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); -@@ -5329,8 +5354,14 @@ var StreamableHTTPClientTransport = class { - signal - }; - const response = await (this._fetch ?? fetch)(this._url, init); -- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; -+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; - if (!response.ok) { -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { -+ if (await this._recoverSession(requestSessionId)) { -+ options?.requestSignal?.throwIfAborted(); -+ return this._send(message, options, isAuthRetry, stepUpRetries, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - if (response.headers.has("www-authenticate")) { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); -@@ -5348,7 +5379,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", { -@@ -5368,7 +5399,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 { diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch new file mode 100644 index 00000000000..13b8000a013 --- /dev/null +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -0,0 +1,629 @@ +diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts +index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 +--- a/dist/cjs/client/index.d.ts ++++ b/dist/cjs/client/index.d.ts +@@ -428,6 +428,8 @@ export declare class Client>; ++ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; + callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ + [x: string]: unknown; + content: ({ +diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts +index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 +--- a/dist/esm/client/index.d.ts ++++ b/dist/esm/client/index.d.ts +@@ -428,6 +428,8 @@ export declare class Client>; ++ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; + 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..adb5b7bd45514a406a0f7e40b64631c101584c84 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. + */ +@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol { + * 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..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 +--- a/dist/cjs/client/streamableHttp.js ++++ b/dist/cjs/client/streamableHttp.js +@@ -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..4a96d6a0328fa348b96f3869ab7e0bb77538182b 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/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts +index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 +--- a/dist/cjs/client/auth.d.ts ++++ b/dist/cjs/client/auth.d.ts +@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; ++/** ++ * 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..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 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 +diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts +index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 +--- a/dist/esm/client/auth.d.ts ++++ b/dist/esm/client/auth.d.ts +@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; ++/** ++ * 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..d367661e580ee61a96654f7af78b2af61dcad98b 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 +diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js +index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 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. + */ +@@ -537,9 +543,11 @@ export class Client extends Protocol { + * 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..ac75b14545fda44aff7ff4d97cc5da884fcc627a 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, 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 +@@ -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..abd8dfd707c155f71dae7aeeeeaf7547368ac749 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); + });