From dcca6c3bd99330b688e2b449ae59c5d478f32e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:32:59 +0000 Subject: [PATCH 1/9] feat(client): migrate to the official TypeScript SDK v2 Client moves to @modelcontextprotocol/client 2.3.1 and via serve to @modelcontextprotocol/server 2.3.1; the 1.x SDK stays as a dev dependency for the fixture servers. Results are still handled leniently (generic requests with an accept-anything schema), error mapping recognises the v2 error classes, the OAuth login probe no longer hardcodes a protocol version, and a unit test guards via's protocol-version constants against drift from the SDK. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- CHANGELOG.md | 6 ++ bench/eval/tools.ts | 3 +- bench/harness/native.ts | 3 +- docs/adr/0004-typescript.md | 2 +- docs/development/implementation.md | 12 ++- package-lock.json | 156 ++++++++++++++++++++++++++++- package.json | 5 +- scripts/bench-perf.ts | 6 +- scripts/lib/chunk-features.ts | 2 +- src/cli/commands/serve.ts | 2 +- src/cli/serve.ts | 16 ++- src/client/errors.ts | 30 ++++-- src/client/oauth/errors.ts | 11 ++ src/client/oauth/login.ts | 59 +++++++---- src/client/oauth/logout.ts | 2 +- src/client/oauth/provider.ts | 10 +- src/client/oauth/store.ts | 3 +- src/client/session.ts | 81 +++++++-------- src/protocol-versions.ts | 25 +++++ test/bench/bench.test.ts | 3 +- test/cli/dist.test.ts | 6 +- test/cli/serve.test.ts | 6 +- test/client/unit.test.ts | 16 ++- test/daemon/e2e.test.ts | 4 +- test/protocol-versions.test.ts | 73 ++++++++++++++ 25 files changed, 427 insertions(+), 115 deletions(-) create mode 100644 src/protocol-versions.ts create mode 100644 test/protocol-versions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 484b5fb..b4fa5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ ### Changed +- 迁移到官方 TypeScript SDK v2(路线图 e1):client 端换成 `@modelcontextprotocol/client` 2.3.1,`via serve` 换成 `@modelcontextprotocol/server` 2.3.1,1.x 的 `@modelcontextprotocol/sdk` 降为开发依赖(只给测试用的模拟 server)。v2 是唯一支持 `2026-07-28` 无状态协议的官方实现,这一步先不改变协商行为(仍是 `initialize` 握手),为 e2 铺路。输出、退出码、命名与 `next:` 提示不变,没有动词表或单数化规则的改动,规范写法不受影响。 + - 结果仍由 via 自己宽松地处理:不经 SDK 的类型化方法校验,一个畸形的工具条目不会让整次 `tools/list` 失败(`src/client/session.ts` 的 `RAW`)。 + - 行为差异:v2 的 HTTP 传输默认只跟随同源重定向(1.x 会跟随到任何地方并带上自定义请求头);重定向到别处时报错并带目标地址。错误映射改为识别 v2 的 `SdkError`/`SdkHttpError`/`ProtocolError`(退出码不变)。OAuth 错误码从 `errorCode` 改读 `code`。 + - OAuth 登录的探测请求(`src/client/oauth/login.ts`)不再写死 `2025-06-18`:先发无状态协议的 `server/discover`,再发带 SDK 最新版本的 `initialize`,取先得到 401/403 的那个的挑战头。 + - 新增 `test/protocol-versions.test.ts` 与 `src/protocol-versions.ts`:via 自己保存的协议版本列表(配置加载器要校验 `protocolVersion`,不能为此加载 SDK)对照 node_modules 里的 SDK 检查,SDK 升级后增减版本会让测试失败。 + - 代价([implementation.md](docs/development/implementation.md#mcp-sdkv2)):进程内模式的一次调用中位数多约 50 ms,走 daemon 的 CLI 与浏览命令不受影响,daemon 建立会话后常驻内存约多 5–10 MiB(测量噪声 ±7 MiB)。 - `serve` 成为内置子命令的保留字。名字恰好是 `serve` 的 server 需要在配置里用 `alias` 改名,改名前不出现在任何视图里(cli.md「内置子命令」)。动词表与单数化规则没有变化,其余规范写法不受影响。 ## [0.3.1] - 2026-10-21 diff --git a/bench/eval/tools.ts b/bench/eval/tools.ts index 94d535c..2586181 100644 --- a/bench/eval/tools.ts +++ b/bench/eval/tools.ts @@ -4,8 +4,7 @@ // replay servers with the official MCP SDK client. // via / mcp2cli: one `shell` tool that runs only that program. The command line is split // POSIX-style and handed to the program as argv; no shell is involved anywhere. -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import type { Exec } from '../harness/exec'; import { injectedTool, nativeToolName, resultText } from '../harness/native'; import { splitCommand } from '../harness/shell'; diff --git a/bench/harness/native.ts b/bench/harness/native.ts index 00f13c6..89e2f82 100644 --- a/bench/harness/native.ts +++ b/bench/harness/native.ts @@ -1,8 +1,7 @@ // The native-MCP column: what a framework puts in front of the model when servers are mounted // directly. Nothing here is estimated by hand: tool definitions come from the replay servers' // `tools/list` and results from their `tools/call`, through the official SDK client. -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import type { ReplayTool } from '../replay-server.mjs'; import { bytesOf } from './tokens'; import type { Task } from './types'; diff --git a/docs/adr/0004-typescript.md b/docs/adr/0004-typescript.md index cba7648..4a279b6 100644 --- a/docs/adr/0004-typescript.md +++ b/docs/adr/0004-typescript.md @@ -9,7 +9,7 @@ ## 决策 -用 TypeScript 实现,运行在 Node.js 20 及以上,以 npm 包发布,另提供单文件可执行版本。MCP 能力基于官方 `@modelcontextprotocol/sdk`。 +用 TypeScript 实现,运行在 Node.js 20 及以上,以 npm 包发布,另提供单文件可执行版本。MCP 能力基于官方 TypeScript SDK(0.3 前是 1.x 的 `@modelcontextprotocol/sdk`,0.4 起是 v2 的 `@modelcontextprotocol/client` 与 `@modelcontextprotocol/server`,因为只有 v2 支持 `2026-07-28` 无状态协议;选型原则不变,见[实现说明](../development/implementation.md#mcp-sdkv2))。 ## 备选方案 diff --git a/docs/development/implementation.md b/docs/development/implementation.md index 9b0a8ad..7970878 100644 --- a/docs/development/implementation.md +++ b/docs/development/implementation.md @@ -6,7 +6,8 @@ | 用途 | 包 | | --- | --- | -| MCP client(stdio、Streamable HTTP、OAuth) | `@modelcontextprotocol/sdk`(官方 SDK,client 端自带 OAuth 流程支持) | +| MCP client(stdio、Streamable HTTP、OAuth) | `@modelcontextprotocol/client`(官方 TypeScript SDK v2,client 端自带 OAuth 流程支持;是目前唯一支持 `2026-07-28` 无状态协议的官方实现,见下「MCP SDK」) | +| `via serve` 的 MCP server | `@modelcontextprotocol/server`(同一代 SDK 的 server 端) | | 固定子命令解析 | 全局选项可以出现在任何位置(包括工具参数之间),0.1 用 `cli/options.ts` 里的小解析器先取走它们,不引入 `commander`;工具参数由 schema 驱动的自定义解析器处理 | | schema 校验 | `ajv` | | 配置解析 | `yaml`、`smol-toml`、`jsonc-parser`(部分框架配置带注释) | @@ -41,6 +42,15 @@ - **分词、签名、索引**:ASCII 快路径(不用正则)、字符串只 kebab 一次、倒排表用扁平数组并能直接使用预算好的词频。分词在一千个工具上约 4–6 ms 降到约 2.3 ms;一千个工具、新进程里的目录构建加索引加首次查询约 80 ms,命中预构建缓存时约 21 ms。 - **daemon**:不在内存里保存解析后的工具列表(磁盘缓存就是副本),`list` 带 `have` 时不重传([daemon.md](../spec/daemon.md));Node 运行时下拉起时加 `--max-semi-space-size=2` 与(Linux)`MALLOC_ARENA_MAX=2`(`daemonRuntime`),建立会话后的常驻内存少 5 到 10 MiB;SDK 与 ajv 仍是按需加载。 +### MCP SDK(v2) + +via 用官方 TypeScript SDK v2:client 端 `@modelcontextprotocol/client`,`via serve` 的 server 端 `@modelcontextprotocol/server`(均精确锁版本)。1.x 的 `@modelcontextprotocol/sdk` 最高协商到 `2025-11-25`,`2026-07-28` 的无状态协议只有 v2 支持([daemon.md](../spec/daemon.md#协议版本与无状态连接));`@modelcontextprotocol/sdk` 现在只是开发依赖,测试用的模拟 server 仍用它,这样「旧一代 server」是真正的旧 SDK。 + +- **结果不经 SDK 的类型化方法校验。** `listTools()`、`readResource()` 等会按完整的 MCP schema 校验整个结果,一个畸形条目就让整次操作失败。via 一直用通用请求加一个什么都接受的 schema(`src/client/session.ts` 的 `RAW`),只留下自己要用的条目(有 `name` 的工具、有 `uri` 的内容),其余原样交给渲染。迁移后保持这一点;`2026-07-28` 连接上的 `tools/call` 例外,走 SDK 的 `callTool()`:只有它会按 `x-mcp-header` 声明镜像 `Mcp-Param-*` 请求头,缺了头 server 会拒绝调用。它的输出校验被换成什么都接受的校验器,结果仍按 server 给的样子显示。 +- **重定向。** v2 的 HTTP 传输默认只跟随同源重定向(1.x 跟随到任何地方,并把自定义请求头一起带过去)。via 保持 v2 的默认:指向别处的重定向报错,错误信息带目标地址,让用户直接配置那个地址。 +- **协议版本常量。** `src/protocol-versions.ts` 重复了 SDK 的版本列表和 `serverInfo` 的 `_meta` 键,因为配置加载器要校验 `protocolVersion`,不能为此加载 SDK。`test/protocol-versions.test.ts` 对照 node_modules 里的 SDK 检查它们:旧版列表等于 `SUPPORTED_PROTOCOL_VERSIONS`,无状态版本等于 SDK server 在 `server/discover` 里给出的列表,并用真正的 SDK client 钉住每个版本连一次。SDK 升级后增减版本会让这个测试失败。OAuth 登录的探测请求(`src/client/oauth/login.ts`)不再写死版本:用 SDK 的 `LATEST_PROTOCOL_VERSION` 发 `initialize`,用 via 的无状态版本发 `server/discover`,哪个先得到 401 就用哪个的挑战头。 +- **代价**(`npm run bench:perf`,同一台机器上与迁移前的构建交替运行,噪声明显):调用路径多加载约 0.55 MiB 的分块(SDK 与 zod 4 共约 1.0 MiB,1.x 约 0.26 MiB 加其中的 zod),加载时要构造全部 schema;进程内模式(无 daemon)的一次 `gh echo_text` 中位数多约 50 ms(HTTP 654 → 702 ms,stdio 797 → 858 ms);走 daemon 的 CLI、浏览类命令不加载 SDK,不受影响(`test/cli/dist.test.ts`、`test/daemon/e2e.test.ts` 的加载追踪断言);daemon 建立 HTTP 会话与调用后的常驻内存同一台机器上约多 5–10 MiB,单次测量的波动(±7 MiB)与之相当。热 daemon 的调用开销不变(约 5–6 ms)。 + 评估后**没有**保留的做法:把目录和索引常驻在 daemon 里再让 CLI 查询(协议要新增 `ls`/`find`,渲染要在两处实现,而预构建缓存已经把 CLI 一侧压到几毫秒,收益不抵复杂度);在命令之间预取(并行 `import()`,收益在噪声内);用非密码学哈希给缓存键(字面量凭证会进入键的原文);关闭 socket 后立即释放(会破坏进程内测试的「空闲退出」语义)。 仍然偏高的部分(真实的下限):ESM 入口本身比 `node -e 0` 多约 3–5 ms;`--version` 比裸 `node` 多约 8–14 ms(加载 V8 编译缓存与入口分块);daemon 里 MCP SDK 与 zod 常驻约 16 MiB,不可避免;有框架配置(带 70 KiB 的 `.claude.json`)时要读并解析它们。 diff --git a/package-lock.json b/package-lock.json index 73d4681..b6331ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.32.1", + "@modelcontextprotocol/client": "2.3.1", + "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "^2.1.2", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", @@ -24,6 +25,8 @@ }, "devDependencies": { "@anthropic-ai/sdk": "^0.135.0", + "@modelcontextprotocol/node": "^2.1.1", + "@modelcontextprotocol/sdk": "^1.32.1", "@types/node": "^22.20.5", "esbuild": "^0.28.2", "string-width": "^8.3.0", @@ -514,6 +517,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -561,10 +565,71 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.8", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.1.tgz", + "integrity": "sha512-EY92OAXN2xYL7tNS/MVsWJW1v8VHAPwl9B4Ca3pCrjmvqyC+S1C/6UHsMxF2GoFPraS5Yyg56vFz9brVpFojMA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@hono/node-server": "^1.19.9", + "hono": "^4.11.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.3.0" + } + }, + "node_modules/@modelcontextprotocol/node/node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.32.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "dev": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -601,6 +666,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/keyring": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.1.2.tgz", @@ -1920,6 +1998,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -2009,6 +2088,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -2033,6 +2113,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2062,6 +2143,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2081,6 +2163,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2094,6 +2177,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2153,6 +2237,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2166,6 +2251,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2175,6 +2261,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2184,6 +2271,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -2193,6 +2281,7 @@ "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -2224,6 +2313,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2241,6 +2331,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2261,6 +2352,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2275,12 +2367,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2305,6 +2399,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2314,6 +2409,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2330,6 +2426,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2384,6 +2481,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, "license": "MIT" }, "node_modules/estree-walker": { @@ -2400,6 +2498,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2440,6 +2539,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "dev": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -2483,6 +2583,7 @@ "version": "8.7.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -2502,6 +2603,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2562,6 +2664,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -2595,6 +2698,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2604,6 +2708,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2628,6 +2733,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2649,6 +2755,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2673,6 +2780,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2686,6 +2794,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2698,6 +2807,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2710,6 +2820,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2722,6 +2833,7 @@ "version": "4.13.13", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2731,6 +2843,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -2751,6 +2864,7 @@ "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -2767,12 +2881,14 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ip-address": { "version": "10.7.4", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -2782,6 +2898,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -2791,6 +2908,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, "license": "MIT" }, "node_modules/is-safe-filename": { @@ -2854,6 +2972,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/jsonc-parser": { @@ -3179,6 +3298,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3188,6 +3308,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -3201,6 +3322,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -3213,6 +3335,7 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3222,6 +3345,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -3251,6 +3375,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3289,6 +3414,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, "license": "MIT", "dependencies": { "content-type": "^2.1.0" @@ -3305,6 +3431,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -3318,6 +3445,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3327,6 +3455,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3353,6 +3482,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -3365,6 +3495,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3374,6 +3505,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -3392,6 +3524,7 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -3533,6 +3666,7 @@ "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3550,6 +3684,7 @@ "version": "6.16.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -3566,6 +3701,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3579,6 +3715,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3708,6 +3845,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -3724,12 +3862,14 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -3756,6 +3896,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -3775,6 +3916,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, "license": "ISC" }, "node_modules/shebang-command": { @@ -3802,6 +3944,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3821,6 +3964,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3837,6 +3981,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -3855,6 +4000,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -3907,6 +4053,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4049,6 +4196,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -4135,6 +4283,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, "license": "MIT", "dependencies": { "content-type": "^2.0.0", @@ -4153,6 +4302,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4215,6 +4365,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4224,6 +4375,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -4433,6 +4585,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/yaml": { @@ -4463,6 +4616,7 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" diff --git a/package.json b/package.json index 31c6025..471567c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "split-diff": "tsup scripts/split-diff.ts --no-config --format esm --target node20 --platform node --out-dir .build/scripts --silent && node .build/scripts/split-diff.js test/fixtures/tools test/fixtures/other" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.32.1", + "@modelcontextprotocol/client": "2.3.1", + "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "^2.1.2", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", @@ -51,6 +52,8 @@ }, "devDependencies": { "@anthropic-ai/sdk": "^0.135.0", + "@modelcontextprotocol/node": "^2.1.1", + "@modelcontextprotocol/sdk": "^1.32.1", "@types/node": "^22.20.5", "esbuild": "^0.28.2", "string-width": "^8.3.0", diff --git a/scripts/bench-perf.ts b/scripts/bench-perf.ts index deb76ae..c5b621a 100644 --- a/scripts/bench-perf.ts +++ b/scripts/bench-perf.ts @@ -20,8 +20,7 @@ import { arch, cpus, release, tmpdir, totalmem, type as osType } from 'node:os'; import { basename, join } from 'node:path'; import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { buildCatalog, normalizeProviderName, renderFind, restoreProvider, SearchIndex, snapshotProvider, type CatalogTool, type DocTerms, type RestoredTerms } from '../src/catalog'; import { CatalogCache } from '../src/cli/catalog-cache'; @@ -609,8 +608,7 @@ async function benchCall(): Promise { mkdirSync(join(ROOT, '.build', 'bench-perf'), { recursive: true }); writeFileSync( sdkScript, - `import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + `import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const client = new Client({ name: 'bench', version: '0.0.0' }); await client.connect(new StreamableHTTPClientTransport(new URL(process.argv[2]))); await client.callTool({ name: 'echo_text', arguments: { message: 'hi' } }); diff --git a/scripts/lib/chunk-features.ts b/scripts/lib/chunk-features.ts index e3eeebf..072bd71 100644 --- a/scripts/lib/chunk-features.ts +++ b/scripts/lib/chunk-features.ts @@ -2,7 +2,7 @@ // loaded is read off the bundle chunks it imported. Each marker is a name that exists only in that // library's code. export const FEATURES: ReadonlyArray = [ - ['mcp-sdk', /InitializeRequestSchema/], + ['mcp-sdk', /mcp\.SdkError/], ['zod', /ZodError/], ['ajv', /KeywordCxt/], ['yaml', /YAMLParseError/], diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 7ffbac8..6751b39 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -8,7 +8,7 @@ export async function serveCommand(app: App, argv: readonly string[]): Promise 0) throw usageFailure(COMMAND_USAGE.serve!, 'via serve -h'); const [{ createServeServer }, { StdioServerTransport }, { run }] = await Promise.all([ import('../serve'), - import('@modelcontextprotocol/sdk/server/stdio.js'), + import('@modelcontextprotocol/server/stdio'), import('../run'), ]); const server = await createServeServer(app.io, run); diff --git a/src/cli/serve.ts b/src/cli/serve.ts index c198d08..5e45906 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -2,8 +2,7 @@ // It offers a fixed set of meta-tools, and each call runs one CLI command in this process with its // output captured, so naming, argument parsing, `deny`, confirmation, redaction, rendering and // cursors are exactly those of the CLI. Nothing here talks to an upstream server directly. -import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult, JSONObject, Server, Tool } from '@modelcontextprotocol/server'; import { matchBuiltin } from './builtins'; import type { CliEnv } from './env'; import { VERSION } from './help'; @@ -16,8 +15,8 @@ const INSTRUCTIONS = `via connects to the user's MCP servers and lets you discov Browse with via_browse (no path: all providers; then "", " ", " :" for usage), search with via_find, call with via_call. Outputs and hints use CLI syntax: "via ..." is via_browse, "via call ..." is via_call, "via more " is via_more, "via resources|read|prompts ..." are via_resources, via_read and via_prompts.`; -const str = (description: string): object => ({ type: 'string', description }); -const object = (properties: Record, required: string[] = []): Tool['inputSchema'] => ({ +const str = (description: string): Record => ({ type: 'string', description }); +const object = (properties: Record, required: string[] = []): Tool['inputSchema'] => ({ type: 'object', properties, ...(required.length > 0 ? { required } : {}), @@ -162,13 +161,10 @@ export function commandLine(tool: string, input: Input): string[] { */ export async function createServeServer(base: CliEnv, runner: Runner): Promise { // The SDK is loaded on demand like everywhere else in src/ (implementation.md, "启动速度"). - const [{ Server }, { CallToolRequestSchema, ListToolsRequestSchema }] = await Promise.all([ - import('@modelcontextprotocol/sdk/server/index.js'), - import('@modelcontextprotocol/sdk/types.js'), - ]); + const { Server } = await import('@modelcontextprotocol/server'); const server = new Server({ name: 'via', version: VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS }); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: SERVE_TOOLS })); - server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { + server.setRequestHandler('tools/list', async () => ({ tools: SERVE_TOOLS })); + server.setRequestHandler('tools/call', async (request): Promise => { let argv: string[]; try { argv = commandLine(request.params.name, (request.params.arguments ?? {}) as Input); diff --git a/src/client/errors.ts b/src/client/errors.ts index cc8db1e..d78065d 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -249,33 +249,44 @@ function isUnauthorized(err: unknown): boolean { return err instanceof Error && err.constructor.name === 'UnauthorizedError'; } -/** SDK `StreamableHTTPError`: message starts with "Streamable HTTP error:" and `code` is the HTTP status (or -1). */ +/** SDK `SdkHttpError`: the transport got an HTTP status it cannot use (matched by name, like the rest, to keep the SDK lazy). */ function httpStatus(err: unknown): number | undefined { - if (!(err instanceof Error)) return undefined; - const code = (err as { code?: unknown }).code; - if (typeof code === 'number' && code >= 100 && err.message.startsWith('Streamable HTTP error')) return code; - return undefined; + if (!(err instanceof Error) || err.name !== 'SdkHttpError') return undefined; + const status = (err as { status?: unknown }).status; + return typeof status === 'number' && status >= 100 ? status : undefined; } -const REQUEST_TIMEOUT = -32001; +const REQUEST_TIMEOUT = 'REQUEST_TIMEOUT'; +const CONNECTION_LOST = new Set(['CONNECTION_CLOSED', 'NOT_CONNECTED', 'SEND_FAILED']); const CONNECTION_CLOSED = -32000; const INVALID_PARAMS = -32602; const RESOURCE_NOT_FOUND = -32002; +/** The JSON-RPC code of an error the server answered with (SDK `ProtocolError`); undefined for anything else. */ function mcpCode(err: unknown): number | undefined { - if (err instanceof Error && err.name === 'McpError') { + if (err instanceof Error && err.name === 'ProtocolError') { const code = (err as { code?: unknown }).code; if (typeof code === 'number') return code; } return undefined; } +/** SDK `SdkError`: a failure of the client itself (timeout, closed connection, …), with a string `code`. */ +function sdkCode(err: unknown): string | undefined { + if (err instanceof Error && (err.name === 'SdkError' || err.name === 'SdkHttpError')) { + const code = (err as { code?: unknown }).code; + if (typeof code === 'string') return code; + } + return undefined; +} + function isTimeout(err: unknown): boolean { - return mcpCode(err) === REQUEST_TIMEOUT; + return sdkCode(err) === REQUEST_TIMEOUT; } function isConnectionClosed(err: unknown): boolean { - return mcpCode(err) === CONNECTION_CLOSED; + const code = sdkCode(err); + return (code !== undefined && CONNECTION_LOST.has(code)) || mcpCode(err) === CONNECTION_CLOSED; } function errnoCode(err: unknown): string | undefined { @@ -304,7 +315,6 @@ function errorMessage(err: unknown): string { function stripSdkPrefix(message: string): string { return message - .replace(/^Streamable HTTP error:\s*/, '') .replace(/^Error POSTing to endpoint:\s*/, '') .replace(/^Failed to open SSE stream:\s*/, ''); } diff --git a/src/client/oauth/errors.ts b/src/client/oauth/errors.ts index f68045d..5c58cba 100644 --- a/src/client/oauth/errors.ts +++ b/src/client/oauth/errors.ts @@ -34,3 +34,14 @@ export class LoginFailedError extends Error { this.name = 'LoginFailedError'; } } + +/** + * The OAuth error code (`invalid_grant`, `server_error`, …) of an error the SDK raised for an + * authorization server's error response; undefined for any other failure. Matched by name so this + * file does not import the SDK. + */ +export function oauthErrorCode(error: unknown): string | undefined { + if (!(error instanceof Error) || error.name !== 'OAuthError') return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} diff --git a/src/client/oauth/login.ts b/src/client/oauth/login.ts index 8dfb36f..e1d9ce6 100644 --- a/src/client/oauth/login.ts +++ b/src/client/oauth/login.ts @@ -4,12 +4,13 @@ // is the same for the terminal, for tests and for anything else that wants to log in. import { randomBytes } from 'node:crypto'; import pkg from '../../../package.json'; -import { LoginFailedError } from './errors'; +import { MODERN_PROTOCOL_VERSIONS } from '../../protocol-versions'; +import { LoginFailedError, oauthErrorCode } from './errors'; import type { OpenResult } from './opener'; import { ViaOAuthProvider } from './provider'; import { CALLBACK_PATH, parseRedirect, randomPort, startLoopback, type AuthorizationResponse, type Loopback } from './redirect'; import type { OAuthRecord, OAuthStore, SetResult } from './store'; -import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { FetchLike } from '@modelcontextprotocol/client'; export interface LoginIO { /** The authorization URL, shown to the person as soon as it is known. */ @@ -53,26 +54,46 @@ const MANUAL_ATTEMPTS = 3; /** What an unauthenticated request to the server says about how to authorize (RFC 9728 / 6750). */ async function probeChallenge(url: string, fetchFn: FetchLike | undefined): Promise<{ resourceMetadataUrl?: URL; scope?: string }> { try { - const { extractWWWAuthenticateParams } = await import('@modelcontextprotocol/sdk/client/auth.js'); - const response = await (fetchFn ?? ((u, init) => fetch(u, init)))(url, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 0, - method: 'initialize', - params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'via', version: pkg.version } }, - }), - signal: AbortSignal.timeout(10_000), - }); - await response.body?.cancel().catch(() => undefined); - return response.status === 401 || response.status === 403 ? extractWWWAuthenticateParams(response) : {}; + const sdk = await import('@modelcontextprotocol/client'); + const send = fetchFn ?? ((u, init) => fetch(u, init)); + const clientInfo = { name: 'via', version: pkg.version }; + // One request per protocol generation, newest first: a server answers either with the challenge + // (an unauthenticated request is refused before it is understood) or with "not for me", and the + // second try covers a server that only understands the other generation. The versions are the + // SDK's and via's own list (protocol-versions.ts), never a literal here. + const modern = MODERN_PROTOCOL_VERSIONS[0]!; + const probes: Array<{ headers: Record; body: unknown }> = [ + { + headers: { 'mcp-protocol-version': modern, 'mcp-method': 'server/discover' }, + body: { + jsonrpc: '2.0', + id: 0, + method: 'server/discover', + params: { _meta: { [sdk.PROTOCOL_VERSION_META_KEY]: modern, [sdk.CLIENT_INFO_META_KEY]: clientInfo, [sdk.CLIENT_CAPABILITIES_META_KEY]: {} } }, + }, + }, + { + headers: {}, + body: { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: sdk.LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo } }, + }, + ]; + for (const probe of probes) { + const response = await send(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...probe.headers }, + body: JSON.stringify(probe.body), + signal: AbortSignal.timeout(10_000), + }); + await response.body?.cancel().catch(() => undefined); + if (response.status === 401 || response.status === 403) return sdk.extractWWWAuthenticateParams(response); + } + return {}; } catch { return {}; // discovery by well-known paths still works without it } } -function describeOAuthError(error: Error & { errorCode?: string }): string { +function describeOAuthError(error: Error): string { const text = error.message.split('\n')[0] ?? ''; return text.length > 200 ? `${text.slice(0, 200)}…` : text; } @@ -88,7 +109,7 @@ function loginFailure(error: unknown, target: LoginTarget): LoginFailedError { `register a client there and set servers.${target.provider}.auth to { oauth: { clientId: "" } }`, ); } - const code = (error as { errorCode?: unknown } | null)?.errorCode; + const code = oauthErrorCode(error); if (typeof code === 'string') return new LoginFailedError(`${target.provider}: the authorization server refused the request (${code}): ${describeOAuthError(error as Error)}`, 4); const host = (() => { try { @@ -132,7 +153,7 @@ async function readManual(io: LoginIO, provider: string): Promise { const { target, io } = options; - const sdk = await import('@modelcontextprotocol/sdk/client/auth.js'); + const sdk = await import('@modelcontextprotocol/client'); const state = randomBytes(24).toString('base64url'); let loopback: Loopback | undefined; try { diff --git a/src/client/oauth/logout.ts b/src/client/oauth/logout.ts index 5b6adb6..49d15e7 100644 --- a/src/client/oauth/logout.ts +++ b/src/client/oauth/logout.ts @@ -3,7 +3,7 @@ // happened. A logout never fails because the server could not be reached. import { oauthKey, type OAuthRecord, type OAuthStore } from './store'; import type { LoginTarget } from './login'; -import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { FetchLike } from '@modelcontextprotocol/client'; export type RevokeOutcome = /** The server accepted the revocation. */ diff --git a/src/client/oauth/provider.ts b/src/client/oauth/provider.ts index 8bcfb22..23a1dd6 100644 --- a/src/client/oauth/provider.ts +++ b/src/client/oauth/provider.ts @@ -10,11 +10,9 @@ // a browser, and it works the same in-process and inside a daemon session. // // The SDK is loaded on demand; importing this module costs nothing at CLI start. -import type { OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js'; -import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; -import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { FetchLike, OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthDiscoveryState, OAuthTokens } from '@modelcontextprotocol/client'; import { MIN_REDACTED_LITERAL_LENGTH } from '../../config/credentials'; -import { LoginRequiredError, TokenRefreshError } from './errors'; +import { LoginRequiredError, oauthErrorCode, TokenRefreshError } from './errors'; import { oauthKey, secretsOf, type OAuthRecord, type OAuthStore, type SetResult } from './store'; /** Refresh when the access token has less than this left. */ @@ -190,7 +188,7 @@ export class ViaOAuthProvider implements OAuthClientProvider { await this.dropTokens(record); throw new LoginRequiredError('expired'); } - const sdk = await import('@modelcontextprotocol/sdk/client/auth.js'); + const sdk = await import('@modelcontextprotocol/client'); try { let discovery = record.discovery; if (!discovery?.authorizationServerUrl) { @@ -207,7 +205,7 @@ export class ViaOAuthProvider implements OAuthClientProvider { }); return await this.commit(record, { ...fresh, issuer: record.tokens?.issuer ?? discovery.authorizationServerUrl }, discovery); } catch (error) { - const code = (error as { errorCode?: unknown } | null)?.errorCode; + const code = oauthErrorCode(error); if (typeof code === 'string' && !TRANSIENT_CODES.has(code)) { // The server no longer accepts this refresh token. Another process may have rotated it // while we were reading: use what it stored, once. diff --git a/src/client/oauth/store.ts b/src/client/oauth/store.ts index f9aa84e..8fbd57c 100644 --- a/src/client/oauth/store.ts +++ b/src/client/oauth/store.ts @@ -5,8 +5,7 @@ import { createHash } from 'node:crypto'; import { access, chmod, constants, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import type { OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js'; -import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { OAuthClientInformationMixed, OAuthDiscoveryState, OAuthTokens } from '@modelcontextprotocol/client'; import { pathContext, userDataDir, type Env, type PathContext } from '../../config/paths'; import { importKeyring } from '../../keyring'; diff --git a/src/client/session.ts b/src/client/session.ts index d5b8b64..5d07ab7 100644 --- a/src/client/session.ts +++ b/src/client/session.ts @@ -1,7 +1,6 @@ // One MCP session per server over stdio or Streamable HTTP. The SDK is imported lazily, on the first // connection, so nothing here costs CLI cold start (implementation.md, "启动速度"). -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { FetchLike, Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Client, ClientOptions, FetchLike, StandardSchemaV1, Transport } from '@modelcontextprotocol/client'; import pkg from '../../package.json'; import type { CredentialResolver, ServerConfig } from '../config/types'; import type { GetPromptResult, McpPrompt, ReadResourceResult, ResourceListing, ServerFeatures, ToolCallResult } from '../protocol'; @@ -76,27 +75,32 @@ interface Handle { } type Sdk = { - Client: typeof import('@modelcontextprotocol/sdk/client/index.js').Client; - ResultSchema: typeof import('@modelcontextprotocol/sdk/types.js').ResultSchema; - ToolListChangedNotificationSchema: typeof import('@modelcontextprotocol/sdk/types.js').ToolListChangedNotificationSchema; - ListRootsRequestSchema: typeof import('@modelcontextprotocol/sdk/types.js').ListRootsRequestSchema; + Client: typeof import('@modelcontextprotocol/client').Client; }; let sdkPromise: Promise | undefined; function loadSdk(): Promise { - sdkPromise ??= Promise.all([ - import('@modelcontextprotocol/sdk/client/index.js'), - import('@modelcontextprotocol/sdk/types.js'), - ]).then(([c, t]) => ({ - Client: c.Client, - ResultSchema: t.ResultSchema, - ToolListChangedNotificationSchema: t.ToolListChangedNotificationSchema, - ListRootsRequestSchema: t.ListRootsRequestSchema, - })); + sdkPromise ??= import('@modelcontextprotocol/client').then(({ Client }) => ({ Client })); return sdkPromise; } +/** + * Result "schema" that accepts anything. via sends requests through it instead of the SDK's typed + * methods (`listTools()`, `readResource()`, …): those check every result against the full MCP schema + * and fail the whole operation on one malformed entry, while via keeps what it can use (a list entry + * with a `name`, a content with a `uri`) and renders the rest as the server sent it. + */ +const RAW: StandardSchemaV1 = { + '~standard': { version: 1, vendor: 'via', validate: (value: unknown) => ({ value }) }, +}; + +/** + * The SDK's `callTool()` checks `structuredContent` against the tool's `outputSchema`. via shows a + * result as the server sent it, as it did before the SDK upgrade, so this validator accepts everything. + */ +const LENIENT_VALIDATOR = { getValidator: () => (input: unknown) => ({ valid: true, data: input, errorMessage: undefined }) } as unknown as NonNullable; + /** * A lazily connected session to one server. Operations are limited to `ServerConfig.concurrency` * at a time; the rest queue. A broken session is dropped and reconnected on the next operation. @@ -162,8 +166,7 @@ export class McpSession { readResource(uri: string, options: ListOptions = {}): Promise { return this.operate(options.timeoutMs ?? this.defaultTimeoutMs(), undefined, async (handle, left) => { this.require(handle, 'resources'); - const { ResultSchema } = await loadSdk(); - const raw = await handle.client.request({ method: 'resources/read', params: { uri } }, ResultSchema, { timeout: left() }).catch((err: unknown) => { + const raw = await handle.client.request({ method: 'resources/read', params: { uri } }, RAW, { timeout: left() }).catch((err: unknown) => { throw resourceNotFound(err, uri, this.server.provider, this.redactor()) ?? err; }); const contents = (raw as { contents?: unknown }).contents; @@ -184,8 +187,7 @@ export class McpSession { getPrompt(name: string, args: Record, options: ListOptions = {}): Promise { return this.operate(options.timeoutMs ?? this.defaultTimeoutMs(), undefined, async (handle, left) => { this.require(handle, 'prompts'); - const { ResultSchema } = await loadSdk(); - const raw = await handle.client.request({ method: 'prompts/get', params: { name, arguments: args } }, ResultSchema, { timeout: left() }); + const raw = await handle.client.request({ method: 'prompts/get', params: { name, arguments: args } }, RAW, { timeout: left() }); const messages = (raw as { messages?: unknown }).messages; if (!Array.isArray(messages)) throw new Error('prompts/get result has no "messages" array'); const description = (raw as { description?: unknown }).description; @@ -206,15 +208,9 @@ export class McpSession { const attempt = async (): Promise => { const handle = await this.ensureConnected(left(), timeoutMs); return this.request(handle, 'request', timeoutMs, async () => { - const { ResultSchema } = await loadSdk(); - const raw = await handle.client.request( - { method: 'tools/call', params: { name: rawName, arguments: args ?? {} } }, - ResultSchema, - { - timeout: left(), - ...(options.onProgress ? { onprogress: options.onProgress } : {}), - }, - ); + const params = { name: rawName, arguments: args ?? {} }; + const send = { timeout: left(), ...(options.onProgress ? { onprogress: options.onProgress } : {}) }; + const raw: unknown = await handle.client.request({ method: 'tools/call', params }, RAW, send); if (typeof raw !== 'object' || raw === null) throw new Error('tools/call returned a non-object result'); return raw as ToolCallResult; }); @@ -269,14 +265,14 @@ export class McpSession { } /** Follow `nextCursor` of a list method and collect `key` from every page; `left` is the time remaining. */ - private async pages(handle: Handle, method: string, key: string, left: () => number, keep: (entry: unknown) => entry is T): Promise { - const { ResultSchema } = await loadSdk(); + private async pages(handle: Handle, method: string, key: string, left: () => number, keep: (entry: unknown) => entry is T, first?: (page: Record) => void): Promise { const items: T[] = []; const seen = new Set(); let cursor: string | undefined; for (let page = 0; page < MAX_LIST_PAGES; page += 1) { - const raw = await handle.client.request({ method, params: cursor === undefined ? {} : { cursor } }, ResultSchema, { timeout: left() }); - const entries = (raw as Record)[key]; + const raw = (await handle.client.request({ method, params: cursor === undefined ? {} : { cursor } }, RAW, { timeout: left() })) as Record; + if (page === 0) first?.(raw); + const entries = raw[key]; if (!Array.isArray(entries)) throw new Error(`${method} result has no "${key}" array`); for (const entry of entries) if (keep(entry)) items.push(entry); const next = (raw as { nextCursor?: unknown }).nextCursor; @@ -375,21 +371,28 @@ export class McpSession { let client: Client | undefined; let handle: Handle | undefined; try { - const { Client: ClientCtor, ToolListChangedNotificationSchema, ListRootsRequestSchema } = await loadSdk(); + const { Client: ClientCtor } = await loadSdk(); // The `roots` capability is declared only when the server has roots configured (ADR 0010), and // never as `listChanged`: the roots of a session do not change during its life. const roots = resolveRoots(server.roots, this.options.roots ?? { cwd: process.cwd(), home: process.env.HOME ?? '' }); - client = new ClientCtor({ name: 'via', version: pkg.version }, { capabilities: roots.length > 0 ? { roots: { listChanged: false } } : {} }); - attempt.client = client; - if (roots.length > 0) client.setRequestHandler(ListRootsRequestSchema, () => ({ roots })); const onToolsChanged = this.options.onToolsChanged; - if (onToolsChanged) client.setNotificationHandler(ToolListChangedNotificationSchema, () => onToolsChanged()); + client = new ClientCtor( + { name: 'via', version: pkg.version }, + { + capabilities: roots.length > 0 ? { roots: { listChanged: false } } : {}, + jsonSchemaValidator: LENIENT_VALIDATOR, + // Only a signal: via lists again itself (the daemon's catalog refresh), not the SDK. + ...(onToolsChanged ? { listChanged: { tools: { autoRefresh: false, debounceMs: 0, onChanged: () => onToolsChanged() } } } : {}), + }, + ); + attempt.client = client; + if (roots.length > 0) client.setRequestHandler('roots/list', () => ({ roots })); let transport: Transport; let terminate: (() => Promise) | undefined; let pid: (() => number | null) | undefined; if (resolved.type === 'stdio') { - const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js'); + const { StdioClientTransport } = await import('@modelcontextprotocol/client/stdio'); // Windows: PATH/PATHEXT lookup that never looks in the working directory, `.cmd` shims checked // for arguments `cmd.exe` cannot carry (platform/command.ts). A no-op elsewhere. const prepared = prepareStdioCommand(resolved.command, resolved.args, { env: resolved.env, cwd: resolved.cwd ?? process.cwd(), platform: process.platform }); @@ -414,7 +417,7 @@ export class McpSession { } catch { throw internal(`invalid url for ${server.provider}: not a URL`); } - const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); const http = new StreamableHTTPClientTransport(url, { requestInit: { headers: resolved.headers }, ...(authFetch ? { fetch: authFetch } : {}) }); terminate = () => http.terminateSession(); transport = http; diff --git a/src/protocol-versions.ts b/src/protocol-versions.ts new file mode 100644 index 0000000..521b37f --- /dev/null +++ b/src/protocol-versions.ts @@ -0,0 +1,25 @@ +// The MCP protocol versions via knows about (docs/spec/config.md `protocolVersion`, cli.md `via servers`). +// Dependency-free on purpose: the config loader validates `protocolVersion` against these lists on every +// command, so this module must not pull in the MCP SDK (implementation.md, "启动速度"). `test/protocol-versions.test.ts` +// checks the lists against what the SDK in node_modules actually negotiates, so an SDK upgrade that adds or +// drops a revision fails a test instead of drifting silently. + +/** Revisions of the stateless era (2026-07-28 and later), newest first: no `initialize` handshake, no server-side session. */ +export const MODERN_PROTOCOL_VERSIONS: readonly string[] = ['2026-07-28']; + +/** Revisions negotiated with the `initialize` handshake, newest first; the SDK's `SUPPORTED_PROTOCOL_VERSIONS`. */ +export const LEGACY_PROTOCOL_VERSIONS: readonly string[] = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; + +/** Every revision `protocolVersion` may name, newest first. */ +export const PROTOCOL_VERSIONS: readonly string[] = [...MODERN_PROTOCOL_VERSIONS, ...LEGACY_PROTOCOL_VERSIONS]; + +/** `_meta` key under which a stateless-era server stamps its identity on responses; the SDK's `SERVER_INFO_META_KEY`. */ +export const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo'; + +export function isProtocolVersion(value: string): boolean { + return PROTOCOL_VERSIONS.includes(value); +} + +export function isModernProtocolVersion(value: string): boolean { + return MODERN_PROTOCOL_VERSIONS.includes(value); +} diff --git a/test/bench/bench.test.ts b/test/bench/bench.test.ts index 682a1b8..1c4e818 100644 --- a/test/bench/bench.test.ts +++ b/test/bench/bench.test.ts @@ -1,7 +1,6 @@ import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { run } from '../../src/cli/run'; import { cannedCalls, matchArgs, readTools, startReplayHttp } from '../../bench/replay-server.mjs'; import type { Exec } from '../../bench/harness/exec'; diff --git a/test/cli/dist.test.ts b/test/cli/dist.test.ts index 69c3df7..f503d82 100644 --- a/test/cli/dist.test.ts +++ b/test/cli/dist.test.ts @@ -4,9 +4,9 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import type { CallToolResult } from '@modelcontextprotocol/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { featuresOf, FEATURES } from '../../scripts/lib/chunk-features'; import { CLI_STDIO_SERVER, FAKE_TOKEN, ghConfig, homeEnv, startFixtureServer } from './helpers'; diff --git a/test/cli/serve.test.ts b/test/cli/serve.test.ts index 12b5a18..f4b7d0e 100644 --- a/test/cli/serve.test.ts +++ b/test/cli/serve.test.ts @@ -1,9 +1,9 @@ // `via serve` (cli.md, "via serve"; ADR 0016), in-process: an MCP client talks to the serve server // over an in-memory transport, and every meta-tool must give exactly what the equivalent CLI command // prints. The stdio transport itself is covered by the built-CLI test in dist.test.ts. -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/client'; +import type { CallToolResult } from '@modelcontextprotocol/client'; import { describe, expect, it } from 'vitest'; import { makeEnv } from '../../src/cli/env'; import { run } from '../../src/cli/run'; diff --git a/test/client/unit.test.ts b/test/client/unit.test.ts index 2031d08..79673da 100644 --- a/test/client/unit.test.ts +++ b/test/client/unit.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { ProtocolError, SdkError, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/client'; import { build } from 'esbuild'; import { describe, expect, it } from 'vitest'; import { baseEnvironment, ClientError, Limiter, mapError, resolveConnection } from '../../src/client'; @@ -124,18 +125,25 @@ describe('mapError', () => { expect(mapError(new CredentialError('missing', 'env:X'), ctx)).toMatchObject({ exitCode: 4, next: 'via auth login acme' }); }); - it('maps an SDK-shaped HTTP error and redacts and flattens its text', () => { - const err = Object.assign(new Error(`Streamable HTTP error: Error POSTing to endpoint: oops\n\u001b[31m${FAKE_TOKEN}\u001b[0m`), { code: 502 }); + it('maps an SDK HTTP error and redacts and flattens its text', () => { + const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: oops\n\u001b[31m${FAKE_TOKEN}\u001b[0m`, { status: 502 }); const mapped = mapError(err, ctx); expect(mapped.exitCode).toBe(6); expect(mapped.message).toBe('acme returned HTTP 502: oops ***'); }); it('caps long upstream text', () => { - const err = Object.assign(new Error(`Streamable HTTP error: ${'x'.repeat(5000)}`), { code: 500 }); + const err = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${'x'.repeat(5000)}`, { status: 500 }); expect(mapError(err, ctx).message.length).toBeLessThan(400); }); + it('maps the SDK errors of a request that ran out of time, lost its connection or was refused', () => { + expect(mapError(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out'), ctx)).toMatchObject({ exitCode: 6, message: 'acme did not answer within 60s' }); + expect(mapError(new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'), ctx)).toMatchObject({ exitCode: 6, message: 'lost the connection to acme' }); + expect(mapError(new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { status: 401 }), ctx)).toMatchObject({ exitCode: 4, next: 'via auth login acme' }); + expect(mapError(new ProtocolError(-32602, 'MCP error -32602: n must be even'), ctx)).toMatchObject({ exitCode: 1, message: 'n must be even' }); + }); + it('maps an unknown failure while connected to exit 6', () => { expect(mapError(new Error('weird'), ctx)).toMatchObject({ exitCode: 6, message: 'acme: weird' }); }); @@ -193,7 +201,7 @@ describe('lazy loading of the MCP SDK', () => { const outputs = Object.entries(result.metafile.outputs); const entryOutput = outputs.find(([, o]) => o.entryPoint !== undefined); expect(entryOutput).toBeDefined(); - const sdkOf = (o: { inputs: Record }): boolean => Object.keys(o.inputs).some((i) => i.includes('@modelcontextprotocol/sdk')); + const sdkOf = (o: { inputs: Record }): boolean => Object.keys(o.inputs).some((i) => i.includes('@modelcontextprotocol/')); expect(sdkOf(entryOutput![1])).toBe(false); // Every chunk the entry statically imports is SDK-free as well. const staticChunks = entryOutput![1].imports.filter((i) => i.kind === 'import-statement' && !i.external).map((i) => i.path); diff --git a/test/daemon/e2e.test.ts b/test/daemon/e2e.test.ts index f7461d8..e8dfeb5 100644 --- a/test/daemon/e2e.test.ts +++ b/test/daemon/e2e.test.ts @@ -463,7 +463,7 @@ describe('cold start', () => { ...d.processEnv(), VIA_NO_DAEMON: daemon ? '0' : '1', TRACE_FILE: trace, - TRACE_IMPORTS: '@modelcontextprotocol/sdk,ajv,ajv-formats', + TRACE_IMPORTS: '@modelcontextprotocol/client,ajv,ajv-formats', }, timeout: 20_000, }); @@ -487,7 +487,7 @@ describe('cold start', () => { const d = await daemonSandbox(config()); const r = await traced(d, ['mock', 'echo', '--message', 'hi'], false); expect(r.code).toBe(0); - expect(r.loaded.some((s) => s.startsWith('@modelcontextprotocol/sdk'))).toBe(true); + expect(r.loaded.some((s) => s.startsWith('@modelcontextprotocol/client'))).toBe(true); expect(r.loaded.some((s) => s.startsWith('ajv'))).toBe(true); }); }); diff --git a/test/protocol-versions.test.ts b/test/protocol-versions.test.ts new file mode 100644 index 0000000..c565dd5 --- /dev/null +++ b/test/protocol-versions.test.ts @@ -0,0 +1,73 @@ +// src/protocol-versions.ts repeats constants the MCP SDK owns, because the config loader must not import +// the SDK (cold start). These tests compare the copies with the SDK installed in node_modules, so an SDK +// upgrade that adds, drops or renames a protocol revision fails here instead of drifting silently. +import { Client, InMemoryTransport, LATEST_PROTOCOL_VERSION, SERVER_INFO_META_KEY as SDK_SERVER_INFO_META_KEY, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/client'; +import { createMcpHandler, Server } from '@modelcontextprotocol/server'; +import { describe, expect, it } from 'vitest'; +import { isModernProtocolVersion, isProtocolVersion, LEGACY_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS, PROTOCOL_VERSIONS, SERVER_INFO_META_KEY } from '../src/protocol-versions'; + +describe('protocol version constants', () => { + it('lists the revisions the SDK negotiates with `initialize`', () => { + expect([...LEGACY_PROTOCOL_VERSIONS]).toEqual([...SUPPORTED_PROTOCOL_VERSIONS]); + expect(LEGACY_PROTOCOL_VERSIONS[0]).toBe(LATEST_PROTOCOL_VERSION); + }); + + it('lists the stateless revisions the SDK server advertises in `server/discover`', async () => { + const handler = createMcpHandler(() => new Server({ name: 'drift', version: '0' }, { capabilities: { tools: {} } }), { legacy: 'reject' }); + const response = await handler.fetch( + new Request('http://drift.invalid/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'mcp-protocol-version': MODERN_PROTOCOL_VERSIONS[0]!, 'mcp-method': 'server/discover' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSIONS[0], 'io.modelcontextprotocol/clientInfo': { name: 'drift', version: '0' }, 'io.modelcontextprotocol/clientCapabilities': {} } }, + }), + }), + ); + const body = (await response.json()) as { result?: { supportedVersions?: string[]; _meta?: Record } }; + expect(body.result?.supportedVersions).toBeDefined(); + // A server with legacy serving off advertises the stateless revisions only. + expect([...body.result!.supportedVersions!].sort()).toEqual([...MODERN_PROTOCOL_VERSIONS].sort()); + expect(body.result?._meta?.[SERVER_INFO_META_KEY]).toEqual({ name: 'drift', version: '0' }); + }); + + it('is accepted by the SDK client when pinned: every stateless revision connects, an unknown one does not', async () => { + for (const version of MODERN_PROTOCOL_VERSIONS) { + const handler = createMcpHandler(() => new Server({ name: 'pin', version: '0' }, { capabilities: { tools: {} } }), { legacy: 'reject' }); + const client = new Client({ name: 'via', version: '0' }, { capabilities: {}, versionNegotiation: { mode: { pin: version } } }); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + // A tiny bridge from the in-memory pipe to the fetch handler: enough for the connect-time probe. + serverSide.onmessage = (message) => { + void (async () => { + const response = await handler.fetch( + new Request('http://pin.invalid/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'mcp-protocol-version': version, 'mcp-method': (message as { method: string }).method }, + body: JSON.stringify(message), + }), + ); + await serverSide.send((await response.json()) as never); + })(); + }; + await serverSide.start(); + await client.connect(clientSide); + expect(client.getNegotiatedProtocolVersion()).toBe(version); + expect(client.getProtocolEra()).toBe('modern'); + await client.close(); + } + }); + + it('knows its own lists', () => { + expect(PROTOCOL_VERSIONS).toEqual([...MODERN_PROTOCOL_VERSIONS, ...LEGACY_PROTOCOL_VERSIONS]); + expect(new Set(PROTOCOL_VERSIONS).size).toBe(PROTOCOL_VERSIONS.length); + for (const v of MODERN_PROTOCOL_VERSIONS) expect(isModernProtocolVersion(v) && isProtocolVersion(v)).toBe(true); + for (const v of LEGACY_PROTOCOL_VERSIONS) expect(!isModernProtocolVersion(v) && isProtocolVersion(v)).toBe(true); + expect(isProtocolVersion('2099-01-01')).toBe(false); + }); + + it('spells the server identity key the way the SDK does', () => { + expect(SERVER_INFO_META_KEY).toBe(SDK_SERVER_INFO_META_KEY); + }); +}); From bf2f9d4315e51d93fe9dfd5445947318cdf51394 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:04:08 +0000 Subject: [PATCH 2/9] feat(client): stateless 2026-07-28 protocol with automatic fallback Connections probe with server/discover and use the stateless protocol when the server offers it, otherwise fall back to the initialize handshake on the same connection. Stateless servers keep no session: the daemon holds the connection and the caches, list changes arrive on a subscriptions/listen stream (re-opened with backoff when it drops), a server's ttlMs shortens the catalog TTL, and the catalog cache remembers the negotiated protocol so a stdio server of the old protocol is not probed (started twice) on every connection. Fixture servers now speak either generation (VIA_TEST_PROTOCOL=modern runs the CLI, daemon and client suites against the new one). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- CHANGELOG.md | 7 + ...col-versions-newest-first-with-fallback.md | 48 ++++ docs/adr/README.md | 1 + docs/overview/glossary.md | 1 + docs/spec/daemon.md | 25 +- docs/spec/discovery.md | 6 +- src/cli/backend.ts | 24 +- src/cli/catalog-cache.ts | 47 +++- src/client/client.ts | 8 +- src/client/errors.ts | 53 +++- src/client/session.ts | 145 ++++++++++- src/config/layer.ts | 7 +- src/config/load.ts | 1 + src/config/merge.ts | 2 +- src/config/model.ts | 1 + src/config/types.ts | 5 + src/daemon/backend.ts | 8 + src/daemon/sessions.ts | 44 +++- src/daemon/target.ts | 2 + src/protocol.ts | 13 +- test/cli/cache.test.ts | 59 ++++- test/cli/helpers.ts | 38 ++- test/client/client.test.ts | 2 +- test/client/protocol.test.ts | 228 ++++++++++++++++++ test/daemon/protocol.test.ts | 2 +- test/fixtures/mcp/cli-server.d.mts | 6 +- test/fixtures/mcp/cli-server.mjs | 77 +++--- test/fixtures/mcp/cli-stdio-server-modern.mjs | 3 + test/fixtures/mcp/cli-stdio-server.mjs | 11 +- test/fixtures/mcp/flavors.mjs | 96 ++++++++ test/fixtures/mcp/mock-server.d.mts | 9 +- test/fixtures/mcp/mock-server.mjs | 120 ++++----- test/fixtures/mcp/modern-http.d.mts | 11 + test/fixtures/mcp/modern-http.mjs | 19 ++ test/fixtures/mcp/stdio-server-modern.mjs | 3 + test/fixtures/mcp/stdio-server.mjs | 18 +- test/helpers/client.ts | 48 +++- test/helpers/daemon.ts | 3 +- test/helpers/generation.ts | 15 ++ 39 files changed, 1031 insertions(+), 185 deletions(-) create mode 100644 docs/adr/0017-protocol-versions-newest-first-with-fallback.md create mode 100644 test/client/protocol.test.ts create mode 100644 test/fixtures/mcp/cli-stdio-server-modern.mjs create mode 100644 test/fixtures/mcp/flavors.mjs create mode 100644 test/fixtures/mcp/modern-http.d.mts create mode 100644 test/fixtures/mcp/modern-http.mjs create mode 100644 test/fixtures/mcp/stdio-server-modern.mjs create mode 100644 test/helpers/generation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fa5a3..7173592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ ### Added +- 支持 MCP `2026-07-28` 的无状态协议,新版本优先、自动回退(路线图 e2,[ADR 0017](docs/adr/0017-protocol-versions-newest-first-with-fallback.md)、[daemon.md](docs/spec/daemon.md#协议版本与无状态连接)):建立连接时先发 `server/discover`,server 支持 `2026-07-28` 就按新协议谈(无 `initialize`、无会话 ID,请求带 `MCP-Protocol-Version` 与 `Mcp-Method` 头,`serverInfo` 读自响应的 `_meta`),被拒绝(旧 server 对陌生方法的 4xx)时在同一条连接上退回 `initialize` 握手(`2025-11-25` 至 `2024-10-07`);用户不需要配置,旧版 server 上的行为不变。要点: + - 变更通知:新协议下 daemon 订阅 `subscriptions/listen` 流,与旧协议的 `notifications/tools/list_changed` 进同一个刷新入口;流意外断开时按 1 秒到 30 秒的退避重订,重订成功后立即刷新一次目录。 + - server 在 `tools/list` 结果里给的 `ttlMs` 与 24 小时的目录 TTL 取较短者,随缓存保存;`0` 或缺省视为没有提示(SDK 构造的 server 默认发 `0`,照字面当作立刻过期会让浏览命令每次都连接 server)。 + - 目录缓存文件记下协商出的 `protocol`(`{version, stateful}`)和 `ttlMs`,都是可缺省的新字段,旧文件照常读取。stdio server 据此跳过协议探测:SDK 在 server 进程的副本上探测,对旧版 stdio server 等于每次连接多启动一个进程,所以缓存新鲜且记录是旧协议时直接握手(失败,超时除外,才再探测一次);缓存为空或过期后的第一次连接仍会多启动一个进程。stdio 的探测 3 秒无应答视为旧版 server。 + - `roots` 在新协议里经 `input_required` 由 SDK 用同一个处理器应答,配置与声明条件不变;`logging/setLevel` 被新协议去掉,via 从不发它;无状态连接没有会话要终止、也没有会话 ID 存盘。 + - 新协议的 `tools/call` 走 SDK 的 `callTool()`,按 `x-mcp-header` 镜像 `Mcp-Param-*` 请求头;结果仍按 server 给的样子显示。 + - 配置新增 `servers..protocolVersion`(取值、语义与可见性见 config.md、cli.md,路线图 e3);daemon 的 `list` 应答与 `status` 多了可选的 `protocol`。 - `via serve`(路线图 c5,[ADR 0016](docs/adr/0016-via-serve-meta-tools-over-stdio.md)):以 stdio MCP server 运行,向没有 shell 的 agent(Claude Desktop、IDE 聊天面板等)暴露七个固定的元工具 `via_browse`、`via_find`、`via_call`、`via_more`、`via_resources`、`via_read`、`via_prompts`。每次调用在进程内执行一次对应的 CLI 命令并捕获输出,所以命名、参数解析、`deny`、`--yes`、本地校验、渲染、游标与脱敏都与命令行相同;结果是 stdout 与 stderr 两段文本,`structuredContent.exitCode` 给出退出码,非 0 时 `isError`。以 `-` 开头的字符串参数和 `via_browse` 里的内置子命令名会被拒绝(退出码 2),模型无法借参数设置选项或运行 `via trust`、`via auth`。七个工具的定义约 730 token。cli.md 新增「via serve」一节,agent-integration.md 新增「没有 shell 的 agent」,愿景的适用边界、路线图与术语表随之更新,`open-questions.md` 移除该条。 - 读取框架配置时,命令行是 `via serve` 的条目(如 `npx -y @modelctx-ai/via serve`、`/usr/local/bin/via serve`)不进入目录,`via servers` 给出 info 级诊断 `self-reference`,避免 via 拉起自己(config.md「校验与诊断」)。 - `-h` 给出调用示例(路线图 f4,[arguments.md](docs/spec/arguments.md#调用示例)):在 `alias:` 行之前加一行 `example: via github issue:read --owner text --repo text --issue-number 1 --method get`,按 `inputSchema` 生成、可直接粘贴。必填参数全写,可选参数补到共 3 个;值取 `enum`/`const`、`examples`、`default`,否则按类型与 `format` 生成;嵌套 object 写成点号路径,标量数组重复 flag,对象数组、tuple、自由 object 写 JSON;nullable 取非 null 分支,本地 `$ref` 展开(递归 schema 有深度上限)。每个词按[引号规则](docs/spec/output.md#可粘贴的命令行)引用,server 给出的值没有安全写法时换用下一个候选;示例只在 `-h` 里,错误信息里的 `usage:` 不变。每个工具约多 20 到 40 个 token。测试对夹具里全部工具的示例用真实的 shell 与解析器、校验器逐条检查。不涉及动词表与规范写法。 diff --git a/docs/adr/0017-protocol-versions-newest-first-with-fallback.md b/docs/adr/0017-protocol-versions-newest-first-with-fallback.md new file mode 100644 index 0000000..231e83a --- /dev/null +++ b/docs/adr/0017-protocol-versions-newest-first-with-fallback.md @@ -0,0 +1,48 @@ +# 0017. MCP 协议版本:新版本优先,自动回退,可以固定 + +- 状态:已接受 +- 日期:2026-10-21 + +## 背景 + +via 锁定的 `@modelcontextprotocol/sdk` 1.x 最高协商到 `2025-11-25`。MCP 的 `2026-07-28` 把协议改成无状态:没有 `initialize` 握手,没有 server 端会话和会话 ID,每个请求自己带版本、客户端信息和能力,变更通知改为 `subscriptions/listen` 流,server 在 `server/discover` 里宣布支持的版本。官方 SDK 里只有 v2(`@modelcontextprotocol/client`)实现了它。新版协议的 server 会越来越多,旧版 server 在很长时间里仍是大多数;via 的用户不应该为此写配置,旧版 server 上的行为也不该变。 + +约束: + +- 浏览命令的冷启动不变,SDK 仍只在要连接时才加载([实现说明](../development/implementation.md#启动速度))。 +- 不为特定 server 适配([ADR 0007](0007-no-gateway-specific-adaptation.md)):协议差异按版本处理,不按 server 处理。 +- via 对列表、资源、prompt 的结果一直是宽松取用(一个畸形条目不让整次操作失败),不因升级 SDK 变严。 + +## 决策 + +**迁移到官方 SDK v2**(client 与 server 两个包),旧的 1.x 只留作测试用的模拟 server([实现说明](../development/implementation.md#mcp-sdkv2))。 + +**默认新版本优先,自动回退。** 建立连接时先探测(`server/discover`),server 给出 `2026-07-28` 就按无状态协议谈,探测被拒绝就在同一条连接上用 `initialize` 握手。探测被拒绝(4xx 或 JSON-RPC 错误)不是错误;401/403 是鉴权问题,5xx 与网络错误照常报告。细节与 daemon 的行为见 [daemon.md](../spec/daemon.md#协议版本与无状态连接)。 + +**stdio 的探测代价用缓存记录消除。** SDK 在 server 进程的副本上探测(多启动一个进程,探测完才启动真正的进程),这对启动慢或启动有副作用的旧版 stdio server 不可接受。目录缓存因此记下上次协商出的版本;记录是旧版协议且缓存新鲜时直接握手、不探测,握手失败(超时除外)再探测一次。daemon 里同一会话重连沿用上次结果。 + +**`protocolVersion` 固定版本,不支持就报错,不回退。** 面向两种人:需要绕过某个 server 在新协议上的缺陷的人,和要求无状态部署的人。字段在 `servers.` 下,取值是 SDK 支持的版本;退出码 6,`next:` 说明去掉或改值。 + +**可见。** `via servers`(读缓存里记下的)与 `via doctor`(实时)显示协商出的版本与连接是否有状态。「有状态」的定义:无状态协议的连接无状态;旧协议下 stdio 进程本身是会话,HTTP 只有 server 分配了会话 ID 才有状态。 + +**缓存提示取较短者,`0` 不算提示。** 无状态协议的 `tools/list` 结果可带 `ttlMs`;正数与 24 小时取较短。SDK 构造的 server 默认发 `ttlMs: 0`,如果照字面当作「立刻过期」,浏览命令每次都要连接 server,这与目录缓存存在的理由相反,所以 `0` 与缺省一样沿用 24 小时。 + +**通知。** 旧协议是 server 推送的 `notifications/tools/list_changed`,新协议是 `subscriptions/listen` 流上的事件;两者进 daemon 刷新目录的同一个入口。SDK 不会在流意外断开后重订,via 自己带退避重订,并在重订成功后立即刷新一次(断开期间可能错过事件)。 + +**roots、日志。** 新协议里 server 通过 `input_required` 向 client 要 roots,SDK 用 via 注册的同一个处理器应答,配置与声明条件不变([ADR 0010](0010-mcp-resources-prompts-and-roots.md))。`logging/setLevel` 被新协议去掉;via 在任何版本上都不发它。 + +## 备选方案 + +- **等 1.x 支持新协议**:1.x 已进入维护,没有这个计划;via 会一直停在 `2025-11-25`。 +- **只在配置里开启新协议(opt-in)**:用户要知道每个 server 的协议版本才能配置,而 via 的前提是用户不关心这些;新版 server 多起来后默认关闭只会让大家都要写配置。 +- **自己实现 `server/discover` 与无状态请求**:要复制 SDK 的版本协商、`Mcp-Method` 等请求头、`input_required` 的多轮重试,每次协议修订都要跟;SDK 是这些的权威实现。 +- **对 stdio 完全不探测,只讲旧协议**:最省事,但永远用不上只讲新协议的 stdio server。保留探测,并用缓存把旧版 server 的代价降到零。 +- **`ttlMs: 0` 按字面当立刻过期**:见上,会让浏览命令失去不连接的能力;真想让目录常新的 server 发正数,或用 `list_changed`。 + +## 后果 + +- 进程内模式(无 daemon)的一次调用慢约 50 ms,daemon 常驻内存多几 MiB(SDK v2 更大,数字见实现说明)。热 daemon 的调用开销不变。 +- 第一次连接一个缓存为空的 stdio server 要多启动一个进程(探测的副本),之后的连接不再多。 +- v2 的 HTTP 传输默认只跟随同源重定向,via 保持这个默认(重定向到别处报错并给出目标地址)。 +- 测试对两代协议各跑一遍([testing.md](../development/testing.md#两代协议));SDK 升级时,协议版本常量的单测会提示 via 保存的版本列表已经漂移。 +- 配置里新增 `protocolVersion`;`ProtocolInfo`(`{ version, stateful }`)进入目录缓存、daemon 的 `list` 应答和 `status`。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 11c94b3..49096a2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ | [0014](0014-drop-the-domain-view.md) | 去掉「域」:provider 按名字列出,不做分类 | 已接受 | | [0015](0015-npm-scope-modelctx-ai.md) | npm 包名为 `@modelctx-ai/via` | 已接受 | | [0016](0016-via-serve-meta-tools-over-stdio.md) | `via serve`:以 stdio MCP server 暴露七个元工具,每次调用就是一次 CLI 命令 | 已接受 | +| [0017](0017-protocol-versions-newest-first-with-fallback.md) | MCP 协议版本:新版本优先,自动回退,可以固定 | 已接受 | ## 流程 diff --git a/docs/overview/glossary.md b/docs/overview/glossary.md index 745fef6..ada2308 100644 --- a/docs/overview/glossary.md +++ b/docs/overview/glossary.md @@ -10,6 +10,7 @@ | **资源模板** | MCP 资源的 URI 模板(RFC 6570,如 `file:///logs/{date}.log`),调用方替换占位符后用 `via read` 读取。 | | **prompt(MCP prompt)** | server 提供的带参数消息模板(`prompts/list`、`prompts/get`),用 `via prompts` 访问。与 `via prompt`(生成给 agent 的说明)无关。 | | **roots** | client 声明给 server 的目录列表(MCP 的 `roots` 能力)。via 默认不声明,按 server 用配置的 `roots` 字段开启。 | +| **协议版本(protocol version)** | 一次 MCP 连接协商出的协议修订,形如 `2026-07-28`、`2025-11-25`。`2026-07-28` 起协议无状态(没有 `initialize` 握手和 server 端会话),via 默认新版本优先、自动回退,`protocolVersion` 可以固定;「有状态」的含义见 [daemon.md](../spec/daemon.md#协议版本与无状态连接)。 | | **动作(verb)** | 从原始名拆出的动词部分,如 `create`、`create-or-update`。 | | **工具引用** | 定位一个工具的写法。命令行形式 `linear issue:create`,单字符串形式 `linear/issue:create`。 | | **元工具(meta-tool)** | `via serve` 暴露给没有 shell 的 agent 的固定工具(`via_browse`、`via_find`、`via_call` 等),每个对应一条 CLI 命令;与下游 server 的工具不同,它们不随目录变化。 | diff --git a/docs/spec/daemon.md b/docs/spec/daemon.md index 654c14d..03974e7 100644 --- a/docs/spec/daemon.md +++ b/docs/spec/daemon.md @@ -27,7 +27,7 @@ socket 只能说明有没有 daemon 在接受连接。还在启动(取锁、 CLI 在每个请求里带上它加载好的 server 定义(只含凭证**引用**)、自己的工作目录、`daemon.serverIdleTimeout`,daemon 自己不读配置文件,所以项目级 `.via.yaml` 随调用目录变化,不会被某个 daemon 的启动目录固化。 -- stdio server 没有写 `cwd` 时,以调用方的工作目录启动;`file:` 引用的相对路径同样相对调用方的目录(daemon 内换算成绝对路径)。会话按「server 名 + 传输 + 鉴权 + `exclusive` + `concurrency` + `roots`」的哈希区分,所以不同目录下的同名 stdio server 是不同的会话。 +- stdio server 没有写 `cwd` 时,以调用方的工作目录启动;`file:` 引用的相对路径同样相对调用方的目录(daemon 内换算成绝对路径)。会话按「server 名 + 传输 + 鉴权 + `exclusive` + `concurrency` + `roots` + `protocolVersion`」的哈希区分,所以不同目录下的同名 stdio server 是不同的会话。 - `roots`([config.md](config.md#roots))里的相对路径和 `~` 同样由 daemon 按调用方的目录与家目录换成绝对 `file://` URI,换算后的列表参与会话键:写了 `roots: ["."]` 的 server 在两个目录里是两个会话,一个会话的 roots 在它的生命周期里不变,所以 via 声明 `roots` 能力时 `listChanged` 为 `false`,不需要发 `notifications/roots/list_changed`。没有 `cwd` 的 HTTP server 因 roots 而按目录分会话,代价与 stdio 的 `cwd` 相同:两个目录交替使用时,后到的会话替换先到的(同名 server 只保留最新定义的会话)。 - `env:` 引用在 **daemon 自己的环境**里解析(它由第一个启动它的 CLI 继承环境),解析值不经过 socket,协议里始终没有凭证值。每个请求带 `envStamp`:对该 server 所有 `env:` 引用的变量名与调用方当前取值取单向摘要(没有 `env:` 引用时为空串)。daemon 用自己的环境算同一个摘要,不一致就回复 `stale`(不执行请求)。 - 收到 `stale` 的 CLI **只对这个 server 的这一个请求**改用进程内模式完成:不重启 daemon,也不把取值告诉 daemon;daemon 里按自己环境建立的会话继续服务摘要一致的调用方,对同一次运行里的其他 server 照常使用 daemon。`stale` 的请求没有执行过,所以不论是否只读都可以安全地在进程内重做,也不打印回退提示。 @@ -37,8 +37,8 @@ CLI 在每个请求里带上它加载好的 server 定义(只含凭证**引用 ## daemon 持有的状态 -- MCP 会话:stdio 子进程、HTTP 的 session id、OAuth 会话(登录与登出由 CLI 完成,daemon 在下一次请求时读到存储里的新状态,见 [auth.md](auth.md#oauth))。 -- 工具目录的副本,连同 server 在 `initialize` 里声明的 `resources` / `prompts` 能力,以及 `list_changed` 订阅:副本就是磁盘缓存(与进程内模式相同的 `catalog/` 文件,两种模式共享,能力声明一并写入);daemon 在内存里只记下它的 `fetchedAt`、能力声明和工具个数,不留解析后的工具对象(几百个工具是几 MB),有人不带 `have` 来要列表时才从文件读出来发送;只有磁盘写不进去时才把列表留在内存里。订阅:收到 `notifications/tools/list_changed` 后 50 毫秒内(合并连发的通知)重新 `tools/list`,同时替换内存副本和磁盘缓存,所以下一次浏览、`find` 或调用就能看到变化;刷新失败时丢弃副本与磁盘缓存,下一次请求重新向 server 拉取。内存副本的有效期与磁盘缓存相同(24 小时)。stdio server 因空闲被停止后订阅随之结束,之后只靠 TTL。 +- MCP 会话:stdio 子进程、旧版协议下 HTTP 的 session id、OAuth 会话(登录与登出由 CLI 完成,daemon 在下一次请求时读到存储里的新状态,见 [auth.md](auth.md#oauth))。无状态协议(`2026-07-28`)没有 server 端会话:daemon 只保留连接与下面的缓存,不做会话恢复,也不存会话 ID([协议版本与无状态连接](#协议版本与无状态连接))。 +- 工具目录的副本,连同 server 在 `initialize` 里声明的 `resources` / `prompts` 能力,以及 `list_changed` 订阅:副本就是磁盘缓存(与进程内模式相同的 `catalog/` 文件,两种模式共享,能力声明一并写入);daemon 在内存里只记下它的 `fetchedAt`、能力声明和工具个数,不留解析后的工具对象(几百个工具是几 MB),有人不带 `have` 来要列表时才从文件读出来发送;只有磁盘写不进去时才把列表留在内存里。订阅:收到 `notifications/tools/list_changed` 后 50 毫秒内(合并连发的通知)重新 `tools/list`,同时替换内存副本和磁盘缓存,所以下一次浏览、`find` 或调用就能看到变化;刷新失败时丢弃副本与磁盘缓存,下一次请求重新向 server 拉取。旧版协议里这是 server 推送的通知,无状态协议里是 `subscriptions/listen` 流上的事件(同一个处理入口,见下节)。内存副本的有效期与磁盘缓存相同:24 小时,server 在 `tools/list` 结果里给了更短的 `ttlMs` 时取更短者([discovery.md](discovery.md#缓存))。stdio server 因空闲被停止后订阅随之结束,之后只靠 TTL。 - 凭证解析结果与 OAuth token 刷新。 - 截断结果的游标。daemon 与进程内模式一样把游标写入用户缓存目录(`cursors/`,目录 0700、文件 0600,内容已脱敏,TTL 30 分钟,按文件修改时间清理),所以 daemon 空闲退出(10 分钟)不会让游标提前失效;只有 TTL 到期或 id 不存在才报告过期(退出码 3)。`via more` 先问已经在运行的 daemon(不会为它拉起新的),再看缓存目录。 - 编译好的 JSON Schema(见下「校验」)。 @@ -102,7 +102,7 @@ type ErrorInfo = { | 请求 | 行为 | | --- | --- | -| `list` | 返回 `{ tools, fetchedAt, fromCache, features? }`,`features` 是 `{ resources, prompts }`(server 是否声明了这两种能力,缓存里没有记录时省略)。先在 daemon 里解析该 server 的凭证(缺失则退出码 4,不发任何请求),再依次看内存副本、磁盘缓存、server;`refresh` 跳过前两者(`via sync`、`via doctor`)。未信任的项目级 server 退出码 5。`have` 是 CLI 手里已有预构建目录的那份列表的 `fetchedAt`:daemon 的列表恰好是这一份时,应答为 `{ unchanged: true, fetchedAt, fromCache: true, features? }`,不带 `tools`(凭证与信任检查照常先做;带 `refresh` 时忽略 `have`)。CLI 只在磁盘缓存里的列表也是这一份时才带 `have`,收到 `unchanged` 后需要工具时从那个缓存文件读,所以浏览类命令不必让 daemon 把整份列表再传一遍 | +| `list` | 返回 `{ tools, fetchedAt, fromCache, features?, protocol? }`,`features` 是 `{ resources, prompts }`(server 是否声明了这两种能力,缓存里没有记录时省略),`protocol` 是 `{ version, stateful }`(最近一次连接协商出的协议版本与连接是否有状态,同样记在缓存里,没有记录时省略)。先在 daemon 里解析该 server 的凭证(缺失则退出码 4,不发任何请求),再依次看内存副本、磁盘缓存、server;`refresh` 跳过前两者(`via sync`、`via doctor`)。未信任的项目级 server 退出码 5。`have` 是 CLI 手里已有预构建目录的那份列表的 `fetchedAt`:daemon 的列表恰好是这一份时,应答为 `{ unchanged: true, fetchedAt, fromCache: true, features?, protocol? }`,不带 `tools`(凭证与信任检查照常先做;带 `refresh` 时忽略 `have`)。CLI 只在磁盘缓存里的列表也是这一份时才带 `have`,收到 `unchanged` 后需要工具时从那个缓存文件读,所以浏览类命令不必让 daemon 把整份列表再传一遍 | | `call` | 返回 server 的原始 `tools/call` 结果。`progress: true` 时,进度作为 `event` 推送。结果中的凭证值在 daemon 里替换为 `***` 后才发出,错误信息同样 | | `resources-list` | 返回 `{ resources, templates }`:`resources/list` 与 `resources/templates/list` 分页拉全(server 没有实现模板方法时 `templates` 为空)。不缓存。server 没有声明 `resources` 能力时不发请求,退出码 3 | | `resource-read` | 返回 `{ contents }`(`resources/read` 的原始结果:`text` 或 base64 的 `blob`)。server 不认识这个 URI(`-32002`,或把它报告为 `-32602` 的 server)退出码 3,信息带 server 的说明;没有声明 `resources` 时同 `resources-list` | @@ -111,7 +111,7 @@ type ErrorInfo = { | `validate` | 编译 JSON Schema(按内容缓存)并校验 `data`,返回 ajv 的错误与 `format` 警告;`compileOnly` 只编译。这样 CLI 进程不加载 ajv | | `redact` | 把 daemon 已知的凭证值换成 `***`,只在已有连接时发,不为它拉起 daemon。`text` 是 CLI 拼好的文字(错误、校验警告),只替换 8 个字符以上的凭证;`value` 是 CLI 回显的内容(`--dry-run` 的 `args`,任意 JSON),其中每个字符串里的任何长度的凭证都替换,键与结构不变([脱敏的范围](output.md#脱敏的范围)) | | `cursor-put` / `cursor-get` | 存取游标记录(内容已脱敏),返回 id 或记录;记录落在用户缓存目录,不随 daemon 退出而丢失 | -| `status` | 返回 pid、版本、socket、运行时间、距空闲退出的时间、请求数、游标数、内存和各会话(server 名、传输、是否连接、子进程 pid、目录中的工具数、调用数、空闲时间)。不含 URL、参数和任何凭证 | +| `status` | 返回 pid、版本、socket、运行时间、距空闲退出的时间、请求数、游标数、内存和各会话(server 名、传输、是否连接、子进程 pid、协商出的协议、目录中的工具数、调用数、空闲时间)。不含 URL、参数和任何凭证 | | `shutdown` | 先停止监听、删除 socket,应答,处理完在途请求后退出。带 `force: true` 时应答后放弃在途请求(关闭会话与 stdio 子进程,它们的 CLI 看到连接中断)立即退出;`via daemon stop` 在 5 秒宽限之后发它 | 约定: @@ -121,6 +121,21 @@ type ErrorInfo = { - `auth` 请求的 `login` 不经过 daemon:登录需要浏览器或终端交互,由 CLI 在自己的进程里完成并写入共用存储([auth.md](auth.md#oauth));daemon 里的会话在下一次请求前读取存储,所以新的登录、登出和刷新结果无需通知。 - 未知消息类型返回 `exitCode: 7` 的错误,不断开连接。 +## 协议版本与无状态连接 + +MCP 的 `2026-07-28` 把协议改成无状态:没有 `initialize` 握手,没有 server 端会话和会话 ID,每个请求自己带协议版本、客户端信息和能力。via 用官方 SDK v2 同时支持新旧两代([ADR 0017](../adr/0017-protocol-versions-newest-first-with-fallback.md)),**用户不需要配置**;旧版 server 上的行为与迁移前相同。 + +- **协商:新版本优先,自动回退。** 建立连接时先发 `server/discover`(探测)。server 在 `supportedVersions` 里给出 `2026-07-28` 就按新协议谈;探测被拒绝(HTTP 400、404、405 或 JSON-RPC 错误,旧 server 对陌生方法的常见答复,**不算错误**)时,在同一条连接上退回 `initialize` 握手,取 server 接受的最新旧版本(`2025-11-25` 至 `2024-10-07`)。401 和 403 是真的鉴权问题,按[鉴权错误](auth.md#调用时的行为)报告;5xx 与网络错误也如实报告,不当成旧 server。 +- **stdio 的探测。** SDK 在 server 进程的一个副本上探测,探测完了才启动真正的进程,所以一次探测多启动一个进程;探测 3 秒没有应答就当作旧版 server(有些旧 server 对握手前的请求不作答)。为了不让每个旧版 stdio server 都被启动两次,目录缓存记着上次协商出的版本(`protocol`):缓存新鲜且记录的是旧版协议时,直接用普通的 `initialize` 握手、不探测,失败(超时除外)才再用探测试一次;daemon 里同一个会话重连时沿用上次的结果。缓存为空或过期后重新探测。HTTP 的探测只多一个请求,不需要这个记录。 +- **无状态连接。** 请求头带 `MCP-Protocol-Version` 与 `Mcp-Method`(方法名,路由用),请求的 `_meta` 带版本、客户端信息和 via 声明的能力;`serverInfo` 从响应的 `_meta` 读取。daemon 只保留连接和缓存:不发 `Mcp-Session-Id`,关闭时没有会话要终止,连接断了下次请求重新协商。via 从不把会话 ID 存到磁盘,所以升级后没有「过期的会话 ID」要丢弃。 +- **有状态是什么意思。** `via servers`、`via doctor` 和协议的 `ProtocolInfo.stateful` 里:新协议连接是无状态的;旧协议下 stdio 进程本身就是会话,HTTP server 只有分配了会话 ID 才有状态(没有分配的,如无状态部署的旧 server,算无状态)。 +- **变更通知。** 旧协议里 server 推送 `notifications/tools/list_changed`;新协议里 client 在连接上开一条 `subscriptions/listen` 流,server 把变更事件发在这条流上。两者进同一个处理入口(上节的刷新)。daemon 在 server 声明了 `tools.listChanged` 时订阅;流被对端意外断开时按 1 秒起翻倍(最长 30 秒)的间隔重新订阅,成功后当作可能错过了事件,立即刷新一次目录。server 主动结束(正常关闭)的流不重订。进程内模式不订阅。 +- **缓存提示。** 新协议的 `tools/list` 结果可带 `ttlMs`(建议的有效期,毫秒)。正数取与 24 小时中较短者;`0` 或没有视为没有给出提示(SDK 构造的 server 默认发 `0`),沿用 24 小时。提示随目录缓存一起保存([discovery.md](discovery.md#缓存))。`cacheScope` 不处理:via 的缓存本来就是每个用户一份。 +- **roots。** 新协议里 server 不再向 client 发 `roots/list` 请求,而是在调用结果里返回「需要输入」(`input_required`),client 回答后重试;SDK 用 via 注册的同一个 `roots/list` 处理器自动应答,所以 `roots` 的配置、声明条件(没配置就不声明)和 `listChanged: false` 都不变([config.md](config.md#roots)、[ADR 0010](../adr/0010-mcp-resources-prompts-and-roots.md))。`roots` 能力在 `2026-07-28` 里已标为弃用(至少保留十二个月),到移除时再处理。 +- **日志级别。** 新协议去掉了 `logging/setLevel`。via 在任何版本上都不发它(daemon 不转发 server 的日志,stdio 的 stderr 另有处理),所以没有要改的。 +- **结果的处理。** 列表、资源和 prompt 的结果仍由 via 宽松地取用(一个畸形条目不让整次操作失败)。新协议的 `tools/call` 走 SDK 的 `callTool()`:它按工具声明的 `x-mcp-header` 把参数镜像进 `Mcp-Param-*` 请求头(缺了头的调用会被 server 拒绝),SDK 的输出校验被关掉,结果仍按 server 给的样子显示。 +- **固定版本。** `protocolVersion`([config.md](config.md#serversname))固定一个版本:server 不支持就报错(退出码 6),不回退。 + ## 校验 `validate` 把参数校验放在 daemon 里:CLI 在 daemon 模式下既不加载 MCP SDK 也不加载 ajv([实现说明](../development/implementation.md#启动速度)),daemon 则复用已编译的 schema。联系 daemon 失败时 CLI 回退为本地 ajv,校验没有副作用,不需要提示。 diff --git a/docs/spec/discovery.md b/docs/spec/discovery.md index 48b9e7a..3c32763 100644 --- a/docs/spec/discovery.md +++ b/docs/spec/discovery.md @@ -14,15 +14,15 @@ provider 就是配置里的 server,按名字原样列出,不做分类:模 - 缓存键:server 定义的哈希(命令、参数、URL、相关 env 名,不含凭证值)+ 身份(鉴权主体的稳定标识,无法获得时用凭证引用本身的哈希)。 - 位置:用户缓存目录下的 `catalog/`,权限 0700。 -- TTL 默认 24 小时;daemon 在线时订阅 `notifications/tools/list_changed` 实时刷新:收到通知后重新拉取该 server 的 `tools/list`,同时更新 daemon 内存里的副本和这里的磁盘缓存,下一次浏览、`find` 或调用就能看到变化([daemon.md](daemon.md#daemon-持有的状态))。 +- TTL 默认 24 小时;无状态协议(`2026-07-28`)的 server 可以在 `tools/list` 结果里给出 `ttlMs`,取与 24 小时中较短者(`ttlMs` 为 `0` 或缺省视为没有给出提示,沿用 24 小时;提示随缓存保存,所以进程内模式和 daemon 一致)。daemon 在线时订阅 `notifications/tools/list_changed`(无状态协议里是 `subscriptions/listen` 流上的同名事件)实时刷新:收到通知后重新拉取该 server 的 `tools/list`,同时更新 daemon 内存里的副本和这里的磁盘缓存,下一次浏览、`find` 或调用就能看到变化([daemon.md](daemon.md#daemon-持有的状态))。 - `via sync [provider]` 强制刷新。 - 进程内模式(0.1)下,缓存是 `tools/list` 的唯一来源:`via ` 只读取、必要时只拉取该 provider;`via`、`via find`、`via prompt` 并行处理全部 server,单个 server 的拉取超时取 `min(server 的 timeout, 10 秒)`(`--timeout` 覆盖),拉起 stdio server 与握手的时间也算在内,所以卡在握手里的 server 不会拖住整条命令。拉取失败不中止:该 provider 仍然列出,标记 `needs auth`、`failed` 或 `untrusted`([浏览视图](#via))。 - 命中缓存时仍会在本地解析该 server 的凭证引用,缺失就按「需要鉴权」处理,并让输出脱敏知道这些值;不发起网络请求。未信任的项目级 server 不读缓存,也不拉取。 -- 0.1 的「身份」只用凭证引用文本参与哈希(引用的值、字面量凭证都不会单独落盘;字面量只进入单向哈希),换一个引用即视为另一份缓存。缓存文件为 `catalog/<哈希>.json`(权限 0600),内容是 `fetchedAt`、原始 `tools/list` 结果,以及 server 在 `initialize` 里声明的能力 `features`(`{"resources": bool, "prompts": bool}`,用于浏览视图的 `also:` 行;没有这一项的旧文件照常读取,只是不显示该行,刷新一次后出现)。 +- 0.1 的「身份」只用凭证引用文本参与哈希(引用的值、字面量凭证都不会单独落盘;字面量只进入单向哈希),换一个引用即视为另一份缓存。缓存文件为 `catalog/<哈希>.json`(权限 0600),内容是 `fetchedAt`、原始 `tools/list` 结果,以及 server 在 `initialize` 里声明的能力 `features`(`{"resources": bool, "prompts": bool}`,用于浏览视图的 `also:` 行;没有这一项的旧文件照常读取,只是不显示该行,刷新一次后出现)、拉取时协商出的协议 `protocol`(`{"version": "2026-07-28", "stateful": false}`,`via servers` 不连接就能显示它,stdio server 据此跳过协议探测,[daemon.md](daemon.md#协议版本与无状态连接))和 server 给的 `ttlMs`(有的话);这两项同样可以缺省。缓存键包含 `protocolVersion`(固定了版本时)。 - 经 daemon 时目录的读取、凭证检查和写缓存都在 daemon 里完成(与进程内模式写同一份 `catalog/` 文件);CLI 只收到原始工具列表,拆分和索引仍在 CLI 侧计算。 - 校验失败且缓存超过 5 分钟时,刷新该 server 的缓存一次再校验([arguments.md](arguments.md#schema-不可靠时))。 - 缓存中保存原始 `tools/list` 结果;拆分与索引由它计算,规则升级无需清缓存:计算结果另存为预构建缓存(下节),规则一变它就失效并重新计算。 -- 缓存文件的第一部分是 `version`、`fetchedAt` 和 `features`(有记录时),工具数组在其后、是文件的末尾;读取时只看前者判断新旧并取出能力声明,数组在有人用到工具定义(调用、参数校验、`-h`)时才解析。 +- 缓存文件的第一部分是 `version`、`fetchedAt`、`ttlMs`、`protocol` 和 `features`(有记录时,按这个顺序),工具数组在其后、是文件的末尾;读取时只看前者判断新旧并取出能力声明,数组在有人用到工具定义(调用、参数校验、`-h`)时才解析。 ### 预构建缓存 diff --git a/src/cli/backend.ts b/src/cli/backend.ts index 865161f..919d3ea 100644 --- a/src/cli/backend.ts +++ b/src/cli/backend.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import type { McpTool } from '../catalog/types'; import type { CallOptions, McpClient } from '../client'; import type { CredentialResolver, ServerConfig } from '../config/types'; -import type { ServerFeatures, ToolCallResult } from '../protocol'; +import type { ProtocolInfo, ServerFeatures, ToolCallResult } from '../protocol'; import { FileCursorStore } from '../render/cursor-file'; import { ExitCode } from '../render/exit-codes'; import { createRedactor, redactValue } from '../render/redact'; @@ -28,6 +28,8 @@ export interface Listing { rawBytes?: number; /** Resources and prompts the server declared; absent when not known (an older cache entry). */ features?: ServerFeatures; + /** The protocol the list was fetched over; absent when not known (an older cache entry). */ + protocol?: ProtocolInfo; } export interface ListingOptions { @@ -103,9 +105,10 @@ export class InProcessBackend implements Backend { /** The client that talks to servers, made on first use: browsing a cached catalog never needs it. */ private client(): Promise { this.clientLoading ??= import('../client').then(({ InProcessClient }) => { - const { servers, resolver, now, oauthStore, onWarning, roots } = this.options; + const { servers, resolver, now, oauthStore, onWarning, roots, cache } = this.options; return (this.loaded = new InProcessClient(servers, resolver, { now, + knownProtocol: (server) => cache.read(server)?.protocol, ...(oauthStore ? { oauthStore } : {}), ...(onWarning ? { onWarning } : {}), ...(roots ? { roots } : {}), @@ -137,6 +140,7 @@ export class InProcessBackend implements Backend { fromCache: true, rawBytes: hit.rawBytes, ...(hit.features ? { features: hit.features } : {}), + ...(hit.protocol ? { protocol: hit.protocol } : {}), get tools(): McpTool[] { return hit.tools; }, @@ -144,11 +148,13 @@ export class InProcessBackend implements Backend { } } const client = await this.client(); - const tools = await client.listTools(server.name, { timeoutMs }); + let ttlMs: number | undefined; + const tools = await client.listTools(server.name, { timeoutMs, onTtl: (ms) => void (ttlMs = ms) }); const features = client.features(server.name); + const protocol = client.protocol(server.name); const fetchedAt = now(); - const rawBytes = cache.write(server, tools, { fetchedAt, ...(features ? { features } : {}) }); - return { tools, fetchedAt, fromCache: false, ...(rawBytes !== undefined ? { rawBytes } : {}), ...(features ? { features } : {}) }; + const rawBytes = cache.write(server, tools, { fetchedAt, ...(features ? { features } : {}), ...(protocol ? { protocol } : {}), ...(ttlMs !== undefined ? { ttlMs } : {}) }); + return { tools, fetchedAt, fromCache: false, ...(rawBytes !== undefined ? { rawBytes } : {}), ...(features ? { features } : {}), ...(protocol ? { protocol } : {}) }; } listTools: McpClient['listTools'] = async (server, options) => (await this.client()).listTools(server, options); @@ -167,6 +173,10 @@ export class InProcessBackend implements Backend { return this.loaded?.features(server); } + protocol(server: string): ProtocolInfo | undefined { + return this.loaded?.protocol(server); + } + compiler(): undefined { return undefined; } @@ -306,6 +316,10 @@ export class FallbackBackend implements Backend { getPrompt: McpClient['getPrompt'] = (server, name, args, options) => this.run((b) => b.getPrompt(server, name, args, options), { idempotent: true, what: `getting a prompt of ${server}` }); + protocol(server: string): ProtocolInfo | undefined { + return this.current.protocol(server); + } + features(server: string): ServerFeatures | undefined { return this.current.features(server); } diff --git a/src/cli/catalog-cache.ts b/src/cli/catalog-cache.ts index b3b2a55..e47b75c 100644 --- a/src/cli/catalog-cache.ts +++ b/src/cli/catalog-cache.ts @@ -11,7 +11,7 @@ import type { McpTool } from '../catalog/types'; import { parseCredentialRef } from '../config/credentials'; import { sha256Hex } from '../sha256'; import type { ServerConfig } from '../config/types'; -import type { ServerFeatures } from '../protocol'; +import type { ProtocolInfo, ServerFeatures } from '../protocol'; /** Default time to live of a cached `tools/list` (discovery.md). */ export const CATALOG_TTL_MS = 24 * 60 * 60 * 1000; @@ -22,6 +22,10 @@ const CACHE_VERSION = 1; interface CacheFile { version: number; fetchedAt: number; + /** The server's own freshness hint for the list (`ttlMs` of a stateless-protocol result); absent when it gave none. */ + ttlMs?: number; + /** What the connection that fetched the list negotiated; absent in files written before it was recorded. */ + protocol?: ProtocolInfo; tools: McpTool[]; /** What the server declared at `initialize`; absent in files written before it was recorded. */ features?: ServerFeatures; @@ -34,10 +38,25 @@ export interface CacheEntry { /** The tools; parsed on first access, so a command that does not need them never pays for them. */ readonly tools: McpTool[]; features?: ServerFeatures; + /** The protocol the list was fetched over (`via servers` shows it without connecting). */ + protocol?: ProtocolInfo; + /** The server's own freshness hint stored with the list, if it gave one. */ + ttlMs?: number; } /** What `write` below puts first in every file, so `read` can learn the age without parsing the tools. */ -const FILE_HEAD = /^\{"version":(\d+),"fetchedAt":(\d+),(?:"features":(\{[^{}]*\}),)?"tools":(?=\[)/; +const FILE_HEAD = /^\{"version":(\d+),"fetchedAt":(\d+),(?:"ttlMs":(\d+),)?(?:"protocol":(\{[^{}]*\}),)?(?:"features":(\{[^{}]*\}),)?"tools":(?=\[)/; + +/** How long a cached list may be used: via's own 24 hours, or less when the server said so (discovery.md, "缓存"). */ +export function effectiveTtl(defaultMs: number, serverMs: number | undefined): number { + return serverMs !== undefined && serverMs > 0 ? Math.min(defaultMs, serverMs) : defaultMs; +} + +/** Protocol info as stored: a version string and a boolean, otherwise it does not count. */ +function validProtocol(value: unknown): ProtocolInfo | undefined { + const p = value as Partial | null | undefined; + return typeof p?.version === 'string' && p.version !== '' && typeof p.stateful === 'boolean' ? { version: p.version, stateful: p.stateful } : undefined; +} /** Features as stored: both flags must be booleans, otherwise the entry counts as written before they were recorded. */ function validFeatures(value: unknown): ServerFeatures | undefined { @@ -83,7 +102,9 @@ function computeKey(server: ServerConfig): string { : auth.type === 'oauth' ? { type: 'oauth', scopes: auth.scopes ?? [], clientId: auth.clientId ?? null } : { type: 'none' }; - return sha256Hex(JSON.stringify({ v: CACHE_VERSION, transport: transportPart, auth: authPart })).slice(0, 32); + // The pin belongs to the key: a list fetched under another protocol version says nothing about this one. Added only when set, so existing keys stay. + const pin = server.protocolVersion === undefined ? {} : { pin: server.protocolVersion }; + return sha256Hex(JSON.stringify({ v: CACHE_VERSION, transport: transportPart, auth: authPart, ...pin })).slice(0, 32); } export class CatalogCache { @@ -108,13 +129,16 @@ export class CatalogCache { // parsed only if somebody reads it. if (Number(head[1]) !== CACHE_VERSION) return undefined; const fetchedAt = Number(head[2]); - if (this.now() - fetchedAt > this.ttlMs) return undefined; + if (this.now() - fetchedAt > effectiveTtl(this.ttlMs, head[3] ? Number(head[3]) : undefined)) return undefined; let tools: McpTool[] | undefined; - const features = head[3] ? validFeatures(JSON.parse(head[3])) : undefined; + const features = head[5] ? validFeatures(JSON.parse(head[5])) : undefined; + const protocol = head[4] ? validProtocol(JSON.parse(head[4])) : undefined; return { fetchedAt, rawBytes: text.length, + ...(head[3] ? { ttlMs: Number(head[3]) } : {}), ...(features ? { features } : {}), + ...(protocol ? { protocol } : {}), get tools(): McpTool[] { return (tools ??= JSON.parse(text.slice(head[0].length, -1)) as McpTool[]); }, @@ -122,9 +146,10 @@ export class CatalogCache { } const data = JSON.parse(text) as Partial; if (data.version !== CACHE_VERSION || typeof data.fetchedAt !== 'number' || !Array.isArray(data.tools)) return undefined; - if (this.now() - data.fetchedAt > this.ttlMs) return undefined; + if (this.now() - data.fetchedAt > effectiveTtl(this.ttlMs, typeof data.ttlMs === 'number' ? data.ttlMs : undefined)) return undefined; const features = validFeatures(data.features); - return { tools: data.tools, fetchedAt: data.fetchedAt, rawBytes: text.length, ...(features ? { features } : {}) }; + const protocol = validProtocol(data.protocol); + return { tools: data.tools, fetchedAt: data.fetchedAt, rawBytes: text.length, ...(typeof data.ttlMs === 'number' ? { ttlMs: data.ttlMs } : {}), ...(features ? { features } : {}), ...(protocol ? { protocol } : {}) }; } catch { return undefined; } @@ -136,8 +161,8 @@ export class CatalogCache { * and the report agree (the prebuilt-catalog cache is keyed by it). Returns the length of the * text written, or undefined when nothing could be written. */ - write(server: ServerConfig, tools: McpTool[], options: { fetchedAt?: number; features?: ServerFeatures } = {}): number | undefined { - const { fetchedAt = this.now(), features } = options; + write(server: ServerConfig, tools: McpTool[], options: { fetchedAt?: number; features?: ServerFeatures; protocol?: ProtocolInfo; ttlMs?: number } = {}): number | undefined { + const { fetchedAt = this.now(), features, protocol, ttlMs } = options; try { mkdirSync(this.dir, { recursive: true, mode: 0o700 }); try { @@ -147,8 +172,8 @@ export class CatalogCache { } const file = this.file(server); const tmp = `${file}.${process.pid}.tmp`; - // `features` goes before `tools`: the tools array is the end of the text (see FILE_HEAD). - const body: CacheFile = { version: CACHE_VERSION, fetchedAt, ...(features ? { features } : {}), tools }; + // Everything but `tools` goes before it: the tools array is the end of the text (see FILE_HEAD). + const body: CacheFile = { version: CACHE_VERSION, fetchedAt, ...(ttlMs !== undefined && ttlMs > 0 ? { ttlMs: Math.round(ttlMs) } : {}), ...(protocol ? { protocol } : {}), ...(features ? { features } : {}), tools }; const text = JSON.stringify(body); writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, file); diff --git a/src/client/client.ts b/src/client/client.ts index 7d9d31a..174759a 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,6 +1,6 @@ import type { McpTool } from '../catalog/types'; import type { CredentialResolver, ServerConfig } from '../config/types'; -import type { GetPromptResult, McpPrompt, ReadResourceResult, ResourceListing, ServerFeatures, ToolCallResult } from '../protocol'; +import type { GetPromptResult, McpPrompt, ProtocolInfo, ReadResourceResult, ResourceListing, ServerFeatures, ToolCallResult } from '../protocol'; import { notFoundServer } from './errors'; import { McpSession, type CallOptions, type ListOptions, type SessionOptions } from './session'; @@ -19,6 +19,8 @@ export interface McpClient { getPrompt(server: string, name: string, args: Record, options?: ListOptions): Promise; /** What the server declared at `initialize`, once a session to it exists; undefined if unknown. */ features(server: string): ServerFeatures | undefined; + /** The protocol version the session to the server negotiated and whether it is stateful; undefined while not connected. */ + protocol(server: string): ProtocolInfo | undefined; close(): Promise; } @@ -65,6 +67,10 @@ export class InProcessClient implements McpClient { return this.sessions.get(this.find(server).name)?.features(); } + protocol(server: string): ProtocolInfo | undefined { + return this.sessions.get(this.find(server).name)?.protocol(); + } + /** Close every session and kill stdio children. */ async close(): Promise { const sessions = [...this.sessions.values()]; diff --git a/src/client/errors.ts b/src/client/errors.ts index d78065d..2582203 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -131,9 +131,13 @@ export function internal(message: string): ClientError { /** Turn anything thrown while connecting or calling into a ClientError. */ export function mapError(err: unknown, ctx: ErrorContext): ClientError { if (err instanceof ClientError) return err; + // The SDK wraps whatever went wrong while it probed the protocol version (a refused connection, a login that + // cannot be refreshed) in an `ERA_NEGOTIATION_FAILED`; the cause is the failure to report. + if (sdkCode(err) === 'ERA_NEGOTIATION_FAILED' && (err as { cause?: unknown }).cause instanceof Error) return mapError((err as { cause: Error }).cause, ctx); const { server } = ctx; const provider = server.provider; const loginNext = `via auth login ${provider}`; + if (ctx.phase === 'connect' && server.protocolVersion !== undefined && refusesVersion(err)) return pinRefused(err, server.protocolVersion, ctx); // OAuth layer (matched by name: it lives next to the lazily loaded SDK). if (err instanceof Error && err.name === 'LoginRequiredError') { @@ -180,7 +184,7 @@ export function mapError(err: unknown, ctx: ErrorContext): ClientError { } if (status !== undefined) { - const detail = clean(stripSdkPrefix(errorMessage(err)), ctx); + const detail = clean(upstreamText(err), ctx); return new ClientError({ exitCode: ExitCode.Upstream, message: `${provider} returned HTTP ${status}${detail ? `: ${detail}` : ''}`, @@ -203,6 +207,44 @@ export function mapError(err: unknown, ctx: ErrorContext): ClientError { }); } +// Pinned protocol version (config.md, `protocolVersion`) --------------------------------------- + +/** + * Whether the failure to connect is the server declining the protocol version via asked for: the SDK's + * "did not offer the pinned version" (a probe without a better cause), a counter-offer it does not accept, + * or the server's own -32022 "unsupported protocol version" (a 400 with a JSON-RPC body). + */ +function refusesVersion(err: unknown): boolean { + if (!(err instanceof Error)) return false; + if (sdkCode(err) === 'ERA_NEGOTIATION_FAILED' && !((err as { cause?: unknown }).cause instanceof Error)) return true; + if (httpStatus(err) === 400 && /-32022|unsupported protocol version/i.test(httpBody(err))) return true; + return /protocol version.*(?:not supported|unsupported)|unsupported protocol version/i.test(err.message); +} + +function httpBody(err: Error): string { + const text = (err as { data?: { text?: unknown } }).data?.text; + return typeof text === 'string' ? text : ''; +} + +/** The versions the server said it supports, when its refusal listed them (`error.data.supported`). */ +function offeredVersions(err: Error): string[] { + try { + const supported = (JSON.parse(httpBody(err)) as { error?: { data?: { supported?: unknown } } }).error?.data?.supported; + return Array.isArray(supported) ? supported.filter((v): v is string => typeof v === 'string' && /^\d{4}-\d\d-\d\d$/.test(v)) : []; + } catch { + return []; + } +} + +function pinRefused(err: unknown, pin: string, ctx: ErrorContext): ClientError { + const offered = err instanceof Error ? offeredVersions(err) : []; + return new ClientError({ + exitCode: ExitCode.Upstream, + message: `cannot start ${ctx.server.provider}: it does not support protocol version ${pin}${offered.length > 0 ? ` (it supports ${offered.join(', ')})` : ''}; servers.${ctx.server.name}.protocolVersion pins it`, + next: `remove protocolVersion from servers.${ctx.server.name}, or pin a version the server supports`, + }); +} + /** Failures while spawning the child or opening the HTTP session. */ function startupFailure(err: unknown, ctx: ErrorContext): ClientError { const { server } = ctx; @@ -222,7 +264,7 @@ function startupFailure(err: unknown, ctx: ErrorContext): ClientError { reason = clean(errorMessage(err), ctx); } } else if (status !== undefined) { - const detail = clean(stripSdkPrefix(errorMessage(err)), ctx); + const detail = clean(upstreamText(err), ctx); reason = `HTTP ${status}${detail ? `: ${detail}` : ''}`; } else if (networkCode(err) !== undefined || isFetchFailure(err)) { reason = `cannot reach ${originOf(server.transport.url)} (${networkCode(err) ?? 'network error'})`; @@ -313,6 +355,13 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** What the SDK says about a failed HTTP exchange, plus the response body it kept separately (a probe answered with 5xx carries it only there). */ +function upstreamText(err: unknown): string { + const message = stripSdkPrefix(errorMessage(err)); + const body = err instanceof Error && err.name === 'SdkHttpError' ? (err as { data?: { text?: unknown } }).data?.text : undefined; + return typeof body === 'string' && body !== '' && !message.includes(body) ? `${message}: ${body}` : message; +} + function stripSdkPrefix(message: string): string { return message .replace(/^Error POSTing to endpoint:\s*/, '') diff --git a/src/client/session.ts b/src/client/session.ts index 5d07ab7..ab34a17 100644 --- a/src/client/session.ts +++ b/src/client/session.ts @@ -1,14 +1,15 @@ // One MCP session per server over stdio or Streamable HTTP. The SDK is imported lazily, on the first // connection, so nothing here costs CLI cold start (implementation.md, "启动速度"). -import type { Client, ClientOptions, FetchLike, StandardSchemaV1, Transport } from '@modelcontextprotocol/client'; +import type { Client, ClientOptions, FetchLike, McpSubscription, StandardSchemaV1, SubscriptionFilter, Transport } from '@modelcontextprotocol/client'; import pkg from '../../package.json'; import type { CredentialResolver, ServerConfig } from '../config/types'; -import type { GetPromptResult, McpPrompt, ReadResourceResult, ResourceListing, ServerFeatures, ToolCallResult } from '../protocol'; +import type { GetPromptResult, McpPrompt, ProtocolInfo, ReadResourceResult, ResourceListing, ServerFeatures, ToolCallResult } from '../protocol'; import type { McpTool } from '../catalog/types'; import { createRedactor } from '../render/redact'; import { resolveConnection, type ResolvedConnection } from './credentials'; import { ClientError, internal, mapError, noFeature, notFoundServer, resourceNotFound, timedOut, untrustedServer, type Phase } from './errors'; import { prepareStdioCommand } from '../platform/command'; +import { isModernProtocolVersion } from '../protocol-versions'; import { Limiter } from './limiter'; import { resolveRoots } from './roots'; import type { ViaOAuthProvider } from './oauth/provider'; @@ -27,6 +28,12 @@ export interface CallOptions { export interface ListOptions { timeoutMs?: number; + /** + * The server's own cache hint for what is being listed: `ttlMs` of a stateless-protocol `tools/list` + * result, called only for a positive value (a server that sends `0` or nothing gave no usable hint: + * discovery.md, "缓存"). + */ + onTtl?: (ttlMs: number) => void; /** Something the person should know about, caused by this operation (see `SessionOptions.onWarning`). */ onWarning?: (message: string) => void; } @@ -37,8 +44,10 @@ export interface SessionOptions { /** Clock for token expiry; tests inject one. */ now?: () => number; /** - * The server sent `notifications/tools/list_changed` (the daemon refreshes its catalog on it). - * Runs on the connection that received it; must not throw. + * The tool list may have changed: the server sent `notifications/tools/list_changed` (a push on the old + * protocol, an event on the `subscriptions/listen` stream of the stateless one), or that stream dropped + * and was opened again, so events may have been missed. The daemon refreshes its catalog on it. Runs on + * the connection that received it; must not throw. */ onToolsChanged?: () => void; /** @@ -47,6 +56,11 @@ export interface SessionOptions { * given once per session. */ onWarning?: (message: string) => void; + /** + * What is known about the protocol this server spoke before this session connected (the catalog cache + * remembers it). Only a stdio server is asked: see `McpSession.open`. + */ + knownProtocol?: (server: ServerConfig) => ProtocolInfo | undefined; /** Base of relative `roots` entries and the meaning of `~`: the CLI's working directory and home (config.md, `roots`). */ roots?: { cwd: string; home: string }; } @@ -64,10 +78,45 @@ const MAX_LIST_PAGES = 1000; const STDERR_KEEP = 4096; /** Ceiling for the best-effort HTTP session termination on close. */ const TERMINATE_WAIT_MS = 1000; +/** + * How long the `server/discover` probe of a stdio server may stay unanswered before it counts as a server + * of the old protocol: some never answer a request that comes before `initialize`, and waiting out the + * whole connect timeout would make every such server slow to start. HTTP has no such limit: silence there + * is an outage, not an answer. + */ +const STDIO_PROBE_TIMEOUT_MS = 3000; +/** Delays between attempts to open the `subscriptions/listen` stream again after it dropped. */ +const RELISTEN_FIRST_MS = 1000; +const RELISTEN_MAX_MS = 30_000; + +/** + * How the connection settles on a protocol version (config.md, `protocolVersion`; daemon.md, "协议版本与无状态连接"). + * Unpinned: newest first, automatically. The probe is `server/discover`; a server that answers it speaks the + * stateless protocol, any other answer falls back to the `initialize` handshake on the same connection. A + * stdio probe runs on a short-lived copy of the server process, so the real one starts once the protocol is + * known (the SDK's choice; a server that dies on a request before `initialize` is then simply an old server). + * Pinned: that version or an error. A stateless pin must be offered by the probe; an older pin is the + * plain handshake that offers nothing else and rejects another counter-offer. + */ +function negotiation(pin: string | undefined, stdio: boolean): Pick { + const probe = stdio ? { timeoutMs: STDIO_PROBE_TIMEOUT_MS } : {}; + if (pin === undefined) return { versionNegotiation: { mode: 'auto', probe } }; + if (isModernProtocolVersion(pin)) return { versionNegotiation: { mode: { pin }, probe } }; + return { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: [pin] }; +} + +/** A connection being made; `close()` abandons it. */ +interface Attempt { + aborted: boolean; + controller: AbortController; + client?: Client; +} interface Handle { client: Client; closed: boolean; + /** What the handshake (or the probe) settled on. */ + protocol: ProtocolInfo; stderr: () => string; /** Process id of the stdio child. */ pid?: () => number | null; @@ -113,6 +162,8 @@ export class McpSession { /** Where warnings go: the operation that started last (the one that most likely caused it). */ private warnSink?: (message: string) => void; private warnedSave = false; + /** What the last connection of this session negotiated (kept across reconnects). */ + private lastProtocol?: ProtocolInfo; /** Abandons the connection being made (`close()` while connecting). */ private abortConnect?: () => void; @@ -135,10 +186,18 @@ export class McpSession { return this.handle.pid?.() ?? undefined; } + /** The negotiated protocol version and whether the connection is stateful; undefined while not connected. */ + protocol(): ProtocolInfo | undefined { + return this.handle && !this.handle.closed ? this.handle.protocol : undefined; + } + /** `tools/list`, following `nextCursor` until the server stops returning one. */ listTools(options: ListOptions = {}): Promise { return this.operate(options.timeoutMs ?? this.defaultTimeoutMs(), options.onWarning, (handle, left) => - this.pages(handle, 'tools/list', 'tools', left, (e): e is McpTool => hasString(e, 'name')), + this.pages(handle, 'tools/list', 'tools', left, (e): e is McpTool => hasString(e, 'name'), (page) => { + const ttl = page.ttlMs; + if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) options.onTtl?.(ttl); + }), ); } @@ -210,7 +269,13 @@ export class McpSession { return this.request(handle, 'request', timeoutMs, async () => { const params = { name: rawName, arguments: args ?? {} }; const send = { timeout: left(), ...(options.onProgress ? { onprogress: options.onProgress } : {}) }; - const raw: unknown = await handle.client.request({ method: 'tools/call', params }, RAW, send); + // The stateless protocol has the SDK mirror tool arguments the tool declared with `x-mcp-header` + // into request headers (a server refuses the call without them); on the old protocol the result + // is taken as the server sent it. + const raw: unknown = + handle.client.getProtocolEra() === 'modern' + ? await handle.client.callTool(params, send) + : await handle.client.request({ method: 'tools/call', params }, RAW, send); if (typeof raw !== 'object' || raw === null) throw new Error('tools/call returned a non-object result'); return raw as ToolCallResult; }); @@ -339,9 +404,10 @@ export class McpSession { } private connect(): Promise { - const attempt: { aborted: boolean; client?: Client } = { aborted: false }; + const attempt: Attempt = { aborted: false, controller: new AbortController() }; const abort = (): void => { attempt.aborted = true; + attempt.controller.abort(); // ends a protocol probe in progress (the SDK would otherwise finish it first) void attempt.client?.close().catch(() => undefined); // kills a half-started child }; this.abortConnect = abort; @@ -350,7 +416,7 @@ export class McpSession { }); } - private async open(attempt: { aborted: boolean; client?: Client }): Promise { + private async open(attempt: Attempt): Promise { const { server } = this; // Checks that must pass before anything is spawned or sent. if (server.disabled) throw notFoundServer(server.name, []); @@ -367,6 +433,24 @@ export class McpSession { throw mapError(err, { server, phase: 'connect', timeoutMs, redact: this.redactor() }); } + // Probing a stdio server starts it twice (the SDK probes a copy), which is slow for a server that + // takes seconds to start and wrong for one with side effects at startup. So a stdio server that was + // last seen speaking the old protocol is connected with the plain handshake and no probe; if that + // fails for a reason other than a timeout, it is tried once more with the probe (it may have moved on). + const known = resolved.type === 'stdio' && server.protocolVersion === undefined ? (this.lastProtocol ?? this.options.knownProtocol?.(server)) : undefined; + const skipProbe = known !== undefined && known.version !== '' && !isModernProtocolVersion(known.version); + try { + return await this.establish(attempt, resolved, authFetch, skipProbe); + } catch (err) { + if (!skipProbe || attempt.aborted || !(err instanceof ClientError) || err.retryable) throw err; + return this.establish(attempt, resolved, authFetch, false); + } + } + + /** Make the client and its transport and connect them: one try, `legacy` meaning "plain handshake, no probe". */ + private async establish(attempt: Attempt, resolved: ResolvedConnection, authFetch: FetchLike | undefined, legacy: boolean): Promise { + const { server } = this; + const timeoutMs = this.defaultTimeoutMs(); let stderrBuf = ''; let client: Client | undefined; let handle: Handle | undefined; @@ -381,6 +465,7 @@ export class McpSession { { capabilities: roots.length > 0 ? { roots: { listChanged: false } } : {}, jsonSchemaValidator: LENIENT_VALIDATOR, + ...negotiation(server.protocolVersion, resolved.type === 'stdio'), // Only a signal: via lists again itself (the daemon's catalog refresh), not the SDK. ...(onToolsChanged ? { listChanged: { tools: { autoRefresh: false, debounceMs: 0, onChanged: () => onToolsChanged() } } } : {}), }, @@ -390,6 +475,7 @@ export class McpSession { let transport: Transport; let terminate: (() => Promise) | undefined; let pid: (() => number | null) | undefined; + let sessionId: (() => string | undefined) | undefined; if (resolved.type === 'stdio') { const { StdioClientTransport } = await import('@modelcontextprotocol/client/stdio'); @@ -420,16 +506,22 @@ export class McpSession { const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); const http = new StreamableHTTPClientTransport(url, { requestInit: { headers: resolved.headers }, ...(authFetch ? { fetch: authFetch } : {}) }); terminate = () => http.terminateSession(); + sessionId = () => http.sessionId; transport = http; } - const created: Handle = { client, closed: false, stderr: () => stderrBuf, ...(pid ? { pid } : {}), ...(terminate ? { terminate } : {}) }; + const created: Handle = { client, closed: false, protocol: { version: '', stateful: false }, stderr: () => stderrBuf, ...(pid ? { pid } : {}), ...(terminate ? { terminate } : {}) }; handle = created; client.onclose = () => { created.closed = true; }; if (attempt.aborted) throw new Error('the session was closed while connecting'); - await client.connect(transport, { timeout: timeoutMs }); + await client.connect(transport, { timeout: timeoutMs, signal: attempt.controller.signal, ...(legacy ? { prior: { kind: 'legacy' as const } } : {}) }); + const modern = client.getProtocolEra() === 'modern'; + // A stdio process is a session of its own; an HTTP server of the old protocol has one if it handed out an id. + created.protocol = { version: client.getNegotiatedProtocolVersion() ?? '', stateful: !modern && (resolved.type === 'stdio' || sessionId?.() !== undefined) }; + if (onToolsChanged) this.watchListen(created, client.autoOpenedSubscription); + this.lastProtocol = created.protocol; return created; } catch (err) { const mapped = mapError(err, { server, phase: 'connect', timeoutMs, redact: this.redactor(), stderrTail: stderrBuf }); @@ -439,6 +531,39 @@ export class McpSession { } } + /** + * On the stateless protocol list changes arrive on a `subscriptions/listen` stream the SDK opened at + * connect. The SDK never opens it again after a drop, and without it the catalog would silently stop + * following the server, so a stream that ends without the server saying goodbye is opened again with a + * growing delay (1 s to 30 s), and the daemon is told to refresh: events may have been missed meanwhile. + */ + private watchListen(handle: Handle, subscription: McpSubscription | undefined): void { + if (!subscription) return; + void subscription.closed.then((reason) => { + if (reason === 'remote' && !handle.closed) void this.relisten(handle, subscription.honoredFilter); + }); + } + + private async relisten(handle: Handle, filter: SubscriptionFilter): Promise { + let delay = RELISTEN_FIRST_MS; + while (!handle.closed) { + await new Promise((resolve) => setTimeout(resolve, delay).unref()); + if (handle.closed) return; + try { + const next = await handle.client.listen(filter, { timeout: this.defaultTimeoutMs() }); + if (handle.closed) { + await next.close().catch(() => undefined); + return; + } + this.watchListen(handle, next); + this.options.onToolsChanged?.(); + return; + } catch { + delay = Math.min(delay * 2, RELISTEN_MAX_MS); + } + } + } + /** * A fetch that attaches the OAuth access token and refreshes it when due. The provider outlives * reconnects: a long-lived session (a daemon's) keeps refreshing through it. diff --git a/src/config/layer.ts b/src/config/layer.ts index b88a58b..16620a8 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -5,6 +5,7 @@ import type { ConfigDiagnostic, DiagnosticCode, DiagnosticLevel } from './diagnostics'; import { isPlainObject, ownEntries, type ServerDef } from './model'; import { parseCredentialRef } from './credentials'; +import { isProtocolVersion, PROTOCOL_VERSIONS } from '../protocol-versions'; import type { AuthConfig, SemanticSearchConfig } from './types'; /** `sources` as written: `auto`, `false`, or a list of framework ids and file paths. */ @@ -26,7 +27,7 @@ export type SemanticLayer = Pick Partial>; const TOP_LEVEL = new Set(['sources', 'servers', 'deny', 'output', 'daemon', 'search']); -const SERVER_FIELDS = new Set(['command', 'args', 'env', 'cwd', 'url', 'headers', 'auth', 'alias', 'rename', 'disabled', 'exclusive', 'confirm', 'timeout', 'concurrency', 'budget', 'noise', 'roots']); +const SERVER_FIELDS = new Set(['command', 'args', 'env', 'cwd', 'url', 'headers', 'auth', 'alias', 'rename', 'disabled', 'exclusive', 'confirm', 'timeout', 'concurrency', 'budget', 'noise', 'roots', 'protocolVersion']); const typeName = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'list' : typeof v); @@ -286,6 +287,10 @@ function serverDef(r: Reporter, name: string, v: unknown): ServerDef | undefined case 'timeout': def.timeout = num(r, path, value, 'number', name); break; case 'concurrency': def.concurrency = num(r, path, value, 'integer', name); break; case 'budget': def.budget = num(r, path, value, 'integer', name); break; + case 'protocolVersion': + if (typeof value === 'string' && isProtocolVersion(value)) def.protocolVersion = value; + else r.invalid(path, `one of ${PROTOCOL_VERSIONS.join(', ')}`, name); + break; case 'auth': def.auth = auth(r, path, value, name); break; case 'confirm': if (value === 'destructive' || value === 'never') def.confirm = value; diff --git a/src/config/load.ts b/src/config/load.ts index 40a2133..aa49a93 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -379,6 +379,7 @@ function finalize( trusted, }; if (d.budget !== undefined) server.budget = d.budget; + if (d.protocolVersion !== undefined) server.protocolVersion = d.protocolVersion; if (d.roots !== undefined && d.roots.length > 0) { server.roots = [...d.roots]; if (transport.type === 'http') { diff --git a/src/config/merge.ts b/src/config/merge.ts index 427349e..44753d0 100644 --- a/src/config/merge.ts +++ b/src/config/merge.ts @@ -63,7 +63,7 @@ export function applyDef(acc: Accumulator, def: ServerDef, origin: ConfigOrigin) if (def.noise !== undefined) d.noise = [...new Set([...(d.noise ?? []), ...def.noise])]; if (def.roots !== undefined) d.roots = [...def.roots]; if (def.auth !== undefined) d.auth = def.auth as AuthConfig; - for (const key of ['alias', 'disabled', 'exclusive', 'confirm', 'timeout', 'concurrency', 'budget'] as const) { + for (const key of ['alias', 'disabled', 'exclusive', 'confirm', 'timeout', 'concurrency', 'budget', 'protocolVersion'] as const) { if (def[key] !== undefined) (d as Record)[key] = def[key]; } diff --git a/src/config/model.ts b/src/config/model.ts index e528f00..3207518 100644 --- a/src/config/model.ts +++ b/src/config/model.ts @@ -22,6 +22,7 @@ export interface ServerDef { budget?: number; noise?: string[]; roots?: string[]; + protocolVersion?: string; } /** Server names are user data: build records without ever assigning `__proto__`. */ diff --git a/src/config/types.ts b/src/config/types.ts index 49e87d0..7396bf4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -79,6 +79,11 @@ export interface ServerConfig { /** Concurrent requests per server; default 4. */ concurrency: number; budget?: number; + /** + * Pins the MCP protocol version (config.md, `protocolVersion`): a server that does not support it is + * an error, not a fallback. Absent: newest first, falling back automatically. + */ + protocolVersion?: string; noise: string[]; /** * Directories the server may work in, as written (paths or `file://` URIs; config.md, `roots`). diff --git a/src/daemon/backend.ts b/src/daemon/backend.ts index 181543f..369ae17 100644 --- a/src/daemon/backend.ts +++ b/src/daemon/backend.ts @@ -15,6 +15,7 @@ import type { GetPromptResult, ListResult, McpPrompt, + ProtocolInfo, ReadResourceResult, RequestBody, ResourceListing, @@ -74,11 +75,13 @@ export class DaemonBackend implements Backend { // if anyone asks for them, are parsed from there instead of coming over the socket. if (!disk || disk.fetchedAt !== result.fetchedAt) return this.listing(server, { timeoutMs, refresh: true }); const features = result.features ?? disk.features; + const protocol = result.protocol ?? disk.protocol; return { fetchedAt: disk.fetchedAt, fromCache: true, rawBytes: disk.rawBytes, ...(features ? { features } : {}), + ...(protocol ? { protocol } : {}), get tools() { return disk.tools; }, @@ -151,6 +154,11 @@ export class DaemonBackend implements Backend { return undefined; } + /** Likewise `protocol`: it comes with the listing. */ + protocol(): ProtocolInfo | undefined { + return undefined; + } + /** * Validation in the daemon, which keeps compiled schemas. Any trouble reaching it falls back to * ajv in this process: validation has no side effects, so nothing is lost but the time it costs. diff --git a/src/daemon/sessions.ts b/src/daemon/sessions.ts index f8288ab..dc2e9a5 100644 --- a/src/daemon/sessions.ts +++ b/src/daemon/sessions.ts @@ -4,10 +4,10 @@ // the error mapping; this layer adds lifetime (idle stop, exclusive servers), the catalog and its // refresh on `list_changed`, and redaction of what goes back over the socket. import type { McpTool } from '../catalog/types'; -import { CATALOG_TTL_MS, CatalogCache } from '../cli/catalog-cache'; +import { CATALOG_TTL_MS, CatalogCache, effectiveTtl } from '../cli/catalog-cache'; import { ClientError, McpSession, mapError, resolveConnection, untrustedServer, type CallOptions } from '../client'; import type { CredentialResolver, ServerConfig } from '../config/types'; -import type { GetPromptResult, ListResult, McpPrompt, ReadResourceResult, ResourceListing, ServerFeatures, ServerTarget, SessionStatus, ToolCallResult } from '../protocol'; +import type { GetPromptResult, ListResult, McpPrompt, ProtocolInfo, ReadResourceResult, ResourceListing, ServerFeatures, ServerTarget, SessionStatus, ToolCallResult } from '../protocol'; import { ExitCode } from '../render/exit-codes'; import { lazyRedactor, redactProtocol, type Redactor } from '../render/redact'; import type { OAuthStore } from '../client/oauth/store'; @@ -73,6 +73,10 @@ interface Entry { toolCount?: number; /** What the server declared at `initialize`, once known. */ features?: ServerFeatures; + /** What the last connection negotiated, once known. */ + protocol?: ProtocolInfo; + /** The server's own freshness hint for the list (stateless protocol); the shorter of it and 24 hours applies. */ + ttlMs?: number; calls: number; active: number; lastUsed: number; @@ -130,8 +134,8 @@ export class SessionManager { try { if (!options.refresh) { const now = this.options.now(); - if (entry.fetchedAt !== undefined && now - entry.fetchedAt <= CATALOG_TTL_MS) { - const features = entry.features ? { features: entry.features } : {}; + if (entry.fetchedAt !== undefined && now - entry.fetchedAt <= effectiveTtl(CATALOG_TTL_MS, entry.ttlMs)) { + const features = { ...(entry.features ? { features: entry.features } : {}), ...(entry.protocol ? { protocol: entry.protocol } : {}) }; if (options.have === entry.fetchedAt) return { unchanged: true, fetchedAt: entry.fetchedAt, fromCache: true, ...features }; if (entry.tools) return { tools: entry.tools, fetchedAt: entry.fetchedAt, fromCache: true, ...features }; } @@ -140,7 +144,10 @@ export class SessionManager { if (hit) { entry.fetchedAt = hit.fetchedAt; if (hit.features) entry.features = hit.features; - const features = entry.features ? { features: entry.features } : {}; + if (hit.protocol) entry.protocol = hit.protocol; + if (hit.ttlMs !== undefined) entry.ttlMs = hit.ttlMs; + else delete entry.ttlMs; + const features = { ...(entry.features ? { features: entry.features } : {}), ...(entry.protocol ? { protocol: entry.protocol } : {}) }; if (options.have === hit.fetchedAt) return { unchanged: true, fetchedAt: hit.fetchedAt, fromCache: true, ...features }; const tools = hit.tools; entry.toolCount = tools.length; @@ -150,9 +157,10 @@ export class SessionManager { delete entry.tools; delete entry.fetchedAt; } - const tools = await entry.session.listTools({ timeoutMs: options.timeoutMs, ...(options.onWarning ? { onWarning: options.onWarning } : {}) }); - this.store(entry, tools); - return { tools, fetchedAt: entry.fetchedAt!, fromCache: false, ...(entry.features ? { features: entry.features } : {}) }; + let ttlMs: number | undefined; + const tools = await entry.session.listTools({ timeoutMs: options.timeoutMs, onTtl: (ms) => void (ttlMs = ms), ...(options.onWarning ? { onWarning: options.onWarning } : {}) }); + this.store(entry, tools, ttlMs); + return { tools, fetchedAt: entry.fetchedAt!, fromCache: false, ...(entry.features ? { features: entry.features } : {}), ...(entry.protocol ? { protocol: entry.protocol } : {}) }; } finally { this.end(entry); } @@ -216,6 +224,8 @@ export class SessionManager { }; const pid = e.session.pid; if (pid !== undefined) status.pid = pid; + const protocol = e.session.protocol(); + if (protocol) status.protocol = protocol; const count = e.toolCount ?? e.tools?.length ?? (e.fetchedAt !== undefined ? this.cache.read(e.original)?.tools.length : undefined); if (count !== undefined) status.tools = count; return status; @@ -305,6 +315,7 @@ export class SessionManager { // `server.roots` is already absolute here (`effectiveServer`), so no base directory is needed. entry.session = new McpSession(server, this.options.resolver, { onToolsChanged: () => this.toolsChanged(entry), + knownProtocol: () => this.cache.read(entry.original)?.protocol, now: this.options.now, ...(this.oauthStore ? { oauthStore: this.oauthStore } : {}), roots: { cwd: target.cwd, home: this.options.home }, @@ -359,12 +370,20 @@ export class SessionManager { entry.idleTimer.unref(); } - private store(entry: Entry, tools: McpTool[]): void { + private store(entry: Entry, tools: McpTool[], ttlMs?: number): void { entry.fetchedAt = this.options.now(); entry.toolCount = tools.length; entry.features = entry.session.features() ?? entry.features; + entry.protocol = entry.session.protocol() ?? entry.protocol; + if (ttlMs !== undefined) entry.ttlMs = ttlMs; + else delete entry.ttlMs; // Keep the list in memory only if it could not be written to disk. - const written = this.cache.write(entry.original, tools, { fetchedAt: entry.fetchedAt, ...(entry.features ? { features: entry.features } : {}) }); + const written = this.cache.write(entry.original, tools, { + fetchedAt: entry.fetchedAt, + ...(entry.features ? { features: entry.features } : {}), + ...(entry.protocol ? { protocol: entry.protocol } : {}), + ...(ttlMs !== undefined ? { ttlMs } : {}), + }); if (written === undefined) entry.tools = tools; else delete entry.tools; } @@ -388,8 +407,9 @@ export class SessionManager { entry.refreshing = true; this.begin(entry); try { - const tools = await entry.session.listTools({ timeoutMs: entry.server.timeout * 1000 }); - this.store(entry, tools); + let ttlMs: number | undefined; + const tools = await entry.session.listTools({ timeoutMs: entry.server.timeout * 1000, onTtl: (ms) => void (ttlMs = ms) }); + this.store(entry, tools, ttlMs); } catch { // The copy is known to be out of date and cannot be renewed now: forget it so the next list asks the server. delete entry.tools; diff --git a/src/daemon/target.ts b/src/daemon/target.ts index 0003dd9..ee55897 100644 --- a/src/daemon/target.ts +++ b/src/daemon/target.ts @@ -75,6 +75,8 @@ export function sessionKey(server: ServerConfig): string { exclusive: server.exclusive, concurrency: server.concurrency, roots: server.roots ?? [], + // Only when pinned: a session key from before the field existed stays what it was. + ...(server.protocolVersion !== undefined ? { protocolVersion: server.protocolVersion } : {}), }; return sha256Hex(JSON.stringify(identity)).slice(0, 24); } diff --git a/src/protocol.ts b/src/protocol.ts index cd481b2..6d63b3a 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -133,14 +133,21 @@ export type Message = Response | Event | Stale; /** What a server declared at `initialize` that via can use besides tools (discovery.md, "浏览视图"). */ export type ServerFeatures = { resources: boolean; prompts: boolean }; +/** + * What a connection negotiated (daemon.md, "协议版本与无状态连接"). `stateful` is false for the stateless + * protocol (`2026-07-28`: no handshake, no server-side session) and for an old-protocol HTTP server that + * assigned no session id; a stdio process, and an HTTP server that assigned a session id, are stateful. + */ +export type ProtocolInfo = { version: string; stateful: boolean }; + /** * `result` of `list`. `features` is absent when it is not known (a cache written before it was * recorded). `unchanged` replaces `tools` when the request's `have` is the `fetchedAt` of the list * the daemon would have sent; the credentials and trust checks ran all the same. */ export type ListResult = ListedTools | ListUnchanged; -export type ListedTools = { tools: McpTool[]; fetchedAt: number; fromCache: boolean; features?: ServerFeatures }; -export type ListUnchanged = { unchanged: true; fetchedAt: number; fromCache: true; features?: ServerFeatures }; +export type ListedTools = { tools: McpTool[]; fetchedAt: number; fromCache: boolean; features?: ServerFeatures; protocol?: ProtocolInfo }; +export type ListUnchanged = { unchanged: true; fetchedAt: number; fromCache: true; features?: ServerFeatures; protocol?: ProtocolInfo }; /** `result` of `validate`. `ok: false` means the schema cannot be compiled. */ export type ValidateResult = @@ -176,6 +183,8 @@ export type SessionStatus = { pid?: number; /** Tools in the daemon's copy of the catalog. */ tools?: number; + /** What the live connection negotiated; absent while not connected. */ + protocol?: ProtocolInfo; calls: number; /** Requests running right now. */ active: number; diff --git a/test/cli/cache.test.ts b/test/cli/cache.test.ts index 995df3f..e1f56de 100644 --- a/test/cli/cache.test.ts +++ b/test/cli/cache.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { cacheKey, CATALOG_TTL_MS } from '../../src/cli/catalog-cache'; import type { ServerConfig } from '../../src/config'; import { httpServer, stdioServer } from '../helpers/client'; +import { defaultGeneration } from '../helpers/generation'; import { CLI_STDIO_SERVER, FAKE_TOKEN, ghConfig, makeSandbox, startFixtureServer, useCleanup } from './helpers'; @@ -93,19 +94,71 @@ describe('catalog cache', () => { expect(r.stdout).toBe('authorization: Bearer ***\n'); }); + // Probing the protocol version starts a stdio server twice (the SDK probes a copy), so the very first + // connection of a server with no cached catalog may spawn two processes. it('does not start a stdio server again to browse a cached catalog', async () => { const sb = makeSandbox(); const log = sb.write('spawns.log', ''); sb.userConfig(`sources: false\nservers:\n job:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(CLI_STDIO_SERVER)}]\n env: { CLI_FIXTURE_LOG: ${JSON.stringify(log)} }\n`); const spawns = (): number => readFileSync(log, 'utf8').split('\n').filter(Boolean).length; expect((await sb.via(['job'])).code).toBe(0); - expect(spawns()).toBe(1); + const first = spawns(); + expect(first).toBeGreaterThanOrEqual(1); + expect(first).toBeLessThanOrEqual(2); await sb.via(['job', 'issue']); await sb.via([]); await sb.via(['find', 'issue']); - expect(spawns()).toBe(1); + expect(spawns()).toBe(first); await sb.via(['sync']); - expect(spawns()).toBe(2); + expect(spawns()).toBeGreaterThan(first); + }); + + // The cache remembers the protocol the server spoke; a server of the old protocol is then connected + // with the plain handshake, one process, no probe (a server of the stateless one is probed each time). + it.runIf(defaultGeneration() === 'legacy')('starts a stdio server of the old protocol once when its catalog is cached', async () => { + const sb = makeSandbox(); + const log = sb.write('spawns.log', ''); + sb.userConfig(`sources: false\nservers:\n job:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(CLI_STDIO_SERVER)}]\n env: { CLI_FIXTURE_LOG: ${JSON.stringify(log)} }\n`); + const spawns = (): number => readFileSync(log, 'utf8').split('\n').filter(Boolean).length; + expect((await sb.via(['job'])).code).toBe(0); + const first = spawns(); + await sb.via(['sync']); + expect(spawns()).toBe(first + 1); + await sb.via(['job', 'echo-text', '--message', 'x']); + expect(spawns()).toBe(first + 2); + }); +}); + +describe('the cache hint of a stateless server', () => { + async function hinted(ttlMs: number | undefined) { + const srv = await startFixtureServer({ token: FAKE_TOKEN, protocol: 'modern', ...(ttlMs !== undefined ? { ttlMs } : {}) }); + useCleanup(() => srv.close()); + const sb = makeSandbox(); + sb.userConfig(ghConfig(srv.url)); + return { srv, sb }; + } + + it('shortens the 24 hours: the catalog is fetched again once the hint has run out', async () => { + const { srv, sb } = await hinted(60_000); + await sb.via(['gh'], { now: T0 }); + await sb.via(['gh'], { now: T0 + 59_000 }); + expect(srv.state.listCalls).toBe(1); + await sb.via(['gh'], { now: T0 + 61_000 }); + expect(srv.state.listCalls).toBe(2); + }); + + it('does not lengthen it', async () => { + const { srv, sb } = await hinted(10 * CATALOG_TTL_MS); + await sb.via(['gh'], { now: T0 }); + await sb.via(['gh'], { now: T0 + CATALOG_TTL_MS + 1000 }); + expect(srv.state.listCalls).toBe(2); + }); + + it.each([0, undefined])('is no hint at all when it is %s: the 24 hours stay', async (ttl) => { + const { srv, sb } = await hinted(ttl); + await sb.via(['gh'], { now: T0 }); + await sb.via(['gh'], { now: T0 + 3600_000 }); + expect(srv.state.listCalls).toBe(1); }); }); diff --git a/test/cli/helpers.ts b/test/cli/helpers.ts index 136507e..7a1c176 100644 --- a/test/cli/helpers.ts +++ b/test/cli/helpers.ts @@ -5,12 +5,13 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { createServer, type IncomingHttpHeaders, type Server as HttpServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { afterEach } from 'vitest'; import { run } from '../../src/cli/run'; import { createCliServer, createCliState, type CliState } from '../fixtures/mcp/cli-server.mjs'; +import { modernEndpoint } from '../fixtures/mcp/modern-http.mjs'; +import { defaultGeneration, fixtureScript, type Generation } from '../helpers/generation'; /** * Variables a child process needs in order to start properly on Windows (`SystemRoot` above all); @@ -39,23 +40,46 @@ export function homeEnv(home: string): Record { }; } -export const CLI_STDIO_SERVER = fileURLToPath(new URL('../fixtures/mcp/cli-stdio-server.mjs', import.meta.url)); +/** The CLI stdio fixture in the default protocol generation (helpers/generation.ts). */ +export const CLI_STDIO_SERVER = fixtureScript('cli-stdio-server', defaultGeneration()); /** Obviously fake; the credential scan must never find it in a snapshot. */ export const FAKE_TOKEN = 'fake-gh-token-for-cli-tests-0001'; +export type { Generation }; + export interface FixtureServer { url: string; state: CliState; requests: IncomingHttpHeaders[]; + /** Stateless servers: publish `notifications/tools/list_changed` to every open `subscriptions/listen` stream. */ + notifyToolsChanged(): void; close(): Promise; } /** Streamable HTTP fixture; requests need `Authorization: Bearer ` when `token` is set. */ -export async function startFixtureServer(options: { token?: string; features?: Array<'resources' | 'prompts' | 'roots'> } = {}): Promise { +export async function startFixtureServer(options: { token?: string; features?: Array<'resources' | 'prompts' | 'roots'>; protocol?: Generation; ttlMs?: number } = {}): Promise { const state = createCliState(); - const result: FixtureServer = { url: '', state, requests: [], close: async () => undefined }; + const generation = options.protocol ?? defaultGeneration(); + const result: FixtureServer = { url: '', state, requests: [], notifyToolsChanged: () => undefined, close: async () => undefined }; + // The stateless endpoint builds a server per request itself; the credential the request carried reaches it through `authorization`. + let currentAuthorization: string | undefined; + const modern = + generation === 'modern' + ? modernEndpoint((notifier) => createCliServer({ state, flavor: 'modern', notifier, ...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}), ...(options.features ? { features: options.features } : {}), ...(currentAuthorization ? { authorization: currentAuthorization } : {}) })) + : undefined; + if (modern) result.notifyToolsChanged = () => modern.notifier.toolsChanged(); const http: HttpServer = createServer((req, res) => { + if (modern) { + if (options.token !== undefined && req.headers.authorization !== `Bearer ${options.token}`) { + res.writeHead(401, { 'content-type': 'text/plain' }).end('unauthorized'); + return; + } + result.requests.push(req.headers); + currentAuthorization = req.headers.authorization; + void modern.handle(req, res); + return; + } const chunks: Buffer[] = []; req.on('data', (c: Buffer) => chunks.push(c)); req.on('end', () => { @@ -83,11 +107,13 @@ export async function startFixtureServer(options: { token?: string; features?: A }); await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); result.url = `http://127.0.0.1:${(http.address() as AddressInfo).port}/mcp`; - result.close = () => - new Promise((resolve) => { + result.close = async () => { + await modern?.close().catch(() => undefined); + await new Promise((resolve) => { http.closeAllConnections(); http.close(() => resolve()); }); + }; return result; } diff --git a/test/client/client.test.ts b/test/client/client.test.ts index 0518505..ff34562 100644 --- a/test/client/client.test.ts +++ b/test/client/client.test.ts @@ -192,7 +192,7 @@ describe('errors', () => { // Closing does not wait for the abandoned handshake either: the child is killed. const closing = Date.now(); await client.close(); - expect(Date.now() - closing).toBeLessThan(3000); + expect(Date.now() - closing).toBeLessThan(8000); // at most the stdio probe timeout (5 s), never the 60 s connect timeout }); it('a request queued behind a long call (concurrency) fails within its own timeout', async () => { diff --git a/test/client/protocol.test.ts b/test/client/protocol.test.ts new file mode 100644 index 0000000..92d22bc --- /dev/null +++ b/test/client/protocol.test.ts @@ -0,0 +1,228 @@ +// Both protocol generations through the same client (docs/spec/daemon.md, "协议版本与无状态连接"): the +// connection tries the stateless protocol first (`server/discover`) and falls back to the `initialize` +// handshake, and everything above it (listing, calls, errors, cache hints, list changes) behaves the same. +import { afterEach, describe, expect, it } from 'vitest'; +import { ClientError, McpSession } from '../../src/client'; +import { TOOLS } from '../fixtures/mcp/mock-server.mjs'; +import { FakeResolver, httpServer, startHttpServer, stdioServer, type Generation, type MockHttp } from '../helpers/client'; + +const sessions: McpSession[] = []; +const servers: MockHttp[] = []; + +function session(server: ReturnType, options: ConstructorParameters[2] = {}): McpSession { + const s = new McpSession(server, new FakeResolver(), options); + sessions.push(s); + return s; +} + +afterEach(async () => { + await Promise.all(sessions.splice(0).map((s) => s.close())); + await Promise.all(servers.splice(0).map((s) => s.close())); +}); + +const textOf = (result: { content?: Array<{ type: string; text?: string }> }): string | undefined => result.content?.[0]?.text; + +async function waitUntil(condition: () => boolean, ms = 5000): Promise { + const deadline = Date.now() + ms; + while (!condition() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25)); +} + +const EXPECTED: Record = { + // The 1.x SDK server answers the highest revision it knows; an HTTP fixture without session ids is stateless in practice. + legacy: { version: '2025-11-25', stateful: { http: false, stdio: true } }, + modern: { version: '2026-07-28', stateful: { http: false, stdio: false } }, +}; + +describe.each(['legacy', 'modern'] as const)('%s server', (generation) => { + const expected = EXPECTED[generation]; + + describe.each(['http', 'stdio'] as const)('over %s', (transport) => { + async function connect(options: { pageSize?: number } = {}): Promise { + if (transport === 'stdio') { + return session(stdioServer({ protocol: generation, ...(options.pageSize ? { env: { MOCK_PAGE_SIZE: String(options.pageSize) } } : {}) })); + } + const http = await startHttpServer({ protocol: generation, ...options }); + servers.push(http); + return session(httpServer(http.url)); + } + + it('negotiates the newest version the server speaks and says whether the connection is stateful', async () => { + const s = await connect(); + expect(s.protocol()).toBeUndefined(); + await s.listTools(); + expect(s.protocol()).toEqual({ version: expected.version, stateful: expected.stateful[transport] }); + }); + + it('lists (across pages) and calls', async () => { + const s = await connect({ pageSize: 3 }); + expect((await s.listTools()).map((t) => t.name)).toEqual(TOOLS.map((t) => t.name)); + expect(textOf(await s.callTool('echo', { message: 'hi' }))).toBe('echo: hi'); + const failed = await s.callTool('fail', undefined); + expect(failed.isError).toBe(true); + expect((await s.callTool('structured', undefined)).structuredContent).toEqual({ items: [{ id: 1 }, { id: 2 }] }); + }); + + it('maps a failure of the server to exit 6 with its text', async () => { + const s = await connect(); + await s.listTools(); + const err = (await s.callTool('no_such_tool', undefined).catch((e: unknown) => e)) as ClientError; + expect(err).toBeInstanceOf(ClientError); + expect(err).toMatchObject({ exitCode: 6, message: 'mock: unknown tool: no_such_tool' }); + }); + }); +}); + +describe('the stateless protocol on the wire', () => { + it('sends the method in an Mcp-Method header and the version in MCP-Protocol-Version, and no session id', async () => { + const http = await startHttpServer({ protocol: 'modern' }); + servers.push(http); + const s = session(httpServer(http.url)); + await s.listTools(); + await s.callTool('echo', { message: 'x' }); + expect(http.requests.length).toBeGreaterThanOrEqual(3); // the probe, the list, the call + expect(http.requests[0]).toMatchObject({ 'mcp-method': 'server/discover', 'mcp-protocol-version': '2026-07-28' }); + const methods = http.requests.map((r) => r['mcp-method']); + expect(methods).toContain('tools/list'); + expect(methods).toContain('tools/call'); + for (const headers of http.requests) { + expect(headers['mcp-session-id']).toBeUndefined(); + expect(headers['mcp-protocol-version']).toBe('2026-07-28'); + } + expect(methods).not.toContain('initialize'); + }); + + it('falls back to the initialize handshake on a server of the old protocol, and sends nothing of the new one afterwards', async () => { + const http = await startHttpServer({ protocol: 'legacy' }); + servers.push(http); + const s = session(httpServer(http.url)); + await s.listTools(); + expect(s.protocol()).toMatchObject({ version: '2025-11-25' }); + const methods = http.requests.map((r) => r['mcp-method']); + expect(methods.every((m) => m === undefined || m === 'server/discover')).toBe(true); + }); + + it('does not terminate a session on close: there is none', async () => { + const http = await startHttpServer({ protocol: 'modern' }); + servers.push(http); + const s = session(httpServer(http.url)); + await s.listTools(); + const before = http.requests.length; + await s.close(); + expect(http.requests.length).toBe(before); + }); +}); + +describe('the cache hint of a stateless server', () => { + it('is reported for a positive ttlMs and not for 0 or none', async () => { + for (const [ttlMs, expected] of [[30_000, 30_000], [0, undefined], [undefined, undefined]] as const) { + const http = await startHttpServer({ protocol: 'modern', ...(ttlMs !== undefined ? { ttlMs } : {}) }); + servers.push(http); + const s = session(httpServer(http.url)); + let seen: number | undefined; + await s.listTools({ onTtl: (ms) => void (seen = ms) }); + expect(seen, `ttlMs ${String(ttlMs)}`).toBe(expected); + } + }); +}); + +describe('list changes', () => { + it('reach the session as events on the subscription stream of the stateless protocol', async () => { + const http = await startHttpServer({ protocol: 'modern' }); + servers.push(http); + let changed = 0; + const s = session(httpServer(http.url), { onToolsChanged: () => void (changed += 1) }); + await s.listTools(); + http.notifyToolsChanged(); + await waitUntil(() => changed > 0); + expect(changed).toBe(1); + }); + + it('open the stream again when it drops, and report that events may have been missed', async () => { + const http = await startHttpServer({ protocol: 'modern' }); + servers.push(http); + let changed = 0; + const s = session(httpServer(http.url), { onToolsChanged: () => void (changed += 1) }); + await s.listTools(); + http.dropConnections(); + await waitUntil(() => changed > 0, 8000); // the first retry comes after 1 s + expect(changed).toBeGreaterThan(0); + const before = changed; + http.notifyToolsChanged(); + await waitUntil(() => changed > before); + expect(changed).toBeGreaterThan(before); + }, 20_000); + + it('arrive from a stateless stdio server too', async () => { + let changed = 0; + const s = session(stdioServer({ protocol: 'modern' }), { onToolsChanged: () => void (changed += 1) }); + await s.callTool('echo', { message: 'warm' }); + await s.callTool('add_tool', undefined); + await waitUntil(() => changed > 0); + expect(changed).toBeGreaterThan(0); + expect((await s.listTools()).map((t) => t.name)).toContain('extra_1'); + }); + + it('arrive as notifications from a server of the old protocol, as before', async () => { + let changed = 0; + const s = session(stdioServer({ protocol: 'legacy' }), { onToolsChanged: () => void (changed += 1) }); + await s.callTool('echo', { message: 'warm' }); + await s.callTool('add_tool', undefined); + await waitUntil(() => changed > 0); + expect(changed).toBe(1); + }); +}); + +describe('a server that speaks neither protocol', () => { + it('fails to connect with exit 6 and says which server', async () => { + const http = await startHttpServer({ status: 502 }); + servers.push(http); + const s = session(httpServer(http.url)); + const err = await s.listTools().catch((e: unknown) => e); + expect(err).toBeInstanceOf(ClientError); + expect((err as ClientError).exitCode).toBe(6); + }); +}); + +describe('a pinned protocol version', () => { + const pinned = (url: string, version: string) => httpServer(url, { protocolVersion: version }); + + it.each([ + ['modern', '2026-07-28'], + ['legacy', '2025-11-25'], + ['legacy', '2025-03-26'], + ['legacy', '2024-11-05'], + ] as const)('is used when a %s server supports %s', async (generation, version) => { + const http = await startHttpServer({ protocol: generation }); + servers.push(http); + const s = session(pinned(http.url, version)); + await s.listTools(); + expect(s.protocol()?.version).toBe(version); + }); + + it.each([ + ['legacy', '2026-07-28', ''], + ['modern', '2025-11-25', ' (it supports 2026-07-28)'], + ['modern', '2024-11-05', ' (it supports 2026-07-28)'], + ] as const)('is an error, not a fallback, when a %s server does not support %s', async (generation, version, offered) => { + const http = await startHttpServer({ protocol: generation }); + servers.push(http); + const s = session(pinned(http.url, version)); + const err = (await s.listTools().catch((e: unknown) => e)) as ClientError; + expect(err).toBeInstanceOf(ClientError); + expect(err.info).toEqual({ + exitCode: 6, + message: `cannot start mock: it does not support protocol version ${version}${offered}; servers.mock.protocolVersion pins it`, + next: 'remove protocolVersion from servers.mock, or pin a version the server supports', + }); + }); + + it('applies to a stdio server too, and skips the probe for an old version', async () => { + const old = session(stdioServer({ protocol: 'legacy', protocolVersion: '2025-06-18' })); + await old.listTools(); + expect(old.protocol()).toEqual({ version: '2025-06-18', stateful: true }); + const refused = session(stdioServer({ protocol: 'legacy', protocolVersion: '2026-07-28' })); + const err = (await refused.listTools().catch((e: unknown) => e)) as ClientError; + expect(err).toBeInstanceOf(ClientError); + expect(err.message).toBe('cannot start mock: it does not support protocol version 2026-07-28; servers.mock.protocolVersion pins it'); + }); +}); diff --git a/test/daemon/protocol.test.ts b/test/daemon/protocol.test.ts index 23ec969..707dbe5 100644 --- a/test/daemon/protocol.test.ts +++ b/test/daemon/protocol.test.ts @@ -284,7 +284,7 @@ describe('catalog', () => { const first = await list(conn, t); const at = first.result.fetchedAt; const same = await list(conn, t, { have: at }); - expect(same.result).toEqual({ unchanged: true, fetchedAt: at, fromCache: true, features: { resources: false, prompts: false } }); + expect(same.result).toEqual({ unchanged: true, fetchedAt: at, fromCache: true, features: { resources: false, prompts: false }, protocol: { version: expect.stringMatching(/^\d{4}-\d\d-\d\d$/), stateful: expect.any(Boolean) } }); const other = await list(conn, t, { have: at - 1 }); expect(other.result).toMatchObject({ fetchedAt: at, fromCache: true }); expect((other.result as ListedTools).tools.map((x) => x.name)).toEqual(TOOLS.map((x) => x.name)); diff --git a/test/fixtures/mcp/cli-server.d.mts b/test/fixtures/mcp/cli-server.d.mts index 8fc9f41..cb7aa26 100644 --- a/test/fixtures/mcp/cli-server.d.mts +++ b/test/fixtures/mcp/cli-server.d.mts @@ -1,4 +1,5 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { Server as ModernServer } from '@modelcontextprotocol/server'; export interface CliState { tools: Array & { name: string }>; @@ -15,4 +16,7 @@ export interface CliState { } export declare function defaultTools(): CliState['tools']; export declare function createCliState(): CliState; -export declare function createCliServer(options: { state: CliState; authorization?: string; features?: Array<'resources' | 'prompts' | 'roots'> }): Server; +type Options = { state: CliState; authorization?: string; features?: Array<'resources' | 'prompts' | 'roots'> }; +/** `flavor` picks the protocol generation: `legacy` (initialize handshake, the default) or `modern` (stateless `2026-07-28`). */ +export declare function createCliServer(options: Options & { flavor: 'modern'; notifier?: { toolsChanged(): void }; /** `ttlMs` of `tools/list` results. */ ttlMs?: number }): ModernServer; +export declare function createCliServer(options: Options & { flavor?: 'legacy' }): Server; diff --git a/test/fixtures/mcp/cli-server.mjs b/test/fixtures/mcp/cli-server.mjs index 74c53b1..ef44235 100644 --- a/test/fixtures/mcp/cli-server.mjs +++ b/test/fixtures/mcp/cli-server.mjs @@ -2,18 +2,9 @@ // output schemas, progress, a long result and failing tools. Plain ESM like mock-server.mjs. // With `features` it also serves resources (text, JSON, blob, several contents, a long text, templates, // two list pages) and prompts; the feature `roots` adds the tool `list_roots`, which asks the client for its roots. -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { - CallToolRequestSchema, - ErrorCode, - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourceTemplatesRequestSchema, - ListResourcesRequestSchema, - ListToolsRequestSchema, - McpError, - ReadResourceRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; +import { buildServer } from './flavors.mjs'; + +const INVALID_PARAMS = -32602; const object = (properties = {}, required = []) => ({ type: 'object', properties, required }); const str = (description) => ({ type: 'string', description }); @@ -103,21 +94,22 @@ export function createCliState() { } /** - * @param {{ state: ReturnType, authorization?: string, features?: Array<'resources' | 'prompts' | 'roots'> }} options + * @param {{ state: ReturnType, authorization?: string, features?: Array<'resources' | 'prompts' | 'roots'>, flavor?: 'legacy' | 'modern', notifier?: { toolsChanged(): void }, ttlMs?: number }} options + * `flavor` picks the protocol generation (flavors.mjs); `notifier` is the stateless handler's change publisher. */ -export function createCliServer({ state, authorization, features = [] }) { +export function createCliServer({ state, authorization, features = [], flavor = 'legacy', notifier, ttlMs }) { const capabilities = { tools: {}, ...(features.includes('resources') ? { resources: {} } : {}), ...(features.includes('prompts') ? { prompts: {} } : {}) }; - const server = new Server({ name: 'cli-fixture', version: '0.0.0' }, { capabilities }); + const table = {}; const secret = authorization ?? process.env.FIXTURE_SECRET ?? ''; if (features.includes('resources')) { // Two pages, to prove that the client follows `nextCursor`. - server.setRequestHandler(ListResourcesRequestSchema, async (request) => { - return request.params?.cursor === 'page-2' ? { resources: RESOURCES.slice(3) } : { resources: RESOURCES.slice(0, 3), nextCursor: 'page-2' }; - }); - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: TEMPLATES })); - server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const { uri } = request.params; + table['resources/list'] = async (params) => { + return params.cursor === 'page-2' ? { resources: RESOURCES.slice(3) } : { resources: RESOURCES.slice(0, 3), nextCursor: 'page-2' }; + }; + table['resources/templates/list'] = async () => ({ resourceTemplates: TEMPLATES }); + table['resources/read'] = async (params, h) => { + const { uri } = params; state.reads.push(uri); switch (uri) { case 'file:///project/README.md': @@ -135,21 +127,21 @@ export function createCliServer({ state, authorization, features = [] }) { case 'file:///project/logs/2026-10-20.log': return { contents: [{ uri, mimeType: 'text/plain', text: 'started\nstopped' }] }; case 'file:///project/odd': - throw new McpError(ErrorCode.InvalidParams, 'Invalid URI'); + h.fail(INVALID_PARAMS, 'Invalid URI'); default: - throw new McpError(-32002, `Resource not found: ${uri}`); + h.fail(-32002, `Resource not found: ${uri}`); } - }); + }; } if (features.includes('prompts')) { - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: state.promptList ?? PROMPTS })); - server.setRequestHandler(GetPromptRequestSchema, async (request) => { - const { name, arguments: args = {} } = request.params; + table['prompts/list'] = async () => ({ prompts: state.promptList ?? PROMPTS }); + table['prompts/get'] = async (params, h) => { + const { name, arguments: args = {} } = params; state.prompts.push({ name, args }); switch (name) { case 'review': - if (args.code === undefined) throw new McpError(ErrorCode.InvalidParams, 'Missing required argument: code'); + if (args.code === undefined) h.fail(INVALID_PARAMS, 'Missing required argument: code'); return { description: 'Review', messages: [{ role: 'user', content: { type: 'text', text: `Please review this ${args.lang ?? 'code'}:\n${args.code}` } }] }; case 'greet_user': return { messages: [{ role: 'user', content: { type: 'text', text: `Hello, ${args.name}!` } }] }; @@ -162,18 +154,19 @@ export function createCliServer({ state, authorization, features = [] }) { ], }; default: - throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`); + h.fail(INVALID_PARAMS, `Unknown prompt: ${name}`); } - }); + }; } - server.setRequestHandler(ListToolsRequestSchema, async () => { + table['tools/list'] = async () => { state.listCalls += 1; - return { tools: features.includes('roots') ? [...state.tools, ROOTS_TOOL] : state.tools }; - }); + // `ttlMs`: the freshness hint a stateless server sends with its tool list. + return { tools: features.includes('roots') ? [...state.tools, ROOTS_TOOL] : state.tools, ...(flavor === 'modern' && ttlMs !== undefined ? { ttlMs } : {}) }; + }; - server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - const { name, arguments: args = {} } = request.params; + table['tools/call'] = async (params, h) => { + const { name, arguments: args = {} } = params; state.calls.push({ name, args }); switch (name) { case 'issue_read': @@ -201,18 +194,18 @@ export function createCliServer({ state, authorization, features = [] }) { case 'whoami': return { content: [{ type: 'text', text: `authorization: ${authorization ?? ''}` }] }; case 'list_roots': { - // A server asking its client: over stdio only (the HTTP fixture answers each request on a fresh server). - const roots = server.getClientCapabilities()?.roots; + // A server asking its client: over stdio only on the old protocol (the HTTP fixture answers each request on a fresh server). + const roots = h.clientCapabilities()?.roots; if (!roots) return { content: [{ type: 'text', text: 'roots: the client did not declare the capability' }] }; - const listed = await server.listRoots(); + const listed = await h.listRoots(); return { content: [{ type: 'text', text: [`roots (listChanged: ${roots.listChanged === true}):`, ...listed.roots.map((r) => `${r.uri} ${r.name ?? ''}`.trimEnd())].join('\n') }] }; } case 'echo_text': return { content: [{ type: 'text', text: `echo: ${args.message}` }] }; case 'run_job': { - const token = extra._meta?.progressToken; + const token = h.progressToken; if (token !== undefined) { - await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: 1, total: 2, message: 'halfway' } }); + await h.notify({ method: 'notifications/progress', params: { progressToken: token, progress: 1, total: 2, message: 'halfway' } }); // The SDK delivers notifications asynchronously: a response in the same read could overtake them. await new Promise((resolve) => setTimeout(resolve, 50)); } @@ -240,7 +233,7 @@ export function createCliServer({ state, authorization, features = [] }) { default: throw new Error(`unknown tool: ${name}`); } - }); + }; - return server; + return buildServer(flavor, { name: 'cli-fixture', version: '0.0.0' }, capabilities, table, notifier ? { notifier } : {}); } diff --git a/test/fixtures/mcp/cli-stdio-server-modern.mjs b/test/fixtures/mcp/cli-stdio-server-modern.mjs new file mode 100644 index 0000000..1ebed14 --- /dev/null +++ b/test/fixtures/mcp/cli-stdio-server-modern.mjs @@ -0,0 +1,3 @@ +// `cli-stdio-server.mjs` on the stateless protocol (2.x SDK); see test/helpers/generation.ts. +process.env.CLI_FIXTURE_PROTOCOL = 'modern'; +await import('./cli-stdio-server.mjs'); diff --git a/test/fixtures/mcp/cli-stdio-server.mjs b/test/fixtures/mcp/cli-stdio-server.mjs index aef2496..bfe656c 100644 --- a/test/fixtures/mcp/cli-stdio-server.mjs +++ b/test/fixtures/mcp/cli-stdio-server.mjs @@ -1,8 +1,11 @@ // The CLI fixture server over stdio: `node test/fixtures/mcp/cli-stdio-server.mjs`. // Needed where the transport matters (progress notifications cannot stream over the JSON-response // HTTP fixture). Tool calls are appended to the file named by CLI_FIXTURE_LOG, one JSON per line. +// CLI_FIXTURE_PROTOCOL=modern serves the stateless `2026-07-28` protocol (2.x SDK); anything else is a +// server of the old generation (1.x SDK) that has never heard of `server/discover`. import { appendFileSync } from 'node:fs'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { createCliServer, createCliState } from './cli-server.mjs'; const state = createCliState(); @@ -10,5 +13,9 @@ const log = process.env.CLI_FIXTURE_LOG; if (log) appendFileSync(log, `${JSON.stringify({ spawn: process.pid })}\n`); // CLI_FIXTURE_FEATURES=resources,prompts switches the optional capabilities on. const features = (process.env.CLI_FIXTURE_FEATURES ?? '').split(',').filter(Boolean); -const server = createCliServer({ state, features }); -await server.connect(new StdioServerTransport()); +if (process.env.CLI_FIXTURE_PROTOCOL === 'modern') { + serveStdio(() => createCliServer({ state, features, flavor: 'modern' })); +} else { + const server = createCliServer({ state, features }); + await server.connect(new StdioServerTransport()); +} diff --git a/test/fixtures/mcp/flavors.mjs b/test/fixtures/mcp/flavors.mjs new file mode 100644 index 0000000..5ba9771 --- /dev/null +++ b/test/fixtures/mcp/flavors.mjs @@ -0,0 +1,96 @@ +// One handler table, two protocol generations. The fixture servers (mock-server.mjs, cli-server.mjs) +// describe what they answer as a table `{ 'tools/list': async (params, h) => result, … }`; this file turns +// it into a server of the old generation (`initialize` handshake, sessions; the 1.x SDK in node_modules) +// or of the stateless one (`server/discover`, `2026-07-28`; the 2.x SDK), so the same tests run against both +// (docs/development/testing.md, "两代协议"). +// +// What a handler may use of its context `h`: +// h.progressToken the request's progress token, if the client asked for progress +// h.notify(notification) send a notification related to this request +// h.clientCapabilities() what the client declared +// h.listRoots() ask the client for its roots (a result `{ roots }`, or undefined when not declared) +// h.toolsChanged() tell listeners the tool list changed +// h.fail(code, message) answer with a JSON-RPC error +import { Server as LegacyServer } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourceTemplatesRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + McpError, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { CLIENT_CAPABILITIES_META_KEY, inputRequired, ProtocolError, Server as ModernServer } from '@modelcontextprotocol/server'; + +const LEGACY_SCHEMAS = { + 'tools/list': ListToolsRequestSchema, + 'tools/call': CallToolRequestSchema, + 'resources/list': ListResourcesRequestSchema, + 'resources/templates/list': ListResourceTemplatesRequestSchema, + 'resources/read': ReadResourceRequestSchema, + 'prompts/list': ListPromptsRequestSchema, + 'prompts/get': GetPromptRequestSchema, +}; + +/** Thrown by `h.listRoots()` on a stateless connection: the handler is asked again with the answer. */ +class NeedRoots extends Error {} + +/** + * @param {'legacy' | 'modern'} flavor + * @param {{ name: string, version: string }} info + * @param {Record} capabilities + * @param {Record Promise>} table + * @param {{ notifier?: { toolsChanged(): void }, instructions?: string }} [options] `notifier`: the stateless handler's `notify` sugar (known only after the handler exists) + */ +export function buildServer(flavor, info, capabilities, table, options = {}) { + if (flavor === 'legacy') { + const server = new LegacyServer(info, { capabilities }); + for (const [method, handler] of Object.entries(table)) { + const schema = LEGACY_SCHEMAS[method]; + if (!schema) throw new Error(`flavors.mjs: no legacy schema for ${method}`); + server.setRequestHandler(schema, (request, extra) => + handler(request.params ?? {}, { + progressToken: extra._meta?.progressToken, + notify: (notification) => extra.sendNotification(notification), + clientCapabilities: () => server.getClientCapabilities(), + listRoots: async () => (server.getClientCapabilities()?.roots ? server.listRoots() : undefined), + toolsChanged: () => server.sendToolListChanged(), + fail: (code, message) => { + throw new McpError(code, message); + }, + }), + ); + } + return server; + } + const server = new ModernServer(info, { capabilities, ...(options.instructions ? { instructions: options.instructions } : {}) }); + for (const [method, handler] of Object.entries(table)) { + server.setRequestHandler(method, async (request, ctx) => { + const envelope = ctx.mcpReq.envelope ?? {}; + const h = { + progressToken: ctx.mcpReq._meta?.progressToken, + notify: (notification) => ctx.mcpReq.notify(notification), + clientCapabilities: () => envelope[CLIENT_CAPABILITIES_META_KEY], + listRoots: async () => { + if (!envelope[CLIENT_CAPABILITIES_META_KEY]?.roots) return undefined; + const answered = ctx.mcpReq.inputResponses?.roots; + if (answered) return answered; + throw new NeedRoots(); + }, + toolsChanged: () => (options.notifier ? options.notifier.toolsChanged() : server.sendToolListChanged()), + fail: (code, message) => { + throw new ProtocolError(code, message); + }, + }; + try { + return await handler(request.params ?? {}, h); + } catch (error) { + if (error instanceof NeedRoots) return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } }); + throw error; + } + }); + } + return server; +} diff --git a/test/fixtures/mcp/mock-server.d.mts b/test/fixtures/mcp/mock-server.d.mts index ed8af6d..0725557 100644 --- a/test/fixtures/mcp/mock-server.d.mts +++ b/test/fixtures/mcp/mock-server.d.mts @@ -1,5 +1,10 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { Server as ModernServer } from '@modelcontextprotocol/server'; + +type State = { active: number; maxActive: number; extra: Array> }; export declare const TOOLS: Array<{ name: string; description: string; inputSchema: Record }>; -export declare function createState(): { active: number; maxActive: number; extra: Array> }; -export declare function createMockServer(options?: { pageSize?: number; state?: { active: number; maxActive: number; extra: Array> } }): Server; +export declare function createState(): State; +/** `flavor` picks the protocol generation: `legacy` (initialize handshake, the default) or `modern` (stateless `2026-07-28`). */ +export declare function createMockServer(options: { pageSize?: number; state?: State; flavor: 'modern'; notifier?: { toolsChanged(): void }; /** `ttlMs` of `tools/list` results. */ ttlMs?: number }): ModernServer; +export declare function createMockServer(options?: { pageSize?: number; state?: State; flavor?: 'legacy' }): Server; diff --git a/test/fixtures/mcp/mock-server.mjs b/test/fixtures/mcp/mock-server.mjs index 55ba297..efc63a4 100644 --- a/test/fixtures/mcp/mock-server.mjs +++ b/test/fixtures/mcp/mock-server.mjs @@ -1,8 +1,7 @@ // Mock MCP server used by the client tests (docs/development/testing.md, "模拟 server"). // Plain ESM so that `node test/fixtures/mcp/stdio-server.mjs` runs without a build step. import { existsSync, writeFileSync } from 'node:fs'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { buildServer } from './flavors.mjs'; const object = (properties = {}, required = []) => ({ type: 'object', properties, required }); @@ -28,65 +27,66 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const text = (value) => ({ content: [{ type: 'text', text: String(value) }] }); /** - * @param {{ pageSize?: number, state?: ReturnType }} [options] + * @param {{ pageSize?: number, state?: ReturnType, flavor?: 'legacy' | 'modern', notifier?: { toolsChanged(): void }, ttlMs?: number }} [options] + * `ttlMs` is the freshness hint `tools/list` carries on the stateless protocol. + * `flavor` picks the protocol generation of the server (flavors.mjs); `notifier` is the stateless handler's change publisher. */ -export function createMockServer({ pageSize = 0, state = createState() } = {}) { - const server = new Server({ name: 'mock', version: '0.0.0' }, { capabilities: { tools: { listChanged: true } } }); - - server.setRequestHandler(ListToolsRequestSchema, async (request) => { - const all = [...TOOLS, ...state.extra]; - if (!pageSize) return { tools: all }; - const start = request.params?.cursor ? Number(request.params.cursor) : 0; - const end = start + pageSize; - return { tools: all.slice(start, end), ...(end < all.length ? { nextCursor: String(end) } : {}) }; - }); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args = {} } = request.params; - switch (name) { - case 'echo': - return text(`echo: ${args.message}`); - case 'fail': - return { content: [{ type: 'text', text: 'boom' }], isError: true }; - case 'structured': - return { - content: [{ type: 'text', text: '{"items":[{"id":1},{"id":2}]}' }], - structuredContent: { items: [{ id: 1 }, { id: 2 }] }, - }; - case 'slow': - await sleep(Number(args.ms ?? 1000)); - return text('done'); - case 'read_env': - return text(process.env[String(args.name)] ?? ''); - case 'busy': { - state.active += 1; - state.maxActive = Math.max(state.maxActive, state.active); - await sleep(100); - state.active -= 1; - return text('ok'); - } - case 'max_busy': - return text(state.maxActive); - case 'crash_once': { - const marker = process.env.MOCK_MARKER; - if (marker && !existsSync(marker)) { - writeFileSync(marker, 'crashed'); - process.exit(1); +export function createMockServer({ pageSize = 0, state = createState(), flavor = 'legacy', notifier, ttlMs } = {}) { + const table = { + 'tools/list': async (params) => { + const all = [...TOOLS, ...state.extra]; + const hint = flavor === 'modern' && ttlMs !== undefined ? { ttlMs } : {}; + if (!pageSize) return { tools: all, ...hint }; + const start = params.cursor ? Number(params.cursor) : 0; + const end = start + pageSize; + return { tools: all.slice(start, end), ...(end < all.length ? { nextCursor: String(end) } : {}), ...hint }; + }, + 'tools/call': async (params, h) => { + const { name, arguments: args = {} } = params; + switch (name) { + case 'echo': + return text(`echo: ${args.message}`); + case 'fail': + return { content: [{ type: 'text', text: 'boom' }], isError: true }; + case 'structured': + return { + content: [{ type: 'text', text: '{"items":[{"id":1},{"id":2}]}' }], + structuredContent: { items: [{ id: 1 }, { id: 2 }] }, + }; + case 'slow': + await sleep(Number(args.ms ?? 1000)); + return text('done'); + case 'read_env': + return text(process.env[String(args.name)] ?? ''); + case 'busy': { + state.active += 1; + state.maxActive = Math.max(state.maxActive, state.active); + await sleep(100); + state.active -= 1; + return text('ok'); } - return text('recovered'); - } - case 'pid': - return text(process.pid); - case 'add_tool': { - const name = `extra_${state.extra.length + 1}`; - state.extra.push({ name, description: 'Added at runtime', inputSchema: object() }); - await server.sendToolListChanged(); - return text(name); + case 'max_busy': + return text(state.maxActive); + case 'crash_once': { + const marker = process.env.MOCK_MARKER; + if (marker && !existsSync(marker)) { + writeFileSync(marker, 'crashed'); + process.exit(1); + } + return text('recovered'); + } + case 'pid': + return text(process.pid); + case 'add_tool': { + const added = `extra_${state.extra.length + 1}`; + state.extra.push({ name: added, description: 'Added at runtime', inputSchema: object() }); + await h.toolsChanged(); + return text(added); + } + default: + throw new Error(`unknown tool: ${name}`); } - default: - throw new Error(`unknown tool: ${name}`); - } - }); - - return server; + }, + }; + return buildServer(flavor, { name: 'mock', version: '0.0.0' }, { tools: { listChanged: true } }, table, notifier ? { notifier } : {}); } diff --git a/test/fixtures/mcp/modern-http.d.mts b/test/fixtures/mcp/modern-http.d.mts new file mode 100644 index 0000000..efd53c2 --- /dev/null +++ b/test/fixtures/mcp/modern-http.d.mts @@ -0,0 +1,11 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Server } from '@modelcontextprotocol/server'; + +export declare function modernEndpoint( + makeServer: (notifier: { toolsChanged(): void }) => Server, + options?: { legacy?: 'reject' | 'stateless' }, +): { + handle(req: IncomingMessage, res: ServerResponse): Promise; + notifier: { toolsChanged(): void }; + close(): Promise; +}; diff --git a/test/fixtures/mcp/modern-http.mjs b/test/fixtures/mcp/modern-http.mjs new file mode 100644 index 0000000..b2a16db --- /dev/null +++ b/test/fixtures/mcp/modern-http.mjs @@ -0,0 +1,19 @@ +// The stateless protocol (`2026-07-28`) on a Node HTTP server, for the fixture servers: a fresh server +// instance per request from `makeServer(notifier)` (so state lives outside it, as in the legacy +// fixtures), served by the 2.x SDK's `createMcpHandler`. `legacy: 'reject'` makes the endpoint strict: a +// client that falls back to `initialize` is told the version is unsupported, so a test cannot pass by +// accident on the old protocol. Plain ESM like the other fixtures. +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { toNodeHandler } from '@modelcontextprotocol/node'; + +/** + * @param {(notifier: { toolsChanged(): void }) => import('@modelcontextprotocol/server').Server} makeServer + * @param {{ legacy?: 'reject' | 'stateless' }} [options] + * @returns {{ handle: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise, notifier: { toolsChanged(): void }, close(): Promise }} + */ +export function modernEndpoint(makeServer, { legacy = 'reject' } = {}) { + const notifier = { toolsChanged: () => handler.notify.toolsChanged() }; + const handler = createMcpHandler(() => makeServer(notifier), { legacy }); + const node = toNodeHandler(handler); + return { handle: (req, res) => node(req, res), notifier, close: () => handler.close() }; +} diff --git a/test/fixtures/mcp/stdio-server-modern.mjs b/test/fixtures/mcp/stdio-server-modern.mjs new file mode 100644 index 0000000..7a4ca2d --- /dev/null +++ b/test/fixtures/mcp/stdio-server-modern.mjs @@ -0,0 +1,3 @@ +// `stdio-server.mjs` on the stateless protocol (2.x SDK); see test/helpers/generation.ts. +process.env.MOCK_PROTOCOL = 'modern'; +await import('./stdio-server.mjs'); diff --git a/test/fixtures/mcp/stdio-server.mjs b/test/fixtures/mcp/stdio-server.mjs index e41e5d2..42ae98b 100644 --- a/test/fixtures/mcp/stdio-server.mjs +++ b/test/fixtures/mcp/stdio-server.mjs @@ -1,7 +1,17 @@ // Mock MCP server over stdio: `node test/fixtures/mcp/stdio-server.mjs`. -// MOCK_PAGE_SIZE paginates tools/list. +// MOCK_PAGE_SIZE paginates tools/list. MOCK_PROTOCOL=modern serves the stateless `2026-07-28` protocol +// (and, like any server built on the 2.x SDK's `serveStdio`, still the old handshake to a client that opens with it); +// MOCK_PROTOCOL=legacy-only (or unset) is a server of the old generation that has never heard of `server/discover`. import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { createMockServer } from './mock-server.mjs'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { createMockServer, createState } from './mock-server.mjs'; -const server = createMockServer({ pageSize: Number(process.env.MOCK_PAGE_SIZE ?? 0) }); -await server.connect(new StdioServerTransport()); +const pageSize = Number(process.env.MOCK_PAGE_SIZE ?? 0); +if (process.env.MOCK_PROTOCOL === 'modern') { + const state = createState(); + const ttlMs = process.env.MOCK_TTL_MS ? Number(process.env.MOCK_TTL_MS) : undefined; + serveStdio(() => createMockServer({ pageSize, state, flavor: 'modern', ...(ttlMs !== undefined ? { ttlMs } : {}) })); +} else { + const server = createMockServer({ pageSize }); + await server.connect(new StdioServerTransport()); +} diff --git a/test/helpers/client.ts b/test/helpers/client.ts index 65ab261..8e660c7 100644 --- a/test/helpers/client.ts +++ b/test/helpers/client.ts @@ -1,6 +1,5 @@ import { createServer, type IncomingHttpHeaders, type Server as HttpServer } from 'node:http'; import type { AddressInfo } from 'node:net'; -import { fileURLToPath } from 'node:url'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CredentialError, @@ -8,13 +7,18 @@ import { type CredentialResolver, type ServerConfig, } from '../../src/config/types'; +import { modernEndpoint } from '../fixtures/mcp/modern-http.mjs'; +import { defaultGeneration, fixtureScript, type Generation } from './generation'; import { createMockServer, createState } from '../fixtures/mcp/mock-server.mjs'; +export type { Generation }; + /** Obviously fake values; never real credentials. */ export const FAKE_TOKEN = 'fake-token-for-tests'; export const FAKE_KEY = 'fake-api-key-for-tests'; -export const STDIO_SERVER = fileURLToPath(new URL('../fixtures/mcp/stdio-server.mjs', import.meta.url)); +/** The stdio fixture in the default protocol generation (generation.ts). */ +export const STDIO_SERVER = fixtureScript('stdio-server', defaultGeneration()); /** Resolves `env:NAME` from a given map, anything else is a literal. Records what it returned. */ export class FakeResolver implements CredentialResolver { @@ -38,10 +42,10 @@ export class FakeResolver implements CredentialResolver { } } -export function stdioServer(overrides: Partial & { env?: Record; args?: string[]; command?: string } = {}): ServerConfig { - const { env, args, command, ...rest } = overrides; +export function stdioServer(overrides: Partial & { env?: Record; args?: string[]; command?: string; protocol?: Generation } = {}): ServerConfig { + const { env, args, command, protocol, ...rest } = overrides; return baseConfig({ - transport: { type: 'stdio', command: command ?? process.execPath, args: args ?? [STDIO_SERVER], env: env ?? {} }, + transport: { type: 'stdio', command: command ?? process.execPath, args: args ?? [protocol === undefined ? STDIO_SERVER : fixtureScript('stdio-server', protocol)], env: env ?? {} }, ...rest, }); } @@ -76,6 +80,10 @@ export interface MockHttp { requests: IncomingHttpHeaders[]; /** Requests rejected with 401. */ rejected: number; + /** Stateless servers: publish `notifications/tools/list_changed` to every open `subscriptions/listen` stream. */ + notifyToolsChanged(): void; + /** Cut every open connection (a `subscriptions/listen` stream among them) without a goodbye. */ + dropConnections(): void; close(): Promise; } @@ -83,11 +91,30 @@ export interface MockHttp { * Streamable HTTP mock on an ephemeral localhost port. Requests need * `Authorization: Bearer ` when `token` is given, otherwise they get 401. */ -export async function startHttpServer(options: { token?: string; pageSize?: number; status?: number; echoAuth?: boolean } = {}): Promise { +export async function startHttpServer(options: { token?: string; pageSize?: number; status?: number; echoAuth?: boolean; protocol?: Generation; ttlMs?: number } = {}): Promise { + const generation = options.protocol ?? defaultGeneration(); const state = createState(); - const result: MockHttp = { url: '', requests: [], rejected: 0, close: async () => undefined }; + const result: MockHttp = { url: '', requests: [], rejected: 0, notifyToolsChanged: () => undefined, dropConnections: () => undefined, close: async () => undefined }; + const modern = generation === 'modern' ? modernEndpoint((notifier) => createMockServer({ pageSize: options.pageSize ?? 0, state, flavor: 'modern', notifier, ...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}) })) : undefined; + if (modern) result.notifyToolsChanged = () => modern.notifier.toolsChanged(); const http: HttpServer = createServer((req, res) => { + if (modern) { + // The stateless endpoint reads the body itself; only the checks below come first. + if (options.status) { + const body = options.echoAuth ? `forced failure for ${String(req.headers.authorization)}` : 'forced failure'; + res.writeHead(options.status, { 'content-type': 'text/plain' }).end(body); + return; + } + if (options.token !== undefined && req.headers.authorization !== `Bearer ${options.token}`) { + result.rejected += 1; + res.writeHead(401, { 'content-type': 'text/plain' }).end('unauthorized'); + return; + } + result.requests.push(req.headers); + void modern.handle(req, res); + return; + } const chunks: Buffer[] = []; req.on('data', (c: Buffer) => chunks.push(c)); req.on('end', () => { @@ -124,10 +151,13 @@ export async function startHttpServer(options: { token?: string; pageSize?: numb await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); result.url = `http://127.0.0.1:${(http.address() as AddressInfo).port}/mcp`; - result.close = () => - new Promise((resolve) => { + result.dropConnections = () => http.closeAllConnections(); + result.close = async () => { + await modern?.close().catch(() => undefined); + await new Promise((resolve) => { http.closeAllConnections(); http.close(() => resolve()); }); + }; return result; } diff --git a/test/helpers/daemon.ts b/test/helpers/daemon.ts index a95cf61..c08ccdd 100644 --- a/test/helpers/daemon.ts +++ b/test/helpers/daemon.ts @@ -3,6 +3,7 @@ // the CLI does it in production. Every sandbox gets its own socket directory (on Windows its own // profile directories and pipe-name prefix), and every daemon it started is stopped (and killed if // need be) when the test ends. +import { defaultGeneration, fixtureScript } from './generation'; import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -238,7 +239,7 @@ export async function daemonSandbox(config: string, env: Record /** The stdio mock server as a `servers:` entry for the user config. */ export function mockServerYaml(name = 'mock', extra = ''): string { - const server = fileURLToPath(new URL('../fixtures/mcp/stdio-server.mjs', import.meta.url)); + const server = fixtureScript('stdio-server', defaultGeneration()); return ` ${name}:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(server)}]\n${extra}`; } diff --git a/test/helpers/generation.ts b/test/helpers/generation.ts new file mode 100644 index 0000000..80b968e --- /dev/null +++ b/test/helpers/generation.ts @@ -0,0 +1,15 @@ +// Which protocol generation the fixture servers speak by default (docs/development/testing.md, "两代协议"). +// The tests run against the old handshake unless `VIA_TEST_PROTOCOL=modern` is set, which CI does for a +// second pass over the end-to-end suites; a test that is about one generation says so explicitly. +import { fileURLToPath } from 'node:url'; + +export type Generation = 'legacy' | 'modern'; + +export function defaultGeneration(): Generation { + return process.env.VIA_TEST_PROTOCOL === 'modern' ? 'modern' : 'legacy'; +} + +/** `test/fixtures/mcp/.mjs`, or its `-modern` twin, which starts the same server on the stateless protocol. */ +export function fixtureScript(name: 'stdio-server' | 'cli-stdio-server', generation: Generation): string { + return fileURLToPath(new URL(`../fixtures/mcp/${name}${generation === 'modern' ? '-modern' : ''}.mjs`, import.meta.url)); +} From 04f6a4b1e1532e7f7d832ed8a90939387e55de71 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:04:41 +0000 Subject: [PATCH 3/9] feat(cli): show the negotiated protocol version and let a server pin it via servers shows the protocol and statefulness each server last negotiated (from the catalog cache), via doctor shows it live. servers..protocolVersion pins a version; a server that does not support it is an error, not a fallback. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- CHANGELOG.md | 1 + docs/guides/agent-integration.md | 2 +- docs/spec/cli.md | 4 +- docs/spec/config.md | 12 +++- src/cli/commands/doctor.ts | 4 +- src/cli/commands/servers.ts | 5 +- src/cli/doctor.ts | 15 ++++- src/cli/protocol-label.ts | 11 ++++ test/cli/__snapshots__/cli-servers.txt | 12 ++-- test/cli/auth.test.ts | 2 +- test/cli/cmd-credentials.test.ts | 2 +- test/cli/doctor.test.ts | 19 +++++++ test/cli/protocol.test.ts | 76 ++++++++++++++++++++++++++ test/config/layer.test.ts | 12 ++++ 14 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 src/cli/protocol-label.ts create mode 100644 test/cli/protocol.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7173592..ea1a826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 协议版本可见、可固定(路线图 e3):`via servers` 多一列协议(上次拉取目录时协商出的版本与连接是否有状态,如 `2026-07-28 stateless`;没有记录时写 `-`),`via doctor` 对探测成功的 server 各加一行 `github: MCP 2026-07-28 stateless`。配置字段 `servers..protocolVersion` 固定一个版本(`2026-07-28`、`2025-11-25`、`2025-06-18`、`2025-03-26`、`2024-11-05`、`2024-10-07`),server 不支持时报错(退出码 6,`next:` 说明去掉或改值)而不是回退,显示时标注 `(pinned)`;不是已知版本的值是 `invalid-field` 错误。它参与目录缓存键与 daemon 会话键,项目级配置设置它不需要 `via trust`。config.md 新增 `protocolVersion` 一节,cli.md 与 agent-integration.md 的 `via servers`、`via doctor` 说明随之更新。**`via servers` 的输出多了一列**,按列解析它的脚本需要跟着改。 - 支持 MCP `2026-07-28` 的无状态协议,新版本优先、自动回退(路线图 e2,[ADR 0017](docs/adr/0017-protocol-versions-newest-first-with-fallback.md)、[daemon.md](docs/spec/daemon.md#协议版本与无状态连接)):建立连接时先发 `server/discover`,server 支持 `2026-07-28` 就按新协议谈(无 `initialize`、无会话 ID,请求带 `MCP-Protocol-Version` 与 `Mcp-Method` 头,`serverInfo` 读自响应的 `_meta`),被拒绝(旧 server 对陌生方法的 4xx)时在同一条连接上退回 `initialize` 握手(`2025-11-25` 至 `2024-10-07`);用户不需要配置,旧版 server 上的行为不变。要点: - 变更通知:新协议下 daemon 订阅 `subscriptions/listen` 流,与旧协议的 `notifications/tools/list_changed` 进同一个刷新入口;流意外断开时按 1 秒到 30 秒的退避重订,重订成功后立即刷新一次目录。 - server 在 `tools/list` 结果里给的 `ttlMs` 与 24 小时的目录 TTL 取较短者,随缓存保存;`0` 或缺省视为没有提示(SDK 构造的 server 默认发 `0`,照字面当作立刻过期会让浏览命令每次都连接 server)。 diff --git a/docs/guides/agent-integration.md b/docs/guides/agent-integration.md index f2e8781..c4159d9 100644 --- a/docs/guides/agent-integration.md +++ b/docs/guides/agent-integration.md @@ -29,7 +29,7 @@ secrets: 输出分四节,节间空一行: -- **servers**:按状态计数(`ok`、`needs auth`、`failed`、`untrusted`、`disabled`),非 ok 的状态列出 provider 名;需要鉴权、连接失败、未信任的 server 各占一行并附 `next:`(OAuth server 为 `via auth login `,未信任的项目级文件为 `via trust <路径>`,凭证缺失时用凭证解析给出的提示)。 +- **servers**:按状态计数(`ok`、`needs auth`、`failed`、`untrusted`、`disabled`),非 ok 的状态列出 provider 名;探测成功的 server 各占一行写出协商出的 MCP 协议版本和连接是否有状态(` github: MCP 2026-07-28 stateless`;`protocolVersion` 固定了版本的加 `(pinned)`,[config.md](../spec/config.md#protocolversion));需要鉴权、连接失败、未信任的 server 各占一行并附 `next:`(OAuth server 为 `via auth login `,未信任的项目级文件为 `via trust <路径>`,凭证缺失时用凭证解析给出的提示)。 - **frameworks still injecting MCP schemas**:按框架归并,数据来自 server 定义所在的框架配置(`origin.framework`),以及 `conflicts` 里被忽略的其他框架定义(同名 server 在多个框架里各被注入一次)。框架里可能已经关掉的 server 无从得知,所以按「仍在注入」估算。每个框架一行:server 数与 token 估算,下一行是 `data/frameworks/` 中的 `disableHint`。 - **secrets**:明文凭证告警,见下。 - **config**:配置诊断(`error`、`warning`,不含 `info`;明文凭证的告警并入 secrets,不重复)和目录告警(与内置子命令重名、重复的 provider),仅在有内容时出现。项目配置里的 `cmd:`(`cmd-forbidden`)附 `next:` 说明改在用户配置里定义;信任记录损坏附 `next:` 说明删除后重新 `via trust`。 diff --git a/docs/spec/cli.md b/docs/spec/cli.md index edf616a..764ead8 100644 --- a/docs/spec/cli.md +++ b/docs/spec/cli.md @@ -70,11 +70,11 @@ via prompts [<名称> [参数]] 列出 MCP prompt;带名称时 - `status`:不拉起 daemon,只询问正在运行的。输出一行概况、socket 路径和各会话(server 名、传输、`running (pid N)` / `connected` / `stopped`、目录中的工具数、调用数、空闲时间或在途数);不含 URL、参数、凭证。socket 上没有 daemon 应答时(包括连得上却完不成握手,例如令牌不符,或 Windows 上收尾中的 daemon 的管道还被别的连接占着),再看 daemon 进程记录([daemon.md](daemon.md#进程记录)):有不在监听的 daemon 进程时每个一行,`daemon: starting · pid N`(还没绑定 socket)、`daemon: stopping · pid N · finishing requests in flight`(已收到 `shutdown`、删掉 socket,在处理在途请求)或 `daemon: not reachable · pid N`(socket 被删或被替换,几秒内自行退出),再加 `socket:` 一行;一个都没有才输出 `daemon: not running` 加 `socket:` 一行(完不成握手而又没有记录时,照旧报告连不上的原因)。有 daemon 应答而旁边还有别的 daemon 进程(例如上一个仍在收尾)时,概况之后追加 `also: stopping · pid N · …`。`VIA_NO_DAEMON` 打开时输出 `daemon: disabled (VIA_NO_DAEMON)`,退出码都是 0。`-o json` 输出 `{"running": true, …}`(字段同协议的 `status`)或 `{"running": false, "socket": …}`,有其他 daemon 进程时带 `processes: [{ pid, state }]`(`state` 为 `starting`、`running`、`stopping`)。 - `stop`:结束这个 socket 的所有 daemon 进程,返回时它们都已退出。先向应答的 daemon 发 `shutdown`,给在途请求 5 秒处理完;仍未结束就发 `shutdown`(`force`),让它放弃在途请求(关闭会话、停止 stdio 子进程)立即退出。socket 上不应答的 daemon 进程(进程记录里的 `starting`、`stopping`)收到 `SIGTERM`,同样立即退出(Windows 上 `SIGTERM` 即结束进程,它的 stdio 子进程不随之结束,见 [daemon.md](daemon.md#windows))。全部退出后输出 `daemon: stopped (pid N[, M…])`,放弃过在途请求时追加 `, requests in flight abandoned`;这些请求的 CLI 按「连接中断」处理(只读请求在进程内重做,其余以退出码 6 报告可能已执行)。没有 daemon 时输出 `daemon: not running`,退出码 0。某个进程 5 秒后仍未退出时退出码 7,`next: kill `。`-o json` 输出 `{"running": false, "pid"?: N, "stopped": [N, …], "abandoned"?: true}`。 - `run`:前台运行 daemon,由 CLI 分离拉起,也可手动运行。选项 `--socket <路径>`、`--idle-timeout <秒>`(缺省取配置的 `daemon.idleTimeout`);已有 daemon 在这个 socket 上应答时输出 `daemon: already running on <路径>` 并以 0 退出。收到 SIGTERM/SIGINT/SIGHUP 时正常退出。 -- `via servers`:每个 server 一行,依次为 provider、传输方式(HTTP 只写 scheme 与主机,stdio 只写命令)、鉴权方式及凭证引用是否可解析(只写引用,不写值)、来源文件、备注(`disabled`、`untrusted`、框架间冲突、保留名冲突);之后列出配置诊断。不连接任何 server,也不执行 `cmd:` 凭证引用([config.md](config.md#凭证引用))。`untrusted` 的备注附 `via trust <路径>`(多个文件待确认时为 `via trust`)。配置里有 error 级诊断时照常显示,不中止。 +- `via servers`:每个 server 一行,依次为 provider、传输方式(HTTP 只写 scheme 与主机,stdio 只写命令)、协议(上次拉取目录时协商出的 MCP 协议版本和连接是否有状态,如 `2026-07-28 stateless`、`2025-11-25 stateful`;固定了版本的加 `(pinned)`,还没有记录时只有固定的版本,什么都没有写 `-`)、鉴权方式及凭证引用是否可解析(只写引用,不写值)、来源文件、备注(`disabled`、`untrusted`、框架间冲突、保留名冲突);之后列出配置诊断。不连接任何 server(协议一列读的是目录缓存,缓存过期或被清掉后回到 `-`),也不执行 `cmd:` 凭证引用([config.md](config.md#凭证引用))。`untrusted` 的备注附 `via trust <路径>`(多个文件待确认时为 `via trust`)。配置里有 error 级诊断时照常显示,不中止。 - `via trust`:显示并记录对项目级配置文件的信任,也能列出、撤销记录;细节、确认方式和退出码见 [security.md](security.md#via-trust)。它读取配置但不连接 server,配置里有 error 级诊断时照常工作(不执行任何工具)。确认默认在终端里完成;`--yes` 只在带显式路径并设置 `VIA_ALLOW_NONINTERACTIVE_TRUST=1` 时有效。 - `via auth`:`login [--manual]`、`status []`、`logout `,只用于 `auth: oauth` 的 HTTP server(provider 接受唯一前缀)。provider 未配置为 OAuth 退出码 2,并提示把 `servers..auth` 设为 `oauth`;项目级配置里未信任的 server 退出码 5(登录会把授权发往仓库指定的地址)。`login` 的等待时间默认 300 秒,`--timeout <秒>` 覆盖。其余行为见 [auth.md](auth.md#oauth)。 - `via sync`:向各 server(或指定的 provider)重新拉取 `tools/list` 并更新缓存,每个 provider 一行(`gh 14 tools` 或 `linear needs auth: …`);有失败时退出码取其中最大的,并在 stderr 提示 `next: via doctor`。 -- `via doctor`:对每个 server 实时探测(不读缓存,成功后更新缓存),再输出检测结果;退出码取各项问题中最高的一个(见[退出码](output.md#退出码))。 +- `via doctor`:对每个 server 实时探测(不读缓存,成功后更新缓存),再输出检测结果;探测成功的 server 各占一行,写出这次协商出的协议版本与连接是否有状态(`github: MCP 2026-07-28 stateless`,固定了的加 `(pinned)`,[agent-integration.md](../guides/agent-integration.md#via-doctor检测与关闭提示));固定的版本 server 不支持时是连接失败,退出码 6;退出码取各项问题中最高的一个(见[退出码](output.md#退出码))。 - `via prompt`:输出一段带标记的说明块,`` 到 `` 之间是说明本身与 provider 列表(按字母序,[agent-integration.md](../guides/agent-integration.md#via-prompt给-agent-的说明))。 - `--out <文件>`:路径相对当前目录。文件不存在时创建;已有标记块时只替换两个标记之间的内容,标记之外不动(输出 `· updated the via block in <文件> · N bytes`);没有标记时追加到末尾、前面空一行(输出 `· appended to <文件> · N bytes`);创建时输出 `· wrote <文件> · N bytes`。标记不成对或出现多对时拒绝,退出码 5,并提示手工修——位置猜错会写坏用户自己的文字。 - `--check <文件>`:只比对不写入。块与当前输出一致时退出码 0,stderr 输出 `· <文件> is up to date`;没有标记块或内容过期时退出码 1,`next:` 为 `via prompt --out <文件>`;标记不可用退出码 5。 diff --git a/docs/spec/config.md b/docs/spec/config.md index 81ca91b..bea1c80 100644 --- a/docs/spec/config.md +++ b/docs/spec/config.md @@ -113,6 +113,16 @@ search: | `budget` | 全部 | 覆盖输出预算 | | `noise` | 全部 | 追加的噪声字段名或通配 | | `roots` | 全部 | 声明给 server 的 roots,见下 | +| `protocolVersion` | 全部 | 固定 MCP 协议版本,见下;默认不固定(新版本优先,自动回退) | + +#### `protocolVersion` + +默认不写:via 先按最新的协议版本(`2026-07-28` 的无状态协议)谈,server 不支持就退回 `initialize` 握手,用户什么都不用配([daemon.md](daemon.md#协议版本与无状态连接))。写了 `protocolVersion` 就只用这一个版本,**server 不支持时报错(退出码 6,`next:` 提示去掉这个字段或改成 server 支持的版本),不回退**。 + +- 取值:`2026-07-28`、`2025-11-25`、`2025-06-18`、`2025-03-26`、`2024-11-05`、`2024-10-07`(与 SDK 支持的一致,由测试对照)。其他值是 `invalid-field` 错误(`servers..protocolVersion: expected one of …`),该字段被忽略。 +- 用途:绕过某个 server 在新协议上的缺陷;或要求一个必须是无状态的部署(`2026-07-28`);对 stdio server 还可以固定旧版本,省去协议探测多启动的那个进程。 +- 当前协商出的版本和连接是否有状态由 `via servers`(上次拉取时记下的)与 `via doctor`(实时)显示,固定了的标注 `pinned`([cli.md](cli.md#内置子命令))。 +- 合并时后者覆盖前者,与 `timeout` 相同;它不改变 via 连到哪里、启动什么,所以项目级配置设置它不需要 `via trust`。它参与目录缓存键和 daemon 会话键。 #### `roots` @@ -120,7 +130,7 @@ search: - **默认没有**:不写或写 `[]` 时 via 不声明 `roots` 能力,server 无从得知任何本机目录。要让 server 看到当前目录写 `roots: ["."]`,这是明确的授权,via 不替用户做这个决定。 - 路径中的 `~` 展开为家目录,相对路径相对于**运行 `via` 的当前目录**(与 stdio 的 `cwd` 一致),都转成绝对 `file://` URI;`file://` URI 原样使用。其他 scheme 的 URI 是 error 级诊断(`servers..roots[0]: expected a path or file:// URI`)。每个 root 的名字取路径的末段。 -- 声明时 `listChanged` 为 `false`:一个会话的 roots 在它的一生里不变。daemon 里 roots 参与会话键,相对路径依赖调用目录时不同目录使用不同的会话([daemon.md](daemon.md#环境与工作目录))。 +- 声明时 `listChanged` 为 `false`:一个会话的 roots 在它的一生里不变。无状态协议(`2026-07-28`)里 server 在调用结果中要求提供 roots,via 用同一份配置回答,配置、声明条件和授权含义都不变(该协议已把 roots 标为弃用,[daemon.md](daemon.md#协议版本与无状态连接))。daemon 里 roots 参与会话键,相对路径依赖调用目录时不同目录使用不同的会话([daemon.md](daemon.md#环境与工作目录))。 - 合并时整体替换,不按元素合并。项目配置设置**非空**的 `roots` 要求 `via trust`(它改变 server 的访问边界),写 `[]`(收窄)不需要;`via trust` 会显示 roots([security.md](security.md#项目级配置的信任))。 - 给 HTTP server 配 `roots` 时,加载时有一条 `warning`:这些本地路径会发往远端服务。 diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 1d45318..1a50134 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -19,9 +19,9 @@ export async function doctorCommand(app: App): Promise { const entry = entryOf.get(server); if (!entry) return { ...base, status: 'error', message: skipped.get(server) ?? 'provider name is not usable' }; try { - const { tools } = await app.listing(entry, { refresh: true, many: true }); + const { tools, protocol } = await app.listing(entry, { refresh: true, many: true }); const warning = await loginStoreWarning(app, server); - return { ...base, status: 'ok', toolCount: tools.length, schemaBytes: Buffer.byteLength(JSON.stringify(tools), 'utf8'), ...(warning ? { warning } : {}) }; + return { ...base, status: 'ok', toolCount: tools.length, ...(protocol ? { protocol } : {}), schemaBytes: Buffer.byteLength(JSON.stringify(tools), 'utf8'), ...(warning ? { warning } : {}) }; } catch (error) { const probe: ServerProbe = { ...base, status: statusOf(error), message: app.redact.scan(error instanceof Error ? error.message : String(error)) }; const next = error instanceof ClientError || error instanceof CredentialError ? error.next : undefined; diff --git a/src/cli/commands/servers.ts b/src/cli/commands/servers.ts index f5b5bd8..e7e1c80 100644 --- a/src/cli/commands/servers.ts +++ b/src/cli/commands/servers.ts @@ -8,6 +8,7 @@ import { ExitCode } from '../../render/exit-codes'; import type { App } from '../app'; import { authState } from '../auth-state'; import { buildProviderTable } from '../directory'; +import { protocolLabel } from '../protocol-label'; import { parseLocal } from '../options'; import { usageFailure } from '../failure'; @@ -39,6 +40,8 @@ export async function serversCommand(app: App, argv: readonly string[]): Promise if (positionals.length > 0) throw usageFailure('via servers takes no arguments', 'via servers -h'); const config = await app.config({ allowErrors: true }); + // What each server negotiated the last time its catalog was fetched; nothing is connected here. + const cache = await app.cache(); const table = buildProviderTable(config.servers); const skipped = new Map(table.skipped.map((s, i) => [s, table.warnings[i]!.message])); const rows: string[][] = []; @@ -55,7 +58,7 @@ export async function serversCommand(app: App, argv: readonly string[]): Promise const skip = skipped.get(server); if (skip) notes.push(skip); const source = server.origin.path ? shorten(server.origin.path, app.io.home) : server.origin.scope; - rows.push([server.provider, describeTransport(server), await authStatus(app, server), source, notes.join('; ')]); + rows.push([server.provider, describeTransport(server), protocolLabel(server.disabled ? undefined : cache.read(server)?.protocol, server.protocolVersion) ?? '-', await authStatus(app, server), source, notes.join('; ')]); } if (rows.length === 0) app.out('no MCP servers found\n'); else app.out(`${alignRows(rows).join('\n')}\n`); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 3fedbc5..0671bd7 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -3,6 +3,8 @@ // Pure: the CLI probes the servers and prints the text; nothing here reads files or the network. import { formatDiagnostic, isCredentialRef, type ConfigDiagnostic, type FrameworkDef, type LoadedConfig, type ServerConfig } from '../config'; import type { CatalogWarning } from '../catalog'; +import type { ProtocolInfo } from '../protocol'; +import { protocolLabel } from './protocol-label'; import { ExitCode } from '../render/exit-codes'; import { shortenHome } from '../config/paths'; import { shellArg, shellWord } from '../render/shell'; @@ -19,6 +21,8 @@ export interface ServerProbe { /** Suggested fix for the `next:` line (e.g. `CredentialError.next`), already redacted. Overrides the derived one. */ next?: string; toolCount?: number; + /** What the connection negotiated (cli.md, `via doctor`); absent when the list came from a source that does not know. */ + protocol?: ProtocolInfo; /** Something to fix that does not stop the server from working (e.g. an unwritable OAuth login store). */ warning?: { message: string; next?: string }; /** Length of the serialized `tools/list` result, for the tokens/turn estimate. */ @@ -97,6 +101,7 @@ interface Row { message?: string; next?: string; warning?: { message: string; next?: string }; + protocol?: ProtocolInfo; } /** Probes, plus config servers nobody probed that are known to be unusable (untrusted, disabled). */ @@ -106,6 +111,7 @@ function collectRows(input: DoctorInput): Row[] { const row: Row = { server: p.server, provider: p.provider, status: p.status }; if (p.message) row.message = oneLine(p.message); if (p.next) row.next = oneLine(p.next); + if (p.protocol) row.protocol = p.protocol; if (p.warning) row.warning = { message: oneLine(p.warning.message), ...(p.warning.next ? { next: oneLine(p.warning.next) } : {}) }; return row; }); @@ -132,7 +138,7 @@ function defaultNext(row: Row, server: ServerConfig | undefined): string[] { return []; } -function renderServers(rows: readonly Row[], home: string | undefined): string[] { +function renderServers(rows: readonly Row[], home: string | undefined, pins: ReadonlyMap): string[] { if (rows.length === 0) return ['servers none configured']; const parts: string[] = [`${rows.filter((r) => r.status === 'ok').length} ok`]; for (const status of ['needs-auth', 'error', 'untrusted', 'disabled'] as const) { @@ -141,6 +147,10 @@ function renderServers(rows: readonly Row[], home: string | undefined): string[] } const lines = [`servers ${parts.join(' · ')}`]; for (const row of rows) { + if (row.status === 'ok') { + const label = protocolLabel(row.protocol, pins.get(row.server)); + if (label) lines.push(` ${row.provider}: MCP ${label}`); + } if (row.status !== 'ok' && row.status !== 'disabled') { lines.push(` ${row.provider}: ${STATUS_LABEL[row.status]}${row.message ? ` (${row.message})` : ''}`); for (const next of row.next?.split('\n') ?? []) lines.push(` next: ${shorten(next, home)}`); @@ -292,7 +302,8 @@ function renderConfigProblems(input: DoctorInput): string[] { export function renderDoctor(input: DoctorInput): DoctorOutput { const rows = collectRows(input); const config = renderConfigProblems(input); - const sections = [renderServers(rows, input.home), renderFrameworks(input), renderSecrets(input.config, input.home)]; + const pins = new Map(input.config.servers.flatMap((s) => (s.protocolVersion !== undefined ? [[s.name, s.protocolVersion] as const] : []))); + const sections = [renderServers(rows, input.home, pins), renderFrameworks(input), renderSecrets(input.config, input.home)]; if (config.length > 0) sections.push(config); let exitCode: number = ExitCode.Ok; diff --git a/src/cli/protocol-label.ts b/src/cli/protocol-label.ts new file mode 100644 index 0000000..d7e9715 --- /dev/null +++ b/src/cli/protocol-label.ts @@ -0,0 +1,11 @@ +// How the negotiated MCP protocol version of a server is shown (cli.md, `via servers` and `via doctor`). +import type { ProtocolInfo } from '../protocol'; + +/** + * `2026-07-28 stateless`, `2025-11-25 stateful`; `pinned` appended when `protocolVersion` fixes it. With + * no connection on record only a pin is known (`2025-11-25 (pinned)`); otherwise `undefined`. + */ +export function protocolLabel(info: ProtocolInfo | undefined, pin: string | undefined): string | undefined { + if (info && info.version !== '') return `${info.version} ${info.stateful ? 'stateful' : 'stateless'}${pin !== undefined ? ' (pinned)' : ''}`; + return pin !== undefined ? `${pin} (pinned)` : undefined; +} diff --git a/test/cli/__snapshots__/cli-servers.txt b/test/cli/__snapshots__/cli-servers.txt index 0570ea9..6f88679 100644 --- a/test/cli/__snapshots__/cli-servers.txt +++ b/test/cli/__snapshots__/cli-servers.txt @@ -1,6 +1,6 @@ -gh http http://127.0.0.1: bearer ~/.config/via/config.yaml -keyless http http://127.0.0.1: bearer (missing env:NOT_SET_ANYWHERE) ~/.config/via/config.yaml -linear http https://mcp.linear.example oauth (not logged in) ~/.config/via/config.yaml -off http http://127.0.0.1: none ~/.config/via/config.yaml disabled -find stdio node none ~/.config/via/config.yaml provider name "find" collides with a built-in subcommand; set "alias" in the server config -figma stdio node none /project/.via.yaml untrusted (via trust /project/.via.yaml) +gh http http://127.0.0.1: - bearer ~/.config/via/config.yaml +keyless http http://127.0.0.1: - bearer (missing env:NOT_SET_ANYWHERE) ~/.config/via/config.yaml +linear http https://mcp.linear.example - oauth (not logged in) ~/.config/via/config.yaml +off http http://127.0.0.1: - none ~/.config/via/config.yaml disabled +find stdio node - none ~/.config/via/config.yaml provider name "find" collides with a built-in subcommand; set "alias" in the server config +figma stdio node - none /project/.via.yaml untrusted (via trust /project/.via.yaml) diff --git a/test/cli/auth.test.ts b/test/cli/auth.test.ts index ce4d5a5..187b85f 100644 --- a/test/cli/auth.test.ts +++ b/test/cli/auth.test.ts @@ -464,7 +464,7 @@ describe('via auth logout', () => { describe('via servers and via doctor with OAuth', () => { it('servers shows the login state in the auth column', async () => { const { srv, sb } = await setup(); - expect((await sb.via(['servers'])).stdout).toMatch(/linear\s+http http:\/\/127\.0\.0\.1:\d+\s+oauth \(not logged in\)/); + expect((await sb.via(['servers'])).stdout).toMatch(/linear\s+http http:\/\/127\.0\.0\.1:\d+\s+-\s+oauth \(not logged in\)/); await login(sb, srv); expect((await sb.via(['servers'], { now: T0 })).stdout).toContain('oauth (logged in, expires in 1h)'); expect((await sb.via(['servers'], { now: T0 + 2 * HOUR })).stdout).toContain('oauth (access token expired, refreshable)'); diff --git a/test/cli/cmd-credentials.test.ts b/test/cli/cmd-credentials.test.ts index 18d94e5..d94632c 100644 --- a/test/cli/cmd-credentials.test.ts +++ b/test/cli/cmd-credentials.test.ts @@ -55,7 +55,7 @@ describe('cmd: credentials', () => { sb.userConfig(`sources: false\nservers:\n gh:\n url: ${srv.url}\n auth: { bearer: ${JSON.stringify(ref)} }\n`); const r = await sb.via(['servers'], { env }); expect(r.code).toBe(0); - expect(r.stdout).toMatch(/^gh {2}http http:\/\/127\.0\.0\.1:|^gh {2}http http:\/\/127\.0\.0\.1:\d+ {2}bearer {2}/); + expect(r.stdout).toMatch(/^gh {2}http http:\/\/127\.0\.0\.1:\d+ {2}- {2}bearer {2}/); expect(r.stdout).not.toContain('missing'); expect(runs()).toBe(0); }); diff --git a/test/cli/doctor.test.ts b/test/cli/doctor.test.ts index 856e06e..7b01669 100644 --- a/test/cli/doctor.test.ts +++ b/test/cli/doctor.test.ts @@ -48,6 +48,25 @@ describe('renderDoctor', () => { await expect(`${out.text}\n`).toMatchFileSnapshot('./__snapshots__/doctor-healthy.txt'); }); + it('shows the protocol each healthy server negotiated, and which versions are pinned', () => { + const out = renderDoctor({ + config: config([server('github'), server('legacy', { protocolVersion: '2025-06-18' }), server('quiet'), server('down')]), + frameworks: BUNDLED_FRAMEWORKS, + probes: [ + probe('github', { protocol: { version: '2026-07-28', stateful: false } }), + probe('legacy', { protocol: { version: '2025-06-18', stateful: true } }), + probe('quiet'), + { server: 'down', provider: 'down', status: 'error', message: 'cannot start down: it does not support protocol version 2026-07-28' }, + ], + }); + expect(out.text.split('\n').slice(0, 4)).toEqual([ + 'servers 3 ok · 1 failed (down)', + ' github: MCP 2026-07-28 stateless', + ' legacy: MCP 2025-06-18 stateful (pinned)', + ' down: failed (cannot start down: it does not support protocol version 2026-07-28)', + ]); + }); + it('says so when there are no servers at all', () => { const out = renderDoctor({ config: config([]), frameworks: BUNDLED_FRAMEWORKS, probes: [] }); expect(out.text.split('\n')[0]).toBe('servers none configured'); diff --git a/test/cli/protocol.test.ts b/test/cli/protocol.test.ts new file mode 100644 index 0000000..fff97da --- /dev/null +++ b/test/cli/protocol.test.ts @@ -0,0 +1,76 @@ +// The protocol version of a server, shown and pinnable (cli.md `via servers` / `via doctor`, config.md +// `protocolVersion`), against a server of each protocol generation. +import { describe, expect, it } from 'vitest'; +import { FAKE_TOKEN, ghConfig, makeSandbox, startFixtureServer, useCleanup, type Generation } from './helpers'; + +async function setup(generation: Generation, extra = '') { + const srv = await startFixtureServer({ token: FAKE_TOKEN, protocol: generation }); + useCleanup(() => srv.close()); + const sb = makeSandbox({ GH_TOKEN: FAKE_TOKEN }); + sb.userConfig(ghConfig(srv.url, extra)); + return { srv, sb }; +} + +const SERVERS_LINE = /^gh +http http:\/\/127\.0\.0\.1:\d+ +(.+?) +bearer/m; +const protocolColumn = (stdout: string): string | undefined => SERVERS_LINE.exec(stdout)?.[1]; + +describe.each([ + ['modern', '2026-07-28 stateless'], + ['legacy', '2025-11-25 stateless'], // the HTTP fixture hands out no session id +] as const)('a %s server', (generation, label) => { + it('is shown by `via servers` once its catalog has been fetched, and by `via doctor` live', async () => { + const { sb } = await setup(generation); + expect(protocolColumn((await sb.via(['servers'])).stdout)).toBe('-'); + expect((await sb.via(['gh'])).code).toBe(0); + expect(protocolColumn((await sb.via(['servers'])).stdout)).toBe(label); + const doctor = await sb.via(['doctor']); + expect(doctor.code).toBe(0); + expect(doctor.stdout).toContain(`gh: MCP ${label}\n`); + }); + + it('is called as before, whichever protocol it speaks', async () => { + const { srv, sb } = await setup(generation); + const r = await sb.via(['gh', 'issue:read', '--owner', 'acme', '--repo', 'api', '--issue-number', '3', '--method', 'get']); + expect(r.code).toBe(0); + expect(r.stdout).toBe('number: 3\ntitle: Support OAuth DCR\nstate: open\n'); + expect(srv.state.calls).toHaveLength(1); + }); +}); + +describe('protocolVersion', () => { + it('pins a version the server speaks: shown as pinned', async () => { + const { sb } = await setup('legacy', ' protocolVersion: 2025-06-18\n'); + expect(protocolColumn((await sb.via(['servers'])).stdout)).toBe('2025-06-18 (pinned)'); + expect((await sb.via(['gh'])).code).toBe(0); + expect(protocolColumn((await sb.via(['servers'])).stdout)).toBe('2025-06-18 stateless (pinned)'); + expect((await sb.via(['doctor'])).stdout).toContain('gh: MCP 2025-06-18 stateless (pinned)\n'); + }); + + it('is an error, not a fallback, when the server does not support it', async () => { + const { sb } = await setup('legacy', ' protocolVersion: 2026-07-28\n'); + const r = await sb.via(['gh']); + expect(r.code).toBe(6); + expect(r.stderr).toContain('error: cannot start gh: it does not support protocol version 2026-07-28; servers.gh.protocolVersion pins it'); + const doctor = await sb.via(['doctor']); + expect(doctor.code).toBe(6); + expect(doctor.stdout).toContain('it does not support protocol version 2026-07-28'); + expect(doctor.stdout).toContain('next: remove protocolVersion from servers.gh, or pin a version the server supports'); + const call = await sb.via(['gh', 'echo_text', '--message', 'x']); + expect(call.code).toBe(6); + expect(call.stderr).toBe('error: cannot start gh: it does not support protocol version 2026-07-28; servers.gh.protocolVersion pins it\nnext: remove protocolVersion from servers.gh, or pin a version the server supports\n'); + }); + + it('names what a stateless server supports when the pin is older', async () => { + const { sb } = await setup('modern', ' protocolVersion: 2025-11-25\n'); + const r = await sb.via(['gh', 'echo_text', '--message', 'x']); + expect(r.code).toBe(6); + expect(r.stderr).toContain('it does not support protocol version 2025-11-25 (it supports 2026-07-28)'); + }); + + it('rejects a value that is not a protocol version, naming the ones that are', async () => { + const { sb } = await setup('legacy', ' protocolVersion: "1999-01-01"\n'); + const r = await sb.via(['servers']); + expect(r.stdout).toContain('servers.gh.protocolVersion: expected one of 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07'); + expect((await sb.via(['gh'])).code).toBe(2); + }); +}); diff --git a/test/config/layer.test.ts b/test/config/layer.test.ts index 293e2f5..41b61be 100644 --- a/test/config/layer.test.ts +++ b/test/config/layer.test.ts @@ -59,6 +59,18 @@ describe('validateLayer', () => { expect(data.servers.get('a')?.roots).toEqual(['.', '~/notes', '/abs', 'C:\\work', 'file:///u', 'FILE:///v']); }); + it('accepts protocolVersion when it names a known revision, and says which are known otherwise', () => { + const ok = validateLayer({ servers: { a: { command: 'x', protocolVersion: '2026-07-28' }, b: { command: 'x', protocolVersion: '2025-03-26' } } }, undefined); + expect(ok.diagnostics).toEqual([]); + expect(ok.data.servers.get('a')?.protocolVersion).toBe('2026-07-28'); + expect(ok.data.servers.get('b')?.protocolVersion).toBe('2025-03-26'); + for (const bad of ['2099-01-01', 'latest', 20260728, true]) { + const { data, diagnostics } = validateLayer({ servers: { a: { command: 'x', protocolVersion: bad } } }, undefined); + expect(diagnostics.map((d) => d.message)).toEqual(['servers.a.protocolVersion: expected one of 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07']); + expect(data.servers.get('a')).toEqual({ command: 'x' }); + } + }); + it('reports type errors with the full path, and drops the bad field only', () => { const { data, diagnostics } = validateLayer({ servers: { github: { timeout: 'soon', url: 'https://g.invalid', concurrency: 2.5 } } }, '/c.yaml'); expect(diagnostics.map((d) => d.message)).toEqual(['servers.github.timeout: expected number', 'servers.github.concurrency: expected positive integer']); From 4607109f6fe0067a16dd01b423faefc94d8759a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:15:09 +0000 Subject: [PATCH 4/9] test: official conformance scenarios in CI, replay server and fixtures for both protocol generations test/conformance/client.mjs drives the built via from @modelcontextprotocol/conformance client scenarios (initialize, tools_call, sse-retry, 14 auth scenarios), known gaps are in expected-failures.yml. The replay server gets a --protocol 2026-07-28 mode and CI runs the server-dependent suites a second time against stateless fixtures (npm run test:stateless). A configured OAuth client id no longer makes the SDK warn about a missing issuer stamp. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- .github/workflows/ci.yml | 30 ++++++ CHANGELOG.md | 2 + bench/README.md | 1 + bench/eval/env.ts | 5 +- bench/eval/tools.ts | 2 +- bench/harness/measure.ts | 5 +- bench/harness/native.ts | 3 +- bench/replay-server.d.mts | 10 +- bench/replay-server.mjs | 63 +++++++++--- docs/development/testing.md | 22 +++++ package.json | 2 + src/client/oauth/provider.ts | 10 +- test/bench/bench.test.ts | 14 ++- test/conformance/client.mjs | 130 +++++++++++++++++++++++++ test/conformance/expected-failures.yml | 21 ++++ 15 files changed, 294 insertions(+), 26 deletions(-) create mode 100644 test/conformance/client.mjs create mode 100644 test/conformance/expected-failures.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 401cc90..63fcf04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,36 @@ jobs: - run: npm run build - run: npm test + # 两代协议(docs/development/testing.md「两代协议」):模拟 server 默认讲旧协议的 `initialize` 握手; + # 这里把它们换成 `2026-07-28` 的无状态协议,再跑一遍依赖 server 的套件(CLI、daemon、client、基准)。 + stateless: + name: check (stateless protocol) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:stateless + + # 官方一致性测试(docs/development/testing.md「官方一致性测试」):套件起一个专用 server,运行 + # test/conformance/client.mjs(它驱动打包后的 via),按 server 看到的请求判定。已知不过的场景记在 + # test/conformance/expected-failures.yml:新增失败、或已经修好却没从清单里删掉,都让这个任务失败。 + conformance: + name: conformance (client scenarios) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:conformance + # Windows 支持(docs/spec/daemon.md、security.md):同一套检查在 Windows 上跑一遍。 # 换行由仓库根的 .gitattributes 固定为 LF,快照与夹具不受 runner 的 core.autocrlf 影响。 windows: diff --git a/CHANGELOG.md b/CHANGELOG.md index ea1a826..c8005a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 测试:官方一致性测试进 CI,端到端测试对两代协议各跑一遍(路线图 e8,[testing.md](docs/development/testing.md#两代协议))。`test/conformance/client.mjs` 把 `@modelcontextprotocol/conformance`(固定 0.1.16)的 client 场景接到打包后的 `via`:`initialize`、`tools_call`、`sse-retry` 和 14 个 `auth/*` 场景通过(用 `via auth login --manual` 加上适配器代为跟随授权重定向);其余场景(CIMD、预注册客户端的密钥、step-up、client credentials、cross-app、elicitation、2025-03-26 的旧授权发现)记在 `test/conformance/expected-failures.yml`,新增失败或修好未删都让 CI 失败。CI 新增 `conformance` 与 `stateless` 两个任务;`npm run test:conformance`、`npm run test:stateless` 本地可跑。模拟 server(`mock-server.mjs`、`cli-server.mjs`)改为一张处理表配两种装配(旧一代用 1.x SDK,无状态一代用 2.x SDK,`VIA_TEST_PROTOCOL=modern` 切换默认),回放 server 增加 `--protocol 2026-07-28`(`VIA_REPLAY_PROTOCOL` 让基准与评测使用它)。 - 协议版本可见、可固定(路线图 e3):`via servers` 多一列协议(上次拉取目录时协商出的版本与连接是否有状态,如 `2026-07-28 stateless`;没有记录时写 `-`),`via doctor` 对探测成功的 server 各加一行 `github: MCP 2026-07-28 stateless`。配置字段 `servers..protocolVersion` 固定一个版本(`2026-07-28`、`2025-11-25`、`2025-06-18`、`2025-03-26`、`2024-11-05`、`2024-10-07`),server 不支持时报错(退出码 6,`next:` 说明去掉或改值)而不是回退,显示时标注 `(pinned)`;不是已知版本的值是 `invalid-field` 错误。它参与目录缓存键与 daemon 会话键,项目级配置设置它不需要 `via trust`。config.md 新增 `protocolVersion` 一节,cli.md 与 agent-integration.md 的 `via servers`、`via doctor` 说明随之更新。**`via servers` 的输出多了一列**,按列解析它的脚本需要跟着改。 - 支持 MCP `2026-07-28` 的无状态协议,新版本优先、自动回退(路线图 e2,[ADR 0017](docs/adr/0017-protocol-versions-newest-first-with-fallback.md)、[daemon.md](docs/spec/daemon.md#协议版本与无状态连接)):建立连接时先发 `server/discover`,server 支持 `2026-07-28` 就按新协议谈(无 `initialize`、无会话 ID,请求带 `MCP-Protocol-Version` 与 `Mcp-Method` 头,`serverInfo` 读自响应的 `_meta`),被拒绝(旧 server 对陌生方法的 4xx)时在同一条连接上退回 `initialize` 握手(`2025-11-25` 至 `2024-10-07`);用户不需要配置,旧版 server 上的行为不变。要点: - 变更通知:新协议下 daemon 订阅 `subscriptions/listen` 流,与旧协议的 `notifications/tools/list_changed` 进同一个刷新入口;流意外断开时按 1 秒到 30 秒的退避重订,重订成功后立即刷新一次目录。 @@ -25,6 +26,7 @@ ### Fixed - 引号审计(路线图 d6,[output.md](docs/spec/output.md#可粘贴的命令行)、[security.md](docs/spec/security.md#打印出来的命令行)):`next:` 提示、浏览视图的 `→ via …` 提示、`did you mean:`/`candidates:` 列表、`usage:` 行和 `alias:` 行、`via trust` 显示的命令行里,来自 server 或用户的文字(拼错的名字、prompt 名、原始工具名、归一化后同名而保留的参数名、文件路径)原来大多原样拼进去,粘贴时 `$(…)`、反引号、引号、换行可以展开或执行。现在全部经 `src/render/shell.ts` 的同一组函数:不用引号的只有字母数字与 `_ . / : + = @ -`;POSIX 用单引号,Windows 用 PowerShell 与 `cmd.exe` 都认的双引号,必要时用 PowerShell 的单引号(词里没有 `cmd.exe` 的语法字符);含控制字符、换行、花引号等没有安全写法的词换成 `<占位符>`(粘贴是语法错误,不会执行);以 `-` 开头的外部值不作位置参数;回显在 `error:` 行里的外部文字,换行与控制字符写成 `\n`、`\u001b` 之类的可见转义,不能再伪造一行 `next:`。可见变化:`via find` 的 `next:` 把查询写成一个词(`via find 'github issue read'`);`via trust` 的 `next:`、`chmod u+w` 的路径含 `~ % , @` 时现在会加引号,Windows 上含 `%` 或 `cmd.exe` 语法字符的路径不再给出命令而是 ``;`via trust` 显示的 stdio 参数里的控制字符写成 `\u000a`(原来是 JSON 的 `\n`)。新增测试用真实的 `sh`、`bash`、`dash` 粘贴引用结果,并用名为 `$(touch PWNED)`、带反引号、引号与换行的工具、参数和 prompt 走完整个命令行。不涉及动词表与规范写法。 +- 配置了 `auth.oauth.clientId`(预注册客户端)的登录,SDK v2 会在 stderr 打印一行 `[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp…`:客户端信息没有标注授权服务器。现在 `clientInformation()` 带上 SDK 解析出的 issuer,不再输出这一行(一致性测试 `auth/pre-registration` 里发现)。 - 叶子工具与资源同名、而叶子的原始名本身就是这个名字时,叶子调不到(实测于 Anysearch:`search` 是叶子,`batch_search` 拆成 `search:batch`)。原来叶子改名为 `kebab(原始名)`,结果还是 `search`,`via anysearch search` 打开的是资源,`via call anysearch/search` 报「is a resource, not a tool」,provider 视图却仍列出 `tools: search`。现在这种情况下资源让出名字,它的工具按规则 6 退回为叶子工具(naming.md 新增规则 7)。cli.md「解析顺序」随之改写。**影响规范写法**:只影响有这种碰撞的 server,被让出的资源下的工具从 `资源:动作` 变为叶子,例如 Anysearch 的 `search:batch` → `batch-search`;原来那个叶子从调不到变为可以用原名调用。动词表与单数化规则没有变化;测试夹具里的真实 server 工具列表拆分结果不变。已缓存的目录按规则指纹自动失效。 - `readOnlyHint` 与 `destructiveHint` 同时为 true 时没有优先级:帮助行同时标 read-only 与 destructive (needs --yes),调用要 `--yes`,超时后又按只读自动重试(实测于 Anysearch 的 `extract`、`sub-domain:get`、`search:batch`)。现在按 MCP 的定义以 `readOnlyHint` 为准:这样的工具是只读的,不要 `--yes`,帮助与视图只标 `read-only`;超时重试与 daemon 回退本来就看 `readOnlyHint`,行为不变(arguments.md「危险操作」、discovery.md)。只给了 `destructiveHint: true` 的工具仍然需要 `--yes`。 diff --git a/bench/README.md b/bench/README.md index 5e89b34..67f7e9c 100644 --- a/bench/README.md +++ b/bench/README.md @@ -151,6 +151,7 @@ $ node bench/replay-server.mjs --tools test/fixtures/tools/github.json \ - 默认是 Streamable HTTP(无状态、JSON 响应),监听就绪后在 stdout 打印一行 JSON;`--stdio` 改为 stdio。 - `--server` 决定使用任务文件里哪个 server 的 `calls`,默认取夹具文件名。 +- 协议:默认讲 `initialize` 握手的旧协议(官方 SDK 1.x);`--protocol 2026-07-28`(模块选项 `protocol`)改讲无状态协议(SDK 2.x,只认 `server/discover`,只会握手的客户端被拒绝),HTTP 与 stdio 都支持。环境变量 `VIA_REPLAY_PROTOCOL=2026-07-28` 让 `bench:tokens` 与 `bench:eval` 在无状态的回放 server 上运行(原生一列的 client 也相应先探测再回退)。结果与协议无关,所以两代 server 的数字应当一致。 - `GET /__replay/calls` 返回已收到的调用日志:`[{ "tool", "args", "matched" }]`,agent 评测用它核对模型到底调了什么。 作为模块使用: diff --git a/bench/eval/env.ts b/bench/eval/env.ts index 464ad25..17edeae 100644 --- a/bench/eval/env.ts +++ b/bench/eval/env.ts @@ -2,7 +2,7 @@ // and, for the CLI conditions, a sandbox (isolated HOME) whose `via` and `mcp2cli` configuration // points at those servers. Every run gets its own, so runs can go in parallel and each call log // belongs to exactly one run. -import { cannedCalls, startReplayHttp, type ReplayLogEntry } from '../replay-server.mjs'; +import { cannedCalls, replayProtocolFromEnv, startReplayHttp, type ReplayLogEntry } from '../replay-server.mjs'; import type { LoadedSuite } from '../harness/load'; import type { RunnerFactory, Runners } from '../harness/measure'; import { createSandbox, type Sandbox } from '../harness/sandbox'; @@ -32,7 +32,8 @@ export async function createEnv(options: { loaded: LoadedSuite; task: Task; cond try { for (const name of loaded.suite.mounted) { const log: ReplayLogEntry[] = []; - const running = await startReplayHttp({ name, tools: loaded.tools.get(name)!, calls: cannedCalls([task], name, loaded.benchDir), log }); + const protocol = replayProtocolFromEnv(); + const running = await startReplayHttp({ name, tools: loaded.tools.get(name)!, calls: cannedCalls([task], name, loaded.benchDir), log, ...(protocol ? { protocol } : {}) }); logs.set(name, log); closers.push(running.close); urls[name] = running.url; diff --git a/bench/eval/tools.ts b/bench/eval/tools.ts index 2586181..0af7a5a 100644 --- a/bench/eval/tools.ts +++ b/bench/eval/tools.ts @@ -41,7 +41,7 @@ export async function nativeTools(env: EvalEnv): Promise { const close = async (): Promise => void (await Promise.all([...clients.values()].map((c) => c.close().catch(() => undefined)))); try { for (const [server, url] of Object.entries(env.urls)) { - const client = new Client({ name: 'via-bench-eval', version: '0.0.0' }); + const client = new Client({ name: 'via-bench-eval', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(new URL(url))); clients.set(server, client); for (const tool of (await client.listTools()).tools as ReplayTool[]) { diff --git a/bench/harness/measure.ts b/bench/harness/measure.ts index 59b147e..01b2e64 100644 --- a/bench/harness/measure.ts +++ b/bench/harness/measure.ts @@ -3,7 +3,7 @@ // is measured in bytes; `estimateTokens` turns bytes into tokens at report time. import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { cannedCalls, startReplayHttp, type ReplayCall, type ReplayLogEntry } from '../replay-server.mjs'; +import { cannedCalls, replayProtocolFromEnv, startReplayHttp, type ReplayCall, type ReplayLogEntry } from '../replay-server.mjs'; import { normalizeOutput, type Exec } from './exec'; import type { LoadedSuite } from './load'; import { measureInjection, measureNativeCalls, type InjectionMeasure, type NativeMeasure } from './native'; @@ -114,7 +114,8 @@ export async function measureSuite(options: MeasureOptions): Promise(url: string, fn: (client: Client) => Promise): Promise { - const client = new Client({ name: 'via-bench-native', version: '0.0.0' }); + // Either generation of replay server (VIA_REPLAY_PROTOCOL): probe, fall back to the handshake. + const client = new Client({ name: 'via-bench-native', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(new URL(url))); try { return await fn(client); diff --git a/bench/replay-server.d.mts b/bench/replay-server.d.mts index 6338c18..d590dbe 100644 --- a/bench/replay-server.d.mts +++ b/bench/replay-server.d.mts @@ -1,4 +1,5 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { Server as StatelessServer } from '@modelcontextprotocol/server'; export interface ReplayTool { name: string; @@ -32,8 +33,13 @@ export interface ReplayOptions { calls?: ReplayCall[]; /** Every `tools/call` received is appended here. */ log?: ReplayLogEntry[]; + /** `2026-07-28` serves the stateless protocol (2.x SDK); absent: the `initialize` handshake of the 1.x SDK. */ + protocol?: typeof STATELESS_PROTOCOL; } +/** The stateless protocol revision the replay server can speak. */ +export declare const STATELESS_PROTOCOL: '2026-07-28'; + export interface TaskLike { calls?: ReadonlyArray<{ server: string; @@ -48,11 +54,13 @@ export interface TaskLike { }>; } +/** `VIA_REPLAY_PROTOCOL` as a `protocol` option: `2026-07-28`, or undefined for the handshake. */ +export declare function replayProtocolFromEnv(env?: Record): typeof STATELESS_PROTOCOL | undefined; export declare function readTools(file: string): ReplayTool[]; export declare function readTaskFile(file: string): unknown; export declare function matchArgs(expected: unknown, actual: unknown): boolean; export declare function cannedCalls(tasks: TaskLike[], server: string, baseDir: string): ReplayCall[]; -export declare function createReplayServer(options: ReplayOptions): Server; +export declare function createReplayServer(options: ReplayOptions): Server & StatelessServer; export declare function startReplayHttp( options: ReplayOptions & { port?: number; host?: string }, ): Promise<{ url: string; log: ReplayLogEntry[]; close(): Promise }>; diff --git a/bench/replay-server.mjs b/bench/replay-server.mjs index fe827fa..7447483 100644 --- a/bench/replay-server.mjs +++ b/bench/replay-server.mjs @@ -4,11 +4,16 @@ // name, the tools and the canned calls are all inputs. // // node bench/replay-server.mjs --tools test/fixtures/tools/github.json \ -// --server github --task bench/tasks/github-triage.yaml [--task ...] [--port 0] [--stdio] +// --server github --task bench/tasks/github-triage.yaml [--task ...] [--port 0] [--stdio] [--protocol 2026-07-28] // // HTTP mode (Streamable HTTP, stateless, JSON responses) prints one JSON line to stdout once it // listens: {"server":"github","url":"http://127.0.0.1:41234/mcp"}. `GET /__replay/calls` returns the // log of every call received so far. Plain ESM so it runs without a build step. +// +// Protocol: by default the server speaks the `initialize` handshake of 2025-11-25 and earlier (1.x SDK). +// `--protocol 2026-07-28` (option `protocol`) serves the stateless protocol instead (2.x SDK: `server/discover`, +// no sessions; a client that only knows the handshake is refused), so the benchmark and the tests can run +// against both generations of server. import { readFileSync } from 'node:fs'; import { createServer } from 'node:http'; import { basename, dirname, isAbsolute, resolve } from 'node:path'; @@ -17,8 +22,22 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { createMcpHandler, Server as StatelessServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import { parse as parseYaml } from 'yaml'; +/** The stateless protocol revision the replay server can speak. */ +export const STATELESS_PROTOCOL = '2026-07-28'; + +/** `VIA_REPLAY_PROTOCOL=2026-07-28` makes the benchmarks and the eval harness replay on the stateless protocol; unset: the handshake. */ +export function replayProtocolFromEnv(env = process.env) { + const value = env.VIA_REPLAY_PROTOCOL; + if (value === undefined || value === '') return undefined; + if (value !== STATELESS_PROTOCOL) throw new Error(`VIA_REPLAY_PROTOCOL: expected ${STATELESS_PROTOCOL} or nothing, got ${value}`); + return value; +} + /** Read a `tools/list` fixture: `{ "tools": [...] }`. */ export function readTools(file) { const parsed = JSON.parse(readFileSync(file, 'utf8')); @@ -78,32 +97,50 @@ export function cannedCalls(tasks, server, baseDir) { * (never a protocol error), so a model that guesses wrong sees an ordinary tool failure. Entries * are tried in order and the first match wins; a call is not "used up" by being answered. * - * @param {{ name?: string, tools: object[], calls?: Array<{ tool: string, args?: object, result: object }>, log?: Array }} options + * @param {{ name?: string, tools: object[], calls?: Array<{ tool: string, args?: object, result: object }>, log?: Array, protocol?: string }} options */ -export function createReplayServer({ name = 'replay', tools, calls = [], log = [] }) { - const server = new Server({ name, version: '0.0.0' }, { capabilities: { tools: {} } }); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name: tool, arguments: args = {} } = request.params; +export function createReplayServer({ name = 'replay', tools, calls = [], log = [], protocol }) { + const listTools = async () => ({ tools }); + const callTool = async (params) => { + const { name: tool, arguments: args = {} } = params; const hit = calls.find((c) => c.tool === tool && (c.args === undefined || matchArgs(c.args, args))); log.push({ tool, args, matched: hit !== undefined }); if (hit) return hit.result; const known = tools.some((t) => t.name === tool); const reason = known ? `no canned result for ${tool} with these arguments` : `unknown tool: ${tool}`; return { content: [{ type: 'text', text: `replay: ${reason}: ${JSON.stringify(args)}` }], isError: true }; - }); + }; + if (protocol === STATELESS_PROTOCOL) { + const server = new StatelessServer({ name, version: '0.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/list', listTools); + server.setRequestHandler('tools/call', (request) => callTool(request.params)); + return server; + } + if (protocol !== undefined) throw new Error(`replay server: unknown protocol ${protocol}`); + const server = new Server({ name, version: '0.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler(ListToolsRequestSchema, listTools); + server.setRequestHandler(CallToolRequestSchema, (request) => callTool(request.params)); return server; } /** * Streamable HTTP front end on a localhost port (0 = ephemeral). Stateless: a Server and a - * transport per request, like the mocks in test/fixtures/mcp/. + * transport per request, like the mocks in test/fixtures/mcp/ (with `protocol: '2026-07-28'` the 2.x SDK's + * request handler does the same and refuses clients that only speak the handshake). * * @returns {Promise<{ url: string, log: Array, close(): Promise }>} */ export async function startReplayHttp({ port = 0, host = '127.0.0.1', ...options }) { const log = options.log ?? []; + const stateless = options.protocol === STATELESS_PROTOCOL ? toNodeHandler(createMcpHandler(() => createReplayServer({ ...options, log }), { legacy: 'reject' })) : undefined; const http = createServer((req, res) => { + if (stateless && !(req.method === 'GET' && req.url === '/__replay/calls')) { + stateless(req, res).catch((error) => { + if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' }); + res.end(String(error)); + }); + return; + } const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { @@ -146,7 +183,7 @@ export async function startReplayHttp({ port = 0, host = '127.0.0.1', ...options // CLI ---------------------------------------------------------------------------------------- function parseArgs(argv) { - const opts = { tasks: [], stdio: false, port: 0, server: undefined, tools: undefined }; + const opts = { tasks: [], stdio: false, port: 0, server: undefined, tools: undefined, protocol: undefined }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--tools') opts.tools = argv[++i]; @@ -154,6 +191,7 @@ function parseArgs(argv) { else if (arg === '--server') opts.server = argv[++i]; else if (arg === '--port') opts.port = Number(argv[++i]); else if (arg === '--stdio') opts.stdio = true; + else if (arg === '--protocol') opts.protocol = argv[++i]; else throw new Error(`unknown argument: ${arg}`); } if (!opts.tools) throw new Error('--tools is required'); @@ -169,8 +207,11 @@ if (process.argv[1] && basename(process.argv[1]) === 'replay-server.mjs') { name: server, tools: readTools(opts.tools), calls: cannedCalls(opts.tasks.map(readTaskFile), server, baseDir), + ...(opts.protocol ? { protocol: opts.protocol } : {}), }; - if (opts.stdio) { + if (opts.stdio && opts.protocol === STATELESS_PROTOCOL) { + serveStdio(() => createReplayServer(options)); + } else if (opts.stdio) { await createReplayServer(options).connect(new StdioServerTransport()); } else { const running = await startReplayHttp({ ...options, port: opts.port }); diff --git a/docs/development/testing.md b/docs/development/testing.md index b1dce58..dbccacc 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -22,6 +22,26 @@ - 慢响应与超时、progress 通知; - 带 `destructiveHint` / `readOnlyHint` 的工具。 +### 两代协议 + +MCP 的 `2026-07-28` 是无状态协议,旧协议是 `initialize` 握手([ADR 0017](../adr/0017-protocol-versions-newest-first-with-fallback.md))。模拟 server 能讲其中任何一代,端到端测试对两代各跑一遍: + +- **同一张处理表,两种 server。** `test/fixtures/mcp/mock-server.mjs` 与 `cli-server.mjs` 把自己回答什么写成一张表(`tools/list`、`tools/call`、`resources/*`、`prompts/*`),`flavors.mjs` 把表装配成旧一代的 server(1.x SDK,`legacy`,默认)或无状态的 server(2.x SDK,`modern`)。工具、资源、prompt、进度、roots、`list_changed` 等行为两代相同;差别只在协议。无状态的 HTTP 端点是 `modern-http.mjs`(`createMcpHandler`,`legacy: 'reject'`:退回握手的客户端会被告知版本不受支持,所以测试不会在旧协议上误过)。stdio 夹具有 `-modern` 孪生脚本(`stdio-server-modern.mjs`、`cli-stdio-server-modern.mjs`)。 +- **选哪一代。** 助手 `startHttpServer`、`startFixtureServer`、`stdioServer`、`CLI_STDIO_SERVER` 默认讲旧一代;环境变量 `VIA_TEST_PROTOCOL=modern`(`test/helpers/generation.ts`)把默认换成无状态一代。专讲某一代的测试(`test/client/protocol.test.ts`、`test/cli/protocol.test.ts`)显式传 `protocol`。`npm run test:stateless` 用 `modern` 重跑依赖 server 的套件(`test/cli`、`test/daemon`、`test/client`、`test/bench`),CI 里是单独的 `stateless` 任务;`npm test` 仍是旧一代。 +- **回放 server。** `bench/replay-server.mjs` 同样有两种:默认旧一代,`--protocol 2026-07-28`(选项 `protocol`)讲无状态协议;`VIA_REPLAY_PROTOCOL=2026-07-28` 让 token 基准与 agent 评测在无状态的回放 server 上跑。 +- **协议版本常量。** via 自己保存的版本列表(`src/protocol-versions.ts`)由 `test/protocol-versions.test.ts` 对照 SDK 检查:旧版列表等于 SDK 的 `SUPPORTED_PROTOCOL_VERSIONS`,无状态版本等于 SDK server 在 `server/discover` 里给出的列表,SDK client 能钉住每个版本连接。SDK 升级后增减版本会让它失败。 +- **协议本身的行为。** `test/client/protocol.test.ts` 覆盖:旧 server 的回退、请求头(`Mcp-Method`、`MCP-Protocol-Version`、没有会话 ID)、`ttlMs`、订阅流的重订、固定版本。 + +### 官方一致性测试 + +[`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance)(固定在 0.1.16)的 client 场景起一个专用 server,运行我们给的命令,再按 server 看到的请求判定。`test/conformance/client.mjs` 是适配器:它在空的 HOME 里(`sources: false`,无 daemon,OAuth 存储用文件)驱动打包后的 `dist/via.js`,和用户用法一致,不绕过 CLI: + +- `initialize`、`tools_call`:`via conf`(握手与 `tools/list`)、`via conf add_numbers --a 2 --b 3`; +- `sse-retry`:调用中途服务端断开事件流,检查 via 按 `retry` 重连并带 `Last-Event-ID`; +- `auth/*`:`via auth login conf --manual`,适配器自己请求授权 URL、取重定向地址再从 stdin 交给 via(相当于人把浏览器最后停在的页面地址粘贴回来),然后用登录后的令牌浏览;场景给的预注册客户端 id 写进 `auth.oauth.clientId`。 + +运行:`npm run test:conformance`(先构建,再 `--suite all`)。已知不过的场景写在 `test/conformance/expected-failures.yml`,每条标明缺口对应的路线图项(CIMD、预注册客户端的密钥、step-up 授权、client credentials 等属 e7,elicitation 属 e5,另有一个 2025-03-26 旧授权规范的发现场景被 SDK 严格的 issuer 校验拒绝):新增失败,或某条已经修好却没从清单里删掉,都让 CI 的 `conformance` 任务失败。升级一致性测试包时先 `npx @modelcontextprotocol/conformance list --client` 看有没有新场景;套件目前的 client 场景最高到 `2025-11-25`,所以这里走的是 via 的回退路径,无状态协议由上面的两代测试覆盖。 + ### 安全相关测试 - 所有快照中不得出现测试凭证值,CI 中对快照做凭证值扫描。 @@ -43,6 +63,8 @@ `.github/workflows/ci.yml` 在每个 PR 和 `main` 的推送上运行: - Node 22、24 上执行 `npm ci`、`npm run typecheck`、`npm run build`、`npm test`(含快照、拆分基线、数据文件校验、凭证扫描); +- `stateless` 任务:把模拟 server 换成 `2026-07-28` 的无状态协议再跑依赖 server 的套件(`npm run test:stateless`,见[两代协议](#两代协议)); +- `conformance` 任务:官方一致性测试的 client 场景(`npm run test:conformance`,见[官方一致性测试](#官方一致性测试)); - `windows-latest`(Node 22)上执行同样四步,再用空的用户目录运行 `dist/via.js`(`--version`、浏览、`doctor`、`daemon status`); - `auto-merge.yml`:CI 在仓库所有者从本仓库分支开的 PR 上通过后,开启 squash 自动合并,合入后删除该 PR 的分支(合入后分支又有新提交,或它是其他打开的 PR 的基准时保留)。合并用的是 `GITHUB_TOKEN`,所以仓库的「自动删除分支」设置不会生效,删除由这个工作流完成;同样因为是 `GITHUB_TOKEN`,合入 `main` 的提交不会再触发 `main` 上的 CI。 - 开发工具链(vitest、tsup)需要 Node 22.12+,而发布产物声明支持 Node 20:在 22 上打包后,换到 Node 20 用空的 HOME 运行 `dist/via.js`(`--version`、`via`、`via doctor`,覆盖懒加载的 chunk)。 diff --git a/package.json b/package.json index 471567c..f066c22 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "prepack": "npm run build", "test": "vitest run", "test:watch": "vitest", + "test:stateless": "VIA_TEST_PROTOCOL=modern vitest run test/cli test/daemon test/client test/bench", + "test:conformance": "npm run build && npx -y @modelcontextprotocol/conformance@0.1.16 client --command \"node test/conformance/client.mjs\" --suite all --expected-failures test/conformance/expected-failures.yml", "typecheck": "tsc --noEmit", "build:bin": "bun scripts/build-bin.ts", "eval:search": "tsup scripts/eval-search.ts --no-config --format esm --target node20 --platform node --out-dir .build/scripts --silent && node .build/scripts/eval-search.js", diff --git a/src/client/oauth/provider.ts b/src/client/oauth/provider.ts index 23a1dd6..b96095a 100644 --- a/src/client/oauth/provider.ts +++ b/src/client/oauth/provider.ts @@ -10,7 +10,7 @@ // a browser, and it works the same in-process and inside a daemon session. // // The SDK is loaded on demand; importing this module costs nothing at CLI start. -import type { FetchLike, OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthDiscoveryState, OAuthTokens } from '@modelcontextprotocol/client'; +import type { FetchLike, OAuthClientInformationContext, OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthDiscoveryState, OAuthTokens } from '@modelcontextprotocol/client'; import { MIN_REDACTED_LITERAL_LENGTH } from '../../config/credentials'; import { LoginRequiredError, oauthErrorCode, TokenRefreshError } from './errors'; import { oauthKey, secretsOf, type OAuthRecord, type OAuthStore, type SetResult } from './store'; @@ -292,10 +292,14 @@ export class ViaOAuthProvider implements OAuthClientProvider { return this.options.login?.state ?? ''; } - clientInformation(): OAuthClientInformationMixed | undefined { + clientInformation(ctx?: OAuthClientInformationContext): OAuthClientInformationMixed | undefined { // Only this attempt's registration is used. One from an earlier login was made for another // redirect URI (every login listens on a new port), so the record's client is not consulted. - return this.configuredClient() ?? this.draft.client; + // A configured client id is stamped with the authorization server the SDK resolved: unstamped it + // would make the SDK print a warning about credentials it cannot bind to an issuer. + const configured = this.configuredClient(); + if (configured) return ctx?.issuer ? { ...configured, issuer: ctx.issuer } : configured; + return this.draft.client; } saveClientInformation(info: OAuthClientInformationMixed): void { diff --git a/test/bench/bench.test.ts b/test/bench/bench.test.ts index 1c4e818..45f303c 100644 --- a/test/bench/bench.test.ts +++ b/test/bench/bench.test.ts @@ -61,6 +61,9 @@ describe('suite files', () => { }); }); +// The replay server speaks either generation of the protocol (bench/replay-server.mjs); the answers do not depend on it. +const PROTOCOLS = [['the initialize handshake', undefined], ['the stateless protocol', '2026-07-28']] as const; + describe('replay server', () => { const closers: Array<() => Promise> = []; afterEach(async () => { @@ -68,7 +71,7 @@ describe('replay server', () => { }); async function connect(url: string): Promise { - const client = new Client({ name: 'bench-test', version: '0.0.0' }); + const client = new Client({ name: 'bench-test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(new URL(url))); closers.push(() => client.close()); return client; @@ -81,13 +84,14 @@ describe('replay server', () => { expect(matchArgs({ a: { b: 1 } }, { a: { b: 1, c: 2 } })).toBe(true); }); - it('serves the fixture tools/list and the canned results, and fails everything else with isError', async () => { + it.each(PROTOCOLS)('serves the fixture tools/list and the canned results, and fails everything else with isError over %s', async (_label, protocol) => { const tools = readTools(`${root}test/fixtures/tools/linear.json`); const task = loadSuite(root, ['linear-my-work']).tasks[0]!; const calls = cannedCalls([task], 'linear', `${root}bench`); - const running = await startReplayHttp({ name: 'linear', tools, calls }); + const running = await startReplayHttp({ name: 'linear', tools, calls, ...(protocol ? { protocol } : {}) }); closers.push(running.close); const client = await connect(running.url); + expect(client.getNegotiatedProtocolVersion()).toBe(protocol ?? '2025-11-25'); expect((await client.listTools()).tools.map((t) => t.name)).toEqual(tools.map((t) => t.name)); @@ -111,10 +115,10 @@ describe('replay server', () => { expect(over.map((e) => e.tool)).toEqual(['get_issue', 'no_such_tool', 'list_teams']); }); - it('can be re-targeted while running (the benchmark swaps canned calls per task)', async () => { + it.each(PROTOCOLS)('can be re-targeted while running (the benchmark swaps canned calls per task) over %s', async (_label, protocol) => { const tools = readTools(`${root}test/fixtures/tools/cloudflare.json`); const calls = [{ tool: 'whoami', result: { content: [{ type: 'text', text: 'first' }] } }]; - const running = await startReplayHttp({ name: 'cloudflare', tools, calls }); + const running = await startReplayHttp({ name: 'cloudflare', tools, calls, ...(protocol ? { protocol } : {}) }); closers.push(running.close); const client = await connect(running.url); expect(JSON.stringify((await client.callTool({ name: 'whoami', arguments: {} })).content)).toContain('first'); diff --git a/test/conformance/client.mjs b/test/conformance/client.mjs new file mode 100644 index 0000000..526bcb9 --- /dev/null +++ b/test/conformance/client.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Adapter between the official MCP conformance suite (`@modelcontextprotocol/conformance`, client scenarios) +// and the `via` CLI: the suite starts a purpose-built server per scenario and runs this script with the +// server's URL as the last argument and the scenario name in MCP_CONFORMANCE_SCENARIO (the scenario's data +// in MCP_CONFORMANCE_CONTEXT, a JSON object). The script drives the built CLI (`dist/via.js`) the way a user +// would, in an empty home, and the suite judges what the server saw on the wire (docs/development/testing.md, +// "官方一致性测试"). +// +// npm run build && npx -y @modelcontextprotocol/conformance client --command "node test/conformance/client.mjs" --scenario initialize +// +// A scenario this adapter does not drive exits non-zero, so the suite records it as a failure rather than a pass. +import { spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scenario = process.env.MCP_CONFORMANCE_SCENARIO; +const serverUrl = process.argv.at(-1); +if (!scenario) fail('MCP_CONFORMANCE_SCENARIO is not set'); +if (!serverUrl || !/^https?:\/\//i.test(serverUrl)) fail(`missing or invalid server URL (got: ${serverUrl ?? ''})`); + +// Scenario data (a pre-registered client, …). Absent for most scenarios. +const context = (() => { + try { + return JSON.parse(process.env.MCP_CONFORMANCE_CONTEXT || '{}'); + } catch { + return {}; + } +})(); +const oauth = scenario.startsWith('auth/'); + +const here = dirname(fileURLToPath(import.meta.url)); +const via = resolve(here, '..', '..', 'dist', 'via.js'); +const home = mkdtempSync(join(tmpdir(), 'via-conformance-')); +mkdirSync(join(home, '.config', 'via'), { recursive: true }); +// `sources: false`: nothing from the machine running the suite leaks in; no daemon: one process, one connection. +const auth = oauth ? ` auth: { oauth: ${context.client_id ? `{ clientId: ${JSON.stringify(context.client_id)} }` : '{}'} }\n` : ''; +writeFileSync(join(home, '.config', 'via', 'config.yaml'), `sources: false\nservers:\n conf:\n url: ${serverUrl}\n timeout: 30\n${auth}`); +const env = { + PATH: process.env.PATH, + HOME: home, + USERPROFILE: home, + XDG_CACHE_HOME: join(home, 'cache'), + XDG_DATA_HOME: join(home, 'data'), + VIA_NO_DAEMON: '1', + VIA_OAUTH_STORE: 'file', +}; + +/** Run the CLI; resolves with its exit code, output goes to the suite's log. */ +function runVia(args, { stdin } = {}) { + return new Promise((resolveRun, reject) => { + const child = spawn(process.execPath, [via, ...args], { env, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'] }); + child.once('error', reject); + child.once('exit', (code) => resolveRun(code ?? 1)); + if (stdin !== undefined) child.stdin.end(stdin); + }); +} + +/** + * `via auth login conf --manual` without a browser: the suite's authorization endpoint approves by redirecting, + * so this script fetches the URL via prints, takes the redirect target and hands it to via on stdin, which + * is what a person does by pasting the page the browser ended up on. + */ +function login() { + return new Promise((resolveLogin, reject) => { + const child = spawn(process.execPath, [via, 'auth', 'login', 'conf', '--manual'], { env, stdio: ['pipe', 'pipe', 'inherit'] }); + let out = ''; + let answered = false; + child.stdout.on('data', (chunk) => { + out += chunk; + const url = /https?:\/\/\S+/.exec(out)?.[0]; + if (!url || answered) return; + answered = true; + fetch(url, { redirect: 'manual' }).then( + (response) => child.stdin.end(`${response.headers.get('location') ?? ''}\n`), + (error) => { + console.error(`could not follow the authorization URL: ${error.message}`); + child.kill(); + }, + ); + }); + child.once('error', reject); + child.once('exit', (code) => (code === 0 ? resolveLogin() : reject(new Error(`via auth login exited with code ${code}`)))); + }); +} + +async function expectOk(args, options) { + const code = await runVia(args, options); + if (code !== 0) throw new Error(`via ${args.join(' ')} exited with code ${code}`); +} + +try { + switch (scenario) { + case 'initialize': + // The handshake (or the probe, then the handshake), then the tool list: the scenario's server only + // answers `initialize` and `tools/list`. + await expectOk(['conf']); + break; + case 'tools_call': + // The server has an `add_numbers` tool and records a check when it is called with numbers. + await expectOk(['conf']); + await expectOk(['conf', 'add_numbers', '--a', '2', '--b', '3']); + break; + case 'sse-retry': + // The server closes the event stream in the middle of a tool call and expects the client to reconnect + // within the retry time it announced. Only the reconnect matters here, so the call is cut short. + await expectOk(['conf']); + await runVia(['conf', 'test_reconnection', '--timeout', '20']); + break; + default: + if (oauth) { + // Authorization scenarios: log in (discovery, registration, PKCE), then use the token. + await login(); + await expectOk(['conf']); + break; + } + fail(`scenario not driven by the via adapter: ${scenario}`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} finally { + rmSync(home, { recursive: true, force: true }); +} + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/test/conformance/expected-failures.yml b/test/conformance/expected-failures.yml new file mode 100644 index 0000000..682298f --- /dev/null +++ b/test/conformance/expected-failures.yml @@ -0,0 +1,21 @@ +# Client scenarios of @modelcontextprotocol/conformance that via does not pass yet (docs/development/testing.md, +# "官方一致性测试"). CI fails on a scenario that fails and is not listed here (a regression), and on one that passes and +# is still listed (remove the entry). Each entry is a gap with an owner in docs/roadmap.md, not a decision. +client: + # e7: CIMD (a client id that is a URL to a metadata document) is not implemented; via registers dynamically. + - auth/basic-cimd + # e7: a pre-registered client is public (`clientId` only); there is nowhere to configure its secret yet, and + # the scenario expects HTTP Basic client authentication. + - auth/pre-registration + # e7: step-up authorization (a 403 `insufficient_scope` on a call asks for a new login with more scope) is not + # implemented; via reports the 403 and tells the person to log in again. + - auth/scope-step-up + # The SDK checks the issuer of the authorization server metadata strictly (RFC 8414 3.3); the 2025-03-26 scenario + # serves metadata whose issuer differs from the URL it was fetched from. + - auth/2025-03-26-oauth-metadata-backcompat + # e5: via does not implement elicitation (and never samples: it does not call a model). + - elicitation-sep1034-client-defaults + # e7: the OAuth client-credentials extension and cross-app access (headless / enterprise grants). + - auth/client-credentials-jwt + - auth/client-credentials-basic + - auth/cross-app-access-complete-flow From 83f10e414875e7de5d7c24df2ccd703a27b1a84e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:24:49 +0000 Subject: [PATCH 5/9] feat(client): via's own outbound requests honor HTTPS_PROXY, HTTP_PROXY and NO_PROXY The MCP HTTP transport, OAuth (login, refresh, revoke) and the embedding endpoint now go through the proxy the environment asks for (names case-insensitive, HTTPS_PROXY for https URLs, NO_PROXY matching). Nothing is loaded without a proxy variable; undici's ProxyAgent is imported on the first proxied request. An unusable proxy is an error, never a direct connection; proxy credentials are redacted and via doctor shows the effective proxy with userinfo hidden. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- CHANGELOG.md | 1 + docs/development/implementation.md | 1 + docs/guides/agent-integration.md | 5 +- docs/spec/cli.md | 2 +- docs/spec/config.md | 12 ++ docs/spec/security.md | 3 +- package-lock.json | 10 ++ package.json | 1 + src/cli/backend.ts | 3 +- src/cli/commands/auth.ts | 11 +- src/cli/commands/doctor.ts | 3 +- src/cli/doctor.ts | 14 +- src/cli/semantic.ts | 9 +- src/client/errors.ts | 4 + src/client/oauth/index.ts | 4 +- src/client/session.ts | 19 ++- src/daemon/sessions.ts | 2 + src/proxy.ts | 221 +++++++++++++++++++++++++++++ test/cli/proxy.test.ts | 115 +++++++++++++++ test/helpers/proxy-server.ts | 71 +++++++++ test/proxy.test.ts | 198 ++++++++++++++++++++++++++ 21 files changed, 698 insertions(+), 11 deletions(-) create mode 100644 src/proxy.ts create mode 100644 test/cli/proxy.test.ts create mode 100644 test/helpers/proxy-server.ts create mode 100644 test/proxy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8005a1..1dd0e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- via 自己发出的 HTTP 请求遵守 `HTTPS_PROXY`、`HTTP_PROXY`、`NO_PROXY`(路线图 f2,[config.md](docs/spec/config.md#代理)、[security.md](docs/spec/security.md)):连接 HTTP 的 MCP server、OAuth(登录、刷新、撤销)和 `search.semantic` 的 embedding 请求。此前这些变量只转发给 stdio 子进程,公司网络里连不上远程 server。变量名不分大小写(同时存在时大写优先);https URL 用 `HTTPS_PROXY`、没有则 `HTTP_PROXY`,http URL 只用 `HTTP_PROXY`;`NO_PROXY` 支持 `*`、主机(含子域)、`.后缀`、`host:port`、IP 字面量,不理解 CIDR,回环地址不会被自动排除;`socks5://` 等不支持的代理是错误(退出码 2),不会悄悄直连。没有设置代理变量时不加载任何东西(undici 的 `ProxyAgent` 在第一个要走代理的请求时按需加载,新增运行时依赖 `undici` ^7)。`via doctor` 新增一节 `proxy`,显示生效的代理,用户名和密码显示为 `***@`;代理的用户名与密码计入脱敏名单。daemon 里的请求用 daemon 自己的环境,改了变量后 `via daemon stop`。**`via doctor` 的输出多了一节 `proxy`**。 - 测试:官方一致性测试进 CI,端到端测试对两代协议各跑一遍(路线图 e8,[testing.md](docs/development/testing.md#两代协议))。`test/conformance/client.mjs` 把 `@modelcontextprotocol/conformance`(固定 0.1.16)的 client 场景接到打包后的 `via`:`initialize`、`tools_call`、`sse-retry` 和 14 个 `auth/*` 场景通过(用 `via auth login --manual` 加上适配器代为跟随授权重定向);其余场景(CIMD、预注册客户端的密钥、step-up、client credentials、cross-app、elicitation、2025-03-26 的旧授权发现)记在 `test/conformance/expected-failures.yml`,新增失败或修好未删都让 CI 失败。CI 新增 `conformance` 与 `stateless` 两个任务;`npm run test:conformance`、`npm run test:stateless` 本地可跑。模拟 server(`mock-server.mjs`、`cli-server.mjs`)改为一张处理表配两种装配(旧一代用 1.x SDK,无状态一代用 2.x SDK,`VIA_TEST_PROTOCOL=modern` 切换默认),回放 server 增加 `--protocol 2026-07-28`(`VIA_REPLAY_PROTOCOL` 让基准与评测使用它)。 - 协议版本可见、可固定(路线图 e3):`via servers` 多一列协议(上次拉取目录时协商出的版本与连接是否有状态,如 `2026-07-28 stateless`;没有记录时写 `-`),`via doctor` 对探测成功的 server 各加一行 `github: MCP 2026-07-28 stateless`。配置字段 `servers..protocolVersion` 固定一个版本(`2026-07-28`、`2025-11-25`、`2025-06-18`、`2025-03-26`、`2024-11-05`、`2024-10-07`),server 不支持时报错(退出码 6,`next:` 说明去掉或改值)而不是回退,显示时标注 `(pinned)`;不是已知版本的值是 `invalid-field` 错误。它参与目录缓存键与 daemon 会话键,项目级配置设置它不需要 `via trust`。config.md 新增 `protocolVersion` 一节,cli.md 与 agent-integration.md 的 `via servers`、`via doctor` 说明随之更新。**`via servers` 的输出多了一列**,按列解析它的脚本需要跟着改。 - 支持 MCP `2026-07-28` 的无状态协议,新版本优先、自动回退(路线图 e2,[ADR 0017](docs/adr/0017-protocol-versions-newest-first-with-fallback.md)、[daemon.md](docs/spec/daemon.md#协议版本与无状态连接)):建立连接时先发 `server/discover`,server 支持 `2026-07-28` 就按新协议谈(无 `initialize`、无会话 ID,请求带 `MCP-Protocol-Version` 与 `Mcp-Method` 头,`serverInfo` 读自响应的 `_meta`),被拒绝(旧 server 对陌生方法的 4xx)时在同一条连接上退回 `initialize` 握手(`2025-11-25` 至 `2024-10-07`);用户不需要配置,旧版 server 上的行为不变。要点: diff --git a/docs/development/implementation.md b/docs/development/implementation.md index 7970878..26ebe8e 100644 --- a/docs/development/implementation.md +++ b/docs/development/implementation.md @@ -7,6 +7,7 @@ | 用途 | 包 | | --- | --- | | MCP client(stdio、Streamable HTTP、OAuth) | `@modelcontextprotocol/client`(官方 TypeScript SDK v2,client 端自带 OAuth 流程支持;是目前唯一支持 `2026-07-28` 无状态协议的官方实现,见下「MCP SDK」) | +| 出站 HTTP 代理 | `undici` 的 `ProxyAgent`(仅在设置了代理变量时按需加载;锁在 7.x:Node 内置的 `fetch` 带的 undici 版本拒绝 8.x 的 dispatcher) | | `via serve` 的 MCP server | `@modelcontextprotocol/server`(同一代 SDK 的 server 端) | | 固定子命令解析 | 全局选项可以出现在任何位置(包括工具参数之间),0.1 用 `cli/options.ts` 里的小解析器先取走它们,不引入 `commander`;工具参数由 schema 驱动的自定义解析器处理 | | schema 校验 | `ajv` | diff --git a/docs/guides/agent-integration.md b/docs/guides/agent-integration.md index c4159d9..94f30f8 100644 --- a/docs/guides/agent-integration.md +++ b/docs/guides/agent-integration.md @@ -16,6 +16,8 @@ servers 9 ok · 1 needs auth (linear) linear: needs auth next: via auth login linear +proxy none + frameworks still injecting MCP schemas: claude-code 6 servers · ~41k tokens/turn hint: 启动时用 --strict-mcp-config 加载空配置,或在 /mcp 中逐个禁用;默认已用 tool search 延迟加载 schema,设 ENABLE_TOOL_SEARCH=false 才会全量注入 @@ -27,9 +29,10 @@ secrets: next: set servers.exa.env.EXA_API_KEY to an env:, file: or keychain: reference in your via config ``` -输出分四节,节间空一行: +输出分五节,节间空一行: - **servers**:按状态计数(`ok`、`needs auth`、`failed`、`untrusted`、`disabled`),非 ok 的状态列出 provider 名;探测成功的 server 各占一行写出协商出的 MCP 协议版本和连接是否有状态(` github: MCP 2026-07-28 stateless`;`protocolVersion` 固定了版本的加 `(pinned)`,[config.md](../spec/config.md#protocolversion));需要鉴权、连接失败、未信任的 server 各占一行并附 `next:`(OAuth server 为 `via auth login `,未信任的项目级文件为 `via trust <路径>`,凭证缺失时用凭证解析给出的提示)。 +- **proxy**:via 自己的 HTTP 请求(MCP server、OAuth、embedding)生效的代理:`proxy none`,或每个代理一行,用户名和密码显示为 `***@`,后面是 `NO_PROXY` 的清单([config.md](../spec/config.md#代理))。 - **frameworks still injecting MCP schemas**:按框架归并,数据来自 server 定义所在的框架配置(`origin.framework`),以及 `conflicts` 里被忽略的其他框架定义(同名 server 在多个框架里各被注入一次)。框架里可能已经关掉的 server 无从得知,所以按「仍在注入」估算。每个框架一行:server 数与 token 估算,下一行是 `data/frameworks/` 中的 `disableHint`。 - **secrets**:明文凭证告警,见下。 - **config**:配置诊断(`error`、`warning`,不含 `info`;明文凭证的告警并入 secrets,不重复)和目录告警(与内置子命令重名、重复的 provider),仅在有内容时出现。项目配置里的 `cmd:`(`cmd-forbidden`)附 `next:` 说明改在用户配置里定义;信任记录损坏附 `next:` 说明删除后重新 `via trust`。 diff --git a/docs/spec/cli.md b/docs/spec/cli.md index 764ead8..90d090e 100644 --- a/docs/spec/cli.md +++ b/docs/spec/cli.md @@ -74,7 +74,7 @@ via prompts [<名称> [参数]] 列出 MCP prompt;带名称时 - `via trust`:显示并记录对项目级配置文件的信任,也能列出、撤销记录;细节、确认方式和退出码见 [security.md](security.md#via-trust)。它读取配置但不连接 server,配置里有 error 级诊断时照常工作(不执行任何工具)。确认默认在终端里完成;`--yes` 只在带显式路径并设置 `VIA_ALLOW_NONINTERACTIVE_TRUST=1` 时有效。 - `via auth`:`login [--manual]`、`status []`、`logout `,只用于 `auth: oauth` 的 HTTP server(provider 接受唯一前缀)。provider 未配置为 OAuth 退出码 2,并提示把 `servers..auth` 设为 `oauth`;项目级配置里未信任的 server 退出码 5(登录会把授权发往仓库指定的地址)。`login` 的等待时间默认 300 秒,`--timeout <秒>` 覆盖。其余行为见 [auth.md](auth.md#oauth)。 - `via sync`:向各 server(或指定的 provider)重新拉取 `tools/list` 并更新缓存,每个 provider 一行(`gh 14 tools` 或 `linear needs auth: …`);有失败时退出码取其中最大的,并在 stderr 提示 `next: via doctor`。 -- `via doctor`:对每个 server 实时探测(不读缓存,成功后更新缓存),再输出检测结果;探测成功的 server 各占一行,写出这次协商出的协议版本与连接是否有状态(`github: MCP 2026-07-28 stateless`,固定了的加 `(pinned)`,[agent-integration.md](../guides/agent-integration.md#via-doctor检测与关闭提示));固定的版本 server 不支持时是连接失败,退出码 6;退出码取各项问题中最高的一个(见[退出码](output.md#退出码))。 +- `via doctor`:对每个 server 实时探测(不读缓存,成功后更新缓存),再输出检测结果;探测成功的 server 各占一行,写出这次协商出的协议版本与连接是否有状态(`github: MCP 2026-07-28 stateless`,固定了的加 `(pinned)`;之后一节 `proxy` 显示生效的代理([config.md](config.md#代理)),[agent-integration.md](../guides/agent-integration.md#via-doctor检测与关闭提示));固定的版本 server 不支持时是连接失败,退出码 6;退出码取各项问题中最高的一个(见[退出码](output.md#退出码))。 - `via prompt`:输出一段带标记的说明块,`` 到 `` 之间是说明本身与 provider 列表(按字母序,[agent-integration.md](../guides/agent-integration.md#via-prompt给-agent-的说明))。 - `--out <文件>`:路径相对当前目录。文件不存在时创建;已有标记块时只替换两个标记之间的内容,标记之外不动(输出 `· updated the via block in <文件> · N bytes`);没有标记时追加到末尾、前面空一行(输出 `· appended to <文件> · N bytes`);创建时输出 `· wrote <文件> · N bytes`。标记不成对或出现多对时拒绝,退出码 5,并提示手工修——位置猜错会写坏用户自己的文字。 - `--check <文件>`:只比对不写入。块与当前输出一致时退出码 0,stderr 输出 `· <文件> is up to date`;没有标记块或内容过期时退出码 1,`next:` 为 `via prompt --out <文件>`;标记不可用退出码 5。 diff --git a/docs/spec/config.md b/docs/spec/config.md index bea1c80..d0d1f29 100644 --- a/docs/spec/config.md +++ b/docs/spec/config.md @@ -134,6 +134,18 @@ search: - 合并时整体替换,不按元素合并。项目配置设置**非空**的 `roots` 要求 `via trust`(它改变 server 的访问边界),写 `[]`(收窄)不需要;`via trust` 会显示 roots([security.md](security.md#项目级配置的信任))。 - 给 HTTP server 配 `roots` 时,加载时有一条 `warning`:这些本地路径会发往远端服务。 +### 代理 + +via 自己发出的 HTTP 请求——连接 HTTP 的 MCP server、OAuth(登录、刷新、撤销令牌)、`search.semantic` 的 embedding 端点——遵守环境变量 `HTTPS_PROXY`、`HTTP_PROXY`、`NO_PROXY`。stdio 子进程收到同样的变量(见下节),所以它们自己的请求也走代理。没有配置项:代理是环境的属性,不属于某个 server,也不能由项目级配置设置。 + +- **变量名不分大小写**(`https_proxy` 也行);两种写法同时存在时用大写的。 +- **选哪个代理**:请求 https URL 用 `HTTPS_PROXY`,没有就用 `HTTP_PROXY`;请求 http URL 只用 `HTTP_PROXY`(只设了 `HTTPS_PROXY` 时,本机回环上的 http server 不会被送去代理)。值可以写成 `http://[user:password@]host:port`、`https://…`,或不带协议的 `host:port`(按 http 代理处理);userinfo 作为代理的 Basic 认证。http 目标走绝对形式的转发请求,https 目标用 `CONNECT` 隧道。 +- **`NO_PROXY`**:逗号分隔。`*` 表示全部;`example.com` 表示这个主机及其子域;`.example.com` 与 `*.example.com` 只表示子域;`host:port` 限定端口;IP 字面量(`127.0.0.1`、`[::1]`)。不在清单里的一律走代理,**`localhost` 与回环地址也不例外**,需要时自己写进 `NO_PROXY`。不理解 CIDR 段。只设了 `NO_PROXY`、没有代理变量时什么都不发生。 +- **不可用的代理是错误,不是直连**:代理地址不是 URL,或协议不是 http / https(如 `socks5://`),用到它的请求失败(退出码 2,`next:` 提示修正或取消这个变量),不会悄悄绕过代理直接连出去。错误信息不含代理地址里的用户名和密码。 +- **daemon**:daemon 里的请求用 **daemon 自己的环境**(由第一个拉起它的 CLI 继承),与 `env:` 凭证的解析一致([daemon.md](daemon.md#环境与工作目录));代理地址和它的密码不经过 socket。改了代理变量后 `via daemon stop`,让下一次调用拉起的 daemon 带上新值。进程内模式、`via auth`、`via find` 用 CLI 自己的环境。 +- **`via doctor`** 在 servers 之后用一节显示生效的代理:`proxy none`,或每个代理一行(`https http://***@proxy.corp:3128 (HTTPS_PROXY)`,用户名和密码显示为 `***@`,不会出现原文)和 `no proxy for … (NO_PROXY)`;不可用的代理在行尾说明原因。显示的是 `via doctor` 进程的环境,与 daemon 的环境可能不同。 +- 实现与冷启动:没有设置代理变量时只是请求发出前扫描一遍环境;设置了才在第一个要走代理的请求时加载 undici 的 `ProxyAgent`(打进一个按需加载的分块,要求 Node.js 20.18.1 及以上;Bun 的单文件版本用 `fetch` 自带的 `proxy` 选项)。证书用 `NODE_EXTRA_CA_CERTS`(企业代理做 TLS 检查时),via 不提供关闭证书校验的选项。 + ### stdio 子进程的环境 子进程不继承 via 的全部环境变量,避免 shell 里其他 server 的令牌泄给每个子进程。环境由两部分合并而成,后者覆盖前者: diff --git a/docs/spec/security.md b/docs/spec/security.md index f2630f2..e7d14be 100644 --- a/docs/spec/security.md +++ b/docs/spec/security.md @@ -19,6 +19,7 @@ via 与 agent 以同一用户、在同一个 shell 环境中运行,因此**只 | OAuth token 出现在输出里 | access token、refresh token、ID token 与客户端 secret 一律计入脱敏列表;授权 URL 只含 `state` 与 PKCE challenge,不含秘密;回调监听只绑 `127.0.0.1` 并校验 `Host` 与 `state`;调用过程永远不会启动授权 | | 授权被发往仓库指定的地址 | `via auth login` 对未信任的项目级 server 退出码 5 | | 本机目录被告知 server(roots) | 默认不声明 `roots` 能力,server 看不到任何本机目录;只有用户在配置里写下 `roots` 才声明。项目级配置设置非空 `roots` 需要 `via trust`,`via trust` 会列出;`[]` 只会收窄,不需要。给 HTTP server 配 `roots` 时告警(本地路径发往远端)。via 不会因为 server 的请求改变 roots,会话的 roots 在其生命周期内固定 | +| 代理密码出现在输出里,或请求绕过代理直连 | `HTTPS_PROXY` / `HTTP_PROXY` 里的用户名和密码计入脱敏名单;`via doctor` 把 userinfo 显示为 `***@`;网络层错误信息在抛出前先抹掉 `user:pass@` 与已知的密码;代理地址不可用时请求失败(退出码 2),不退回直连;代理变量只来自环境,项目级配置无法设置,代理密码不经过 daemon 的 socket([config.md](config.md#代理)) | | 工具描述与查询被发往第三方 embedding 端点 | `search.semantic` 默认关闭,只在用户配置里生效(项目与框架配置里的被忽略并告警);见下文[embedding 检索的数据外流](#embedding-检索的数据外流) | ## 打印出来的命令行 @@ -108,7 +109,7 @@ via 在 POSIX 上靠文件模式(目录 0700、文件 0600)保护信任记 - **传输与凭证**:`url` 必须是 https(回环地址除外),不含内嵌凭证;凭证只来自 `auth` 的凭证引用,在内存中解析,随请求头发出;不跟随重定向,避免凭证随跳转发到别处;解析出的值进入脱敏列表,不出现在输出、错误信息、缓存文件里。 - **错误信息**:退回词法排序时只输出状态码或错误码(`HTTP 401`、`ECONNREFUSED`、`timeout after 3s`)和缺失的凭证引用名;不输出响应体(服务端可能回显请求)、请求头或端点。 - **缓存**:用户缓存目录下的 `embeddings/`(0700,文件 0600),只含文本哈希与向量;端点只以哈希参与文件名。向量本身可以部分反推文本,所以与目录缓存一样按用户私有数据对待。 -- **范围**:只有 `via find` 使用它,其他命令不会触发请求;daemon 不参与,也不会因此保存端点凭证。 +- **范围**:只有 `via find` 使用它,其他命令不会触发请求;daemon 不参与,也不会因此保存端点凭证。请求遵守代理变量([config.md](config.md#代理))。 ## 单文件可执行 diff --git a/package-lock.json b/package-lock.json index b6331ae..f578e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "get-east-asian-width": "^1.5.0", "jsonc-parser": "^3.3.1", "smol-toml": "^1.9.1", + "undici": "^7.30.1", "yaml": "^2.9.1" }, "bin": { @@ -4354,6 +4355,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index f066c22..3d5495c 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "get-east-asian-width": "^1.5.0", "jsonc-parser": "^3.3.1", "smol-toml": "^1.9.1", + "undici": "^7.30.1", "yaml": "^2.9.1" }, "devDependencies": { diff --git a/src/cli/backend.ts b/src/cli/backend.ts index 919d3ea..bb55122 100644 --- a/src/cli/backend.ts +++ b/src/cli/backend.ts @@ -105,10 +105,11 @@ export class InProcessBackend implements Backend { /** The client that talks to servers, made on first use: browsing a cached catalog never needs it. */ private client(): Promise { this.clientLoading ??= import('../client').then(({ InProcessClient }) => { - const { servers, resolver, now, oauthStore, onWarning, roots, cache } = this.options; + const { servers, resolver, now, oauthStore, onWarning, roots, cache, env } = this.options; return (this.loaded = new InProcessClient(servers, resolver, { now, knownProtocol: (server) => cache.read(server)?.protocol, + env, ...(oauthStore ? { oauthStore } : {}), ...(onWarning ? { onWarning } : {}), ...(roots ? { roots } : {}), diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index e53dea5..0b25290 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -1,9 +1,11 @@ // `via auth login|status|logout` (auth.md, "OAuth"). Login is the only place an authorization // happens; calls never start one. This file is the terminal side: it resolves the provider, // wires the person-facing I/O into the flow in client/oauth/, and prints the outcome. +import type { FetchLike } from '@modelcontextprotocol/client'; import type { LoginIO, LoginStatus, LoginTarget } from '../../client/oauth'; import type { ServerConfig } from '../../config'; import { userConfigHint } from '../../config/paths'; +import { proxyFetch } from '../../proxy'; import { ExitCode } from '../../render/exit-codes'; import { shellArg } from '../../render/shell'; import { alignRows } from '../../render/width'; @@ -61,6 +63,12 @@ function loginFailure(error: unknown): unknown { return error; } +/** OAuth requests go through the proxy the environment asks for (config.md, "代理"); none set: the plain `fetch`. */ +function proxied(app: App, resolver: { track?: (value: string) => void }): { fetch?: FetchLike } { + const send = proxyFetch(app.io.env, { track: (value) => resolver.track?.(value) }); + return send ? { fetch: send } : {}; +} + async function login(app: App, argv: readonly string[]): Promise { const { flags, positionals } = parseLocal('auth login', argv, [{ name: 'manual', value: false }]); if (positionals.length !== 1) throw usageFailure(USAGE, 'via auth -h'); @@ -86,6 +94,7 @@ async function login(app: App, argv: readonly string[]): Promise { io, track: (value) => resolver.track?.(value), now: app.io.now, + ...proxied(app, resolver), }); } catch (error) { throw loginFailure(error); @@ -186,7 +195,7 @@ async function logout(app: App, argv: readonly string[]): Promise { const { performLogout } = await import('../../client/oauth'); let result: Awaited>; try { - result = await performLogout({ target, store: await app.oauthStore() }); + result = await performLogout({ target, store: await app.oauthStore(), ...proxied(app, await app.resolver()) }); } catch (error) { const reason = error instanceof Error ? error.message.split('\n')[0] : 'unknown error'; throw fail(ExitCode.Internal, `cannot delete the saved login of ${entry.name}: ${reason}`, 'via doctor'); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 1a50134..b6659e1 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -4,6 +4,7 @@ import { shellWord } from '../../render/shell'; import { ClientError } from '../../client/errors'; import type { App } from '../app'; import { buildProviderTable, statusOf } from '../directory'; +import { describeProxy, proxySettings } from '../../proxy'; import { renderDoctor, type ServerProbe } from '../doctor'; export async function doctorCommand(app: App): Promise { @@ -31,7 +32,7 @@ export async function doctorCommand(app: App): Promise { }), ); - const out = renderDoctor({ config, frameworks: BUNDLED_FRAMEWORKS, probes, catalogWarnings: table.warnings, home: app.io.home }); + const out = renderDoctor({ config, frameworks: BUNDLED_FRAMEWORKS, probes, catalogWarnings: table.warnings, home: app.io.home, proxy: describeProxy(proxySettings(app.io.env)) }); app.out(`${out.text}\n`); return out.exitCode; } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 0671bd7..3a19489 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -37,6 +37,11 @@ export interface DoctorInput { catalogWarnings?: readonly CatalogWarning[]; /** Home directory; paths below it are shown as `~/…`. */ home?: string; + /** + * The effective HTTP proxy, one line per part, userinfo already hidden (`describeProxy`); empty when none + * is set. Absent: not reported (config.md, "代理"). + */ + proxy?: readonly string[]; } export interface DoctorOutput { @@ -163,6 +168,13 @@ function renderServers(rows: readonly Row[], home: string | undefined, pins: Rea return lines; } +// proxy -------------------------------------------------------------------------------------- + +function renderProxy(lines: readonly string[]): string[] { + if (lines.length === 0) return ['proxy none']; + return lines.map((line, i) => `${i === 0 ? 'proxy ' : ' '}${line}`); +} + // frameworks --------------------------------------------------------------------------------- function formatTokens(bytes: number): string { @@ -303,7 +315,7 @@ export function renderDoctor(input: DoctorInput): DoctorOutput { const rows = collectRows(input); const config = renderConfigProblems(input); const pins = new Map(input.config.servers.flatMap((s) => (s.protocolVersion !== undefined ? [[s.name, s.protocolVersion] as const] : []))); - const sections = [renderServers(rows, input.home, pins), renderFrameworks(input), renderSecrets(input.config, input.home)]; + const sections = [renderServers(rows, input.home, pins), ...(input.proxy ? [renderProxy(input.proxy)] : []), renderFrameworks(input), renderSecrets(input.config, input.home)]; if (config.length > 0) sections.push(config); let exitCode: number = ExitCode.Ok; diff --git a/src/cli/semantic.ts b/src/cli/semantic.ts index 9f90083..e6ed467 100644 --- a/src/cli/semantic.ts +++ b/src/cli/semantic.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import type { Catalog } from '../catalog'; import { semanticScores, type SemanticResult } from '../catalog/semantic'; import type { SemanticSearchConfig } from '../config'; +import { proxyFetch } from '../proxy'; import type { App } from './app'; /** Authentication headers of the embedding endpoint; credential references are resolved here, in memory. */ @@ -19,6 +20,12 @@ async function authHeaders(app: App, config: SemanticSearchConfig): Promise { return semanticScores( @@ -31,6 +38,6 @@ export function semanticFind(app: App, config: SemanticSearchConfig, catalog: Ca timeoutMs: config.timeout * 1000, indexTimeoutMs: config.indexTimeout * 1000, }, - { headers: () => authHeaders(app, config), cacheDir: join(app.cacheDir, 'embeddings') }, + { headers: () => authHeaders(app, config), cacheDir: join(app.cacheDir, 'embeddings'), ...proxied(app) }, ); } diff --git a/src/client/errors.ts b/src/client/errors.ts index 2582203..d051320 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -137,6 +137,10 @@ export function mapError(err: unknown, ctx: ErrorContext): ClientError { const { server } = ctx; const provider = server.provider; const loginNext = `via auth login ${provider}`; + // A proxy variable that cannot be used (config.md, "代理"): the environment's mistake, not the server's. + if (err instanceof Error && err.name === 'ProxyConfigError') { + return new ClientError({ exitCode: ExitCode.Usage, message: `cannot reach ${provider}: ${clean(err.message, ctx)}`, next: 'fix the proxy variable, or unset it (via doctor shows what is in effect)' }); + } if (ctx.phase === 'connect' && server.protocolVersion !== undefined && refusesVersion(err)) return pinRefused(err, server.protocolVersion, ctx); // OAuth layer (matched by name: it lives next to the lazily loaded SDK). diff --git a/src/client/oauth/index.ts b/src/client/oauth/index.ts index 0014955..16f8ebc 100644 --- a/src/client/oauth/index.ts +++ b/src/client/oauth/index.ts @@ -1,5 +1,6 @@ // OAuth for HTTP servers (docs/spec/auth.md): login, token storage, silent refresh, status, logout. // Everything here loads the MCP SDK on demand, so importing it is cheap. +import type { FetchLike } from '@modelcontextprotocol/client'; import type { ServerConfig } from '../../config/types'; import type { LoginTarget } from './login'; import { ViaOAuthProvider } from './provider'; @@ -48,7 +49,7 @@ export function oauthTarget(server: ServerConfig): LoginTarget | undefined { export function createCallProvider( server: ServerConfig, store: OAuthStore, - options: { track?: (value: string) => void; now?: () => number; onSaveFailed?: (reason: string) => void } = {}, + options: { track?: (value: string) => void; now?: () => number; onSaveFailed?: (reason: string) => void; fetch?: FetchLike } = {}, ): ViaOAuthProvider { const target = oauthTarget(server); if (!target) throw new Error(`${server.provider} does not use OAuth over HTTP`); @@ -60,5 +61,6 @@ export function createCallProvider( ...(options.track ? { track: options.track } : {}), ...(options.now ? { now: options.now } : {}), ...(options.onSaveFailed ? { onSaveFailed: options.onSaveFailed } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), }); } diff --git a/src/client/session.ts b/src/client/session.ts index ab34a17..a7c6df2 100644 --- a/src/client/session.ts +++ b/src/client/session.ts @@ -10,6 +10,7 @@ import { resolveConnection, type ResolvedConnection } from './credentials'; import { ClientError, internal, mapError, noFeature, notFoundServer, resourceNotFound, timedOut, untrustedServer, type Phase } from './errors'; import { prepareStdioCommand } from '../platform/command'; import { isModernProtocolVersion } from '../protocol-versions'; +import { proxyFetch } from '../proxy'; import { Limiter } from './limiter'; import { resolveRoots } from './roots'; import type { ViaOAuthProvider } from './oauth/provider'; @@ -61,6 +62,12 @@ export interface SessionOptions { * remembers it). Only a stdio server is asked: see `McpSession.open`. */ knownProtocol?: (server: ServerConfig) => ProtocolInfo | undefined; + /** + * The environment whose proxy variables (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`) this session's HTTP + * requests follow (config.md, "代理"): the CLI's in-process, the daemon's own in the daemon. Default: none, + * so a library caller is never surprised by a proxy it did not ask for. + */ + env?: Readonly>; /** Base of relative `roots` entries and the meaning of `~`: the CLI's working directory and home (config.md, `roots`). */ roots?: { cwd: string; home: string }; } @@ -504,7 +511,9 @@ export class McpSession { throw internal(`invalid url for ${server.provider}: not a URL`); } const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); - const http = new StreamableHTTPClientTransport(url, { requestInit: { headers: resolved.headers }, ...(authFetch ? { fetch: authFetch } : {}) }); + // Through the proxy the environment asks for, if any; the OAuth fetch (made in `oauthFetch`) wraps the same one. + const send = authFetch ?? this.proxied(); + const http = new StreamableHTTPClientTransport(url, { requestInit: { headers: resolved.headers }, ...(send ? { fetch: send } : {}) }); terminate = () => http.terminateSession(); sessionId = () => http.sessionId; transport = http; @@ -574,6 +583,7 @@ export class McpSession { const track = (value: string): void => this.resolver.track?.(value); this.oauth = createCallProvider(this.server, this.options.oauthStore ?? createOAuthStore(), { track, + ...(this.proxied() ? { fetch: this.proxied()! } : {}), ...(this.options.now ? { now: this.options.now } : {}), onSaveFailed: (reason) => { if (this.warnedSave) return; @@ -585,7 +595,12 @@ export class McpSession { }); } await this.oauth.requireLogin(); - return this.oauth.createFetch(); + return this.oauth.createFetch(this.proxied()); + } + + /** A `fetch` through the configured proxy, or undefined when the environment names none. */ + private proxied(): FetchLike | undefined { + return this.options.env ? proxyFetch(this.options.env, { track: (value) => this.resolver.track?.(value) }) : undefined; } private warn(message: string): void { diff --git a/src/daemon/sessions.ts b/src/daemon/sessions.ts index dc2e9a5..22801b8 100644 --- a/src/daemon/sessions.ts +++ b/src/daemon/sessions.ts @@ -316,6 +316,8 @@ export class SessionManager { entry.session = new McpSession(server, this.options.resolver, { onToolsChanged: () => this.toolsChanged(entry), knownProtocol: () => this.cache.read(entry.original)?.protocol, + // Proxy variables come from the daemon's own environment, like `env:` credentials (config.md, "代理"). + env: this.options.env, now: this.options.now, ...(this.oauthStore ? { oauthStore: this.oauthStore } : {}), roots: { cwd: target.cwd, home: this.options.home }, diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..6fef225 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,221 @@ +// HTTP proxy settings for via's own outbound requests (docs/spec/config.md, "代理"): the MCP HTTP +// transport, OAuth (login, refresh, revoke) and the embedding endpoint of `find`. stdio children get the +// same variables through their environment (`client/env.ts`); this is for what via requests itself. +// +// Node's `fetch` ignores `HTTPS_PROXY`, so a request that has to go through a proxy is sent with an undici +// `ProxyAgent` as its dispatcher. undici is loaded only then: with no proxy variable set (almost always) +// nothing here costs anything but a scan of the environment, and it happens when a request is about to be +// made, never at CLI start. Under Bun, `fetch` takes the proxy as an option and undici is not involved. +// +// The rules (the same for every request, no per-server exceptions): +// - variable names are case-insensitive; where both spellings of a name are set the upper-case one wins; +// - a request to an https URL uses `HTTPS_PROXY`, else `HTTP_PROXY`; a request to an http URL uses +// `HTTP_PROXY` only (so a loopback server over http is not sent to a proxy that can only reach the outside +// when just `HTTPS_PROXY` is set); +// - `NO_PROXY` is a comma separated list: `*` (everything), `example.com` (that host and its subdomains), +// `.example.com` and `*.example.com` (subdomains), `host:port`, an IP literal; nothing is excluded unless +// listed (not even `localhost`); CIDR ranges are not understood; +// - only http and https proxies are supported; a proxy URL of another scheme (or one that is not a URL) +// is an error for the requests it would have served, never a silent direct connection. +import type { FetchLike } from '@modelcontextprotocol/client'; + +type Env = Readonly>; + +export interface ProxyEntry { + /** The URL as set, userinfo included: handed to the proxy agent, never printed (use `describeProxy`). */ + url: string; + /** The variable it came from, as spelled in the environment (`HTTPS_PROXY`, `http_proxy`, …). */ + variable: string; +} + +export interface ProxySettings { + https?: ProxyEntry; + http?: ProxyEntry; + /** The `NO_PROXY` list as set; `noProxyVariable` names the variable. */ + noProxy?: string; + noProxyVariable?: string; +} + +/** A proxy variable is set but cannot be used. Its message never contains the proxy URL's userinfo. */ +export class ProxyConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProxyConfigError'; + } +} + +/** The value of `name` in `env`, any case; the upper-case spelling first. */ +function lookup(env: Env, name: string): { value: string; variable: string } | undefined { + const wanted = [name.toUpperCase(), name.toLowerCase()]; + for (const variable of wanted) { + const value = env[variable]; + if (value !== undefined && value.trim() !== '') return { value: value.trim(), variable }; + } + // Windows environments have their own spelling rules (`Https_Proxy`). + for (const [key, value] of Object.entries(env)) { + if (key.toLowerCase() === name.toLowerCase() && !wanted.includes(key) && value !== undefined && value.trim() !== '') return { value: value.trim(), variable: key }; + } + return undefined; +} + +/** The proxy variables of `env`; undefined when none is set (the common case: no proxy). */ +export function proxySettings(env: Env): ProxySettings | undefined { + const https = lookup(env, 'HTTPS_PROXY'); + const http = lookup(env, 'HTTP_PROXY'); + if (!https && !http) return undefined; + const no = lookup(env, 'NO_PROXY'); + return { + ...(https ? { https: { url: https.value, variable: https.variable } } : {}), + ...(http ? { http: { url: http.value, variable: http.variable } } : {}), + ...(no ? { noProxy: no.value, noProxyVariable: no.variable } : {}), + }; +} + +/** A proxy URL as people write it: `host:port` without a scheme means http. */ +function parseProxyUrl(entry: ProxyEntry): URL { + const text = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry.url) ? entry.url : `http://${entry.url}`; + let url: URL; + try { + url = new URL(text); + } catch { + throw new ProxyConfigError(`${entry.variable} is not a proxy URL`); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ProxyConfigError(`${entry.variable}: unsupported proxy scheme "${url.protocol.slice(0, -1)}" (only http and https proxies are supported)`); + } + return url; +} + +/** Whether `host` (lower case, no brackets) with `port` is covered by one `NO_PROXY` entry. */ +function matches(entry: string, host: string, port: string): boolean { + let rule = entry.trim().toLowerCase(); + if (rule === '') return false; + if (rule === '*') return true; + let rulePort = ''; + const portMatch = /^(.*?):(\d+)$/.exec(rule); + // `[::1]:8080` and `host:8080` carry a port; a bare IPv6 literal has colons but no port. + if (portMatch && !(rule.includes(':') && portMatch[1]!.includes(':') && !portMatch[1]!.startsWith('['))) { + rule = portMatch[1]!; + rulePort = portMatch[2]!; + } + if (rulePort !== '' && rulePort !== port) return false; + rule = rule.replace(/^\[|\]$/g, ''); + const subdomainsOnly = rule.startsWith('.') || rule.startsWith('*.'); + rule = rule.replace(/^\*?\./, ''); + if (rule === '') return false; + if (host === rule) return !subdomainsOnly; + return host.endsWith(`.${rule}`); +} + +/** Whether `NO_PROXY` exempts a request to `url`. */ +export function bypassed(settings: ProxySettings, url: URL): boolean { + if (!settings.noProxy) return false; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + const port = url.port || (url.protocol === 'https:' ? '443' : '80'); + return settings.noProxy.split(/[\s,]+/).some((entry) => matches(entry, host, port)); +} + +/** The proxy a request to `url` goes through; undefined for a direct connection. Throws `ProxyConfigError` for an unusable proxy. */ +export function proxyFor(settings: ProxySettings, url: URL): { entry: ProxyEntry; proxy: URL } | undefined { + const entry = url.protocol === 'https:' ? (settings.https ?? settings.http) : url.protocol === 'http:' ? settings.http : undefined; + if (!entry || bypassed(settings, url)) return undefined; + return { entry, proxy: parseProxyUrl(entry) }; +} + +/** `http://proxy.corp:3128`, with a username or password shown as `***@`. Never the credentials themselves. */ +function hideUserinfo(entry: ProxyEntry): string { + try { + const url = parseProxyUrl(entry); + const hidden = url.username !== '' || url.password !== ''; + return `${url.protocol}//${hidden ? '***@' : ''}${url.host}${url.pathname !== '/' ? url.pathname : ''}`; + } catch { + // Not a URL we understand: say which variable and nothing of its value. + return '(invalid)'; + } +} + +/** The effective proxy as `via doctor` shows it: one line per proxy, then the exemptions. Empty when no proxy is set. */ +export function describeProxy(settings: ProxySettings | undefined): string[] { + if (!settings) return []; + const lines: string[] = []; + const add = (label: string, entry: ProxyEntry | undefined): void => { + if (!entry) return; + let note = ''; + try { + parseProxyUrl(entry); + } catch (error) { + note = ` — ${(error as Error).message}`; + } + lines.push(`${label} ${hideUserinfo(entry)} (${entry.variable})${note}`); + }; + add('https', settings.https); + add('http', settings.http); + if (settings.https && !settings.http) lines[lines.length - 1] += '; http requests go direct'; + if (settings.noProxy) lines.push(`no proxy for ${settings.noProxy.split(/[\s,]+/).filter(Boolean).join(', ')} (${settings.noProxyVariable})`); + return lines; +} + +/** Replace `user:pass@` in URLs and every secret in `secrets` in a message from the network layer. */ +function scrub(text: string, secrets: readonly string[]): string { + let out = text.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s@]*@/gi, '$1***@'); + for (const secret of secrets) if (secret !== '') out = out.split(secret).join('***'); + return out; +} + +const agents = new Map>(); + +export interface ProxyFetchOptions { + /** Told the user name and password of a proxy URL, so output redaction knows them. */ + track?: (secret: string) => void; +} + +/** + * A `fetch` that goes through the proxy `env` asks for, or undefined when no proxy variable is set (use the + * plain `fetch`). The proxy is chosen per request (scheme of the URL, `NO_PROXY`); a request that no proxy + * serves is sent directly. `base` is what finally sends (default: the global `fetch`). + */ +export function proxyFetch(env: Env, options: ProxyFetchOptions = {}, base: FetchLike = (url, init) => fetch(url, init)): FetchLike | undefined { + const settings = proxySettings(env); + if (!settings) return undefined; + for (const entry of [settings.https, settings.http]) { + if (!entry) continue; + try { + const url = parseProxyUrl(entry); + if (url.username !== '') options.track?.(decodeURIComponent(url.username)); + if (url.password !== '') options.track?.(decodeURIComponent(url.password)); + } catch { + // reported when a request would use it + } + } + return async (input, init) => { + const target = new URL(typeof input === 'string' || input instanceof URL ? input : (input as Request).url); + const via = proxyFor(settings, target); + if (!via) return base(input, init); + const secrets = [decodeURIComponent(via.proxy.username), decodeURIComponent(via.proxy.password)].filter((s) => s !== ''); + try { + if (process.versions.bun) return await base(input, { ...init, proxy: via.proxy.href } as RequestInit); + const key = via.proxy.href; + let agent = agents.get(key); + if (!agent) { + // `proxyTunnel: false`: an http URL is forwarded in absolute form (what proxies expect); only https is tunnelled with CONNECT. + agent = import('undici').then( + ({ ProxyAgent }) => new ProxyAgent({ uri: via.proxy.href, proxyTunnel: false }), + () => { + agents.delete(key); + throw new ProxyConfigError(`${via.entry.variable} cannot be used on this Node.js (${process.version}): the proxy support needs Node.js 20.18.1 or newer`); + }, + ); + agents.set(key, agent); + } + // The cast bridges undici's Dispatcher type and the one Node's fetch typings name; it is a valid dispatcher at runtime. + return await base(input, { ...init, dispatcher: (await agent) as never } as RequestInit); + } catch (error) { + if (error instanceof ProxyConfigError) throw error; + if (!(error instanceof Error)) throw error; + // The network layer may quote the proxy URL; the cause keeps its error code (ECONNREFUSED, …) for the error mapping. + const scrubbed = new Error(scrub(error.message, secrets), { cause: error.cause instanceof Error ? Object.assign(new Error(scrub(error.cause.message, secrets)), { code: (error.cause as { code?: unknown }).code }) : undefined }); + scrubbed.name = error.name; + throw scrubbed; + } + }; +} diff --git a/test/cli/proxy.test.ts b/test/cli/proxy.test.ts new file mode 100644 index 0000000..9680708 --- /dev/null +++ b/test/cli/proxy.test.ts @@ -0,0 +1,115 @@ +// via's own outbound requests follow HTTPS_PROXY / HTTP_PROXY / NO_PROXY (config.md, "代理", security.md): +// the MCP HTTP transport, OAuth, `via doctor`'s report. The origin servers are on loopback over http, so +// the variable that matters is HTTP_PROXY; the CONNECT path for https is in test/proxy.test.ts. +import { describe, expect, it } from 'vitest'; +import { startOAuthServer } from '../fixtures/mcp/oauth-server.mjs'; +import { startProxy } from '../helpers/proxy-server'; +import { FAKE_TOKEN, ghConfig, makeSandbox, startFixtureServer, useCleanup } from './helpers'; + +async function setup(extra = '') { + const srv = await startFixtureServer({ token: FAKE_TOKEN }); + useCleanup(() => srv.close()); + const proxy = await startProxy(); + useCleanup(() => proxy.close()); + const sb = makeSandbox(); + sb.userConfig(ghConfig(srv.url, extra)); + return { srv, proxy, sb }; +} + +describe('MCP over HTTP', () => { + it('goes through HTTP_PROXY, and the server still answers', async () => { + const { srv, proxy, sb } = await setup(); + const r = await sb.via(['gh', 'echo_text', '--message', 'hi'], { env: { HTTP_PROXY: proxy.url } }); + expect(r.code).toBe(0); + expect(r.stdout).toBe('echo: hi\n'); + expect(srv.state.calls).toEqual([{ name: 'echo_text', args: { message: 'hi' } }]); + // The probe, the handshake, the listing and the call, all of them carried by the proxy, in absolute form. + const lines = proxy.log.map((l) => l.line); + expect(lines.filter((l) => l === `POST ${srv.url}`).length).toBeGreaterThanOrEqual(3); + expect(lines.every((l) => l.endsWith(` ${srv.url}`))).toBe(true); + }); + + it('accepts the lower-case name', async () => { + const { proxy, sb } = await setup(); + expect((await sb.via(['gh'], { env: { http_proxy: proxy.url } })).code).toBe(0); + expect(proxy.log.length).toBeGreaterThan(0); + }); + + it('skips the proxy for hosts NO_PROXY lists', async () => { + const { proxy, sb } = await setup(); + const r = await sb.via(['gh', 'echo_text', '--message', 'hi'], { env: { HTTP_PROXY: proxy.url, NO_PROXY: 'localhost,127.0.0.1' } }); + expect(r.code).toBe(0); + expect(proxy.log).toEqual([]); + }); + + it('is direct when no proxy variable is set, even with NO_PROXY', async () => { + const { proxy, sb } = await setup(); + expect((await sb.via(['gh'], { env: { NO_PROXY: 'example.com' } })).code).toBe(0); + expect(proxy.log).toEqual([]); + }); + + it('is not used for an http server when only HTTPS_PROXY is set', async () => { + const { proxy, sb } = await setup(); + expect((await sb.via(['gh'], { env: { HTTPS_PROXY: proxy.url } })).code).toBe(0); + expect(proxy.log).toEqual([]); + }); + + it('reports an unreachable proxy as an upstream failure without printing its credentials', async () => { + const { sb } = await setup(); + const r = await sb.via(['gh', 'echo_text', '--message', 'hi'], { env: { HTTP_PROXY: 'http://alice:hunter2pass@127.0.0.1:1' } }); + expect(r.code).toBe(6); + expect(r.stdout + r.stderr).not.toMatch(/alice|hunter2pass/); + }); + + it('refuses a proxy of a kind it cannot speak instead of connecting directly (exit 2)', async () => { + const { srv, sb } = await setup(); + const r = await sb.via(['gh', 'echo_text', '--message', 'hi'], { env: { HTTP_PROXY: 'socks5://alice:hunter2pass@127.0.0.1:1080' } }); + expect(r.code).toBe(2); + expect(r.stderr).toContain('HTTP_PROXY: unsupported proxy scheme "socks5" (only http and https proxies are supported)'); + expect(r.stderr).toContain('next: fix the proxy variable, or unset it'); + expect(r.stdout + r.stderr).not.toMatch(/alice|hunter2pass/); + expect(srv.state.calls).toEqual([]); + }); +}); + +describe('via doctor', () => { + it('says no proxy is in effect, or which one, with the user name and password hidden', async () => { + const { proxy, sb } = await setup(); + expect((await sb.via(['doctor'])).stdout).toContain('\nproxy none\n'); + const url = new URL(proxy.url); + url.username = 'alice'; + url.password = 'hunter2pass'; + const r = await sb.via(['doctor'], { env: { HTTPS_PROXY: url.href, HTTP_PROXY: proxy.url, no_proxy: 'localhost,127.0.0.1' } }); + expect(r.stdout).toContain(`\nproxy https http://***@${url.host} (HTTPS_PROXY)\n http ${proxy.url} (HTTP_PROXY)\n no proxy for localhost, 127.0.0.1 (no_proxy)\n`); + expect(r.stdout + r.stderr).not.toMatch(/alice|hunter2pass/); + }); +}); + +describe('OAuth', () => { + it('logs in, and logs out, through the proxy', async () => { + const srv = await startOAuthServer({}); + useCleanup(() => srv.close()); + const proxy = await startProxy(); + useCleanup(() => proxy.close()); + const sb = makeSandbox({}); + sb.userConfig(`sources: false\nservers:\n linear:\n url: ${srv.url}\n auth: oauth\n`); + const env = { HTTP_PROXY: proxy.url }; + const login = await sb.via(['auth', 'login', 'linear'], { + env, + openUrl: async (url) => { + srv.browse(url).catch(() => undefined); + return { opened: true }; + }, + }); + expect(login.code).toBe(0); + const paths = proxy.log.map((l) => new URL(l.line.split(' ')[1]!).pathname); + expect(paths).toEqual(expect.arrayContaining(['/mcp', '/register', '/token'])); + expect(paths.some((p) => p.startsWith('/.well-known/'))).toBe(true); + // A call with the saved login also goes through it. + const before = proxy.log.length; + expect((await sb.via(['linear'], { env })).code).toBe(0); + expect(proxy.log.length).toBeGreaterThan(before); + const out = await sb.via(['auth', 'logout', 'linear'], { env }); + expect(out.code).toBe(0); + }); +}); diff --git a/test/helpers/proxy-server.ts b/test/helpers/proxy-server.ts new file mode 100644 index 0000000..dc2eeb6 --- /dev/null +++ b/test/helpers/proxy-server.ts @@ -0,0 +1,71 @@ +// A forward HTTP proxy on a loopback port, for the tests of via's own outbound requests (docs/spec/config.md, +// "代理"): plain requests (absolute-form URL) are forwarded, CONNECT tunnels are piped. It records what it +// was asked to carry, including the credentials the client presented to it. +import { createServer, request as httpRequest, type IncomingMessage, type Server } from 'node:http'; +import { connect, type AddressInfo, type Socket } from 'node:net'; + +export interface ProxyLog { + /** `GET http://127.0.0.1:1234/mcp` for a forwarded request, `CONNECT 127.0.0.1:1234` for a tunnel. */ + line: string; + /** The `Proxy-Authorization` header the client sent. */ + authorization?: string; +} + +export interface ProxyServer { + /** `http://127.0.0.1:`. */ + url: string; + log: ProxyLog[]; + close(): Promise; +} + +export async function startProxy(): Promise { + const log: ProxyLog[] = []; + const sockets = new Set(); + const server: Server = createServer((req, res) => { + log.push({ line: `${req.method} ${req.url}`, ...(req.headers['proxy-authorization'] ? { authorization: String(req.headers['proxy-authorization']) } : {}) }); + let target: URL; + try { + target = new URL(req.url ?? ''); + } catch { + res.writeHead(400).end('absolute URL expected'); + return; + } + const headers = { ...req.headers }; + delete headers['proxy-authorization']; + delete headers['proxy-connection']; + const upstream = httpRequest({ host: target.hostname, port: target.port, path: `${target.pathname}${target.search}`, method: req.method, headers }, (back: IncomingMessage) => { + res.writeHead(back.statusCode ?? 502, back.headers); + back.pipe(res); + }); + upstream.on('error', () => res.writeHead(502).end()); + req.pipe(upstream); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + server.on('connect', (req, client: Socket, head) => { + log.push({ line: `CONNECT ${req.url}`, ...(req.headers['proxy-authorization'] ? { authorization: String(req.headers['proxy-authorization']) } : {}) }); + const [host, port] = String(req.url).split(':'); + const upstream = connect(Number(port), host, () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length > 0) upstream.write(head); + upstream.pipe(client); + client.pipe(upstream); + }); + upstream.on('error', () => client.destroy()); + client.on('error', () => upstream.destroy()); + sockets.add(upstream); + upstream.once('close', () => sockets.delete(upstream)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + log, + close: () => + new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }), + }; +} diff --git a/test/proxy.test.ts b/test/proxy.test.ts new file mode 100644 index 0000000..008b1e4 --- /dev/null +++ b/test/proxy.test.ts @@ -0,0 +1,198 @@ +// via's own outbound requests follow the proxy variables (docs/spec/config.md, "代理"): which variable wins, +// what NO_PROXY exempts, what `via doctor` shows, and that a request really goes through a proxy. +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { bypassed, describeProxy, ProxyConfigError, proxyFetch, proxyFor, proxySettings } from '../src/proxy'; +import { startProxy, type ProxyServer } from './helpers/proxy-server'; + +const settings = (env: Record) => proxySettings(env)!; +const target = (url: string) => new URL(url); + +describe('proxySettings', () => { + it('is undefined when no proxy variable is set (NO_PROXY alone does nothing)', () => { + expect(proxySettings({})).toBeUndefined(); + expect(proxySettings({ NO_PROXY: 'localhost', PATH: '/bin' })).toBeUndefined(); + expect(proxySettings({ HTTPS_PROXY: ' ', http_proxy: '' })).toBeUndefined(); + }); + + it('reads the variables in either case, the upper-case spelling first', () => { + expect(settings({ https_proxy: 'http://lower:1' }).https).toEqual({ url: 'http://lower:1', variable: 'https_proxy' }); + expect(settings({ HTTPS_PROXY: 'http://upper:1', https_proxy: 'http://lower:1' }).https).toEqual({ url: 'http://upper:1', variable: 'HTTPS_PROXY' }); + expect(settings({ Http_Proxy: 'http://mixed:1' }).http).toEqual({ url: 'http://mixed:1', variable: 'Http_Proxy' }); + expect(settings({ HTTP_PROXY: 'http://p:1', no_proxy: 'a.test' })).toMatchObject({ noProxy: 'a.test', noProxyVariable: 'no_proxy' }); + }); +}); + +describe('proxyFor', () => { + const both = settings({ HTTPS_PROXY: 'http://secure:3128', HTTP_PROXY: 'http://plain:3128' }); + + it('uses HTTPS_PROXY for an https URL and HTTP_PROXY for an http URL', () => { + expect(proxyFor(both, target('https://api.example.com/mcp'))?.proxy.host).toBe('secure:3128'); + expect(proxyFor(both, target('http://api.example.com/mcp'))?.proxy.host).toBe('plain:3128'); + }); + + it('falls back to HTTP_PROXY for https, but never the other way round', () => { + expect(proxyFor(settings({ HTTP_PROXY: 'http://plain:3128' }), target('https://a.test/'))?.proxy.host).toBe('plain:3128'); + expect(proxyFor(settings({ HTTPS_PROXY: 'http://secure:3128' }), target('http://127.0.0.1:9/'))).toBeUndefined(); + }); + + it('takes a proxy written as host:port for an http proxy', () => { + expect(proxyFor(settings({ HTTPS_PROXY: 'proxy.corp:8080' }), target('https://a.test/'))?.proxy.href).toBe('http://proxy.corp:8080/'); + }); + + it('refuses a proxy it cannot use instead of going direct', () => { + expect(() => proxyFor(settings({ HTTPS_PROXY: 'socks5://proxy:1080' }), target('https://a.test/'))).toThrow(ProxyConfigError); + expect(() => proxyFor(settings({ HTTPS_PROXY: 'socks5://u:secret-pass@proxy:1080' }), target('https://a.test/'))).toThrow('HTTPS_PROXY: unsupported proxy scheme "socks5"'); + try { + proxyFor(settings({ HTTPS_PROXY: 'socks5://u:secret-pass@proxy:1080' }), target('https://a.test/')); + } catch (error) { + expect((error as Error).message).not.toContain('secret-pass'); + } + expect(() => proxyFor(settings({ HTTPS_PROXY: 'http://[' }), target('https://a.test/'))).toThrow('HTTPS_PROXY is not a proxy URL'); + }); +}); + +describe('NO_PROXY', () => { + const no = (list: string, url: string): boolean => bypassed(settings({ HTTPS_PROXY: 'http://p:1', NO_PROXY: list }), target(url)); + + it.each([ + ['*', 'https://anything.example/', true], + ['example.com', 'https://example.com/', true], + ['example.com', 'https://api.example.com/', true], + ['example.com', 'https://notexample.com/', false], + ['.example.com', 'https://api.example.com/', true], + ['.example.com', 'https://example.com/', false], + ['*.example.com', 'https://api.example.com/', true], + ['*.example.com', 'https://example.com/', false], + ['EXAMPLE.com', 'https://Example.COM/', true], + ['example.com:8443', 'https://example.com:8443/', true], + ['example.com:8443', 'https://example.com/', false], + ['example.com:443', 'https://example.com/', true], + ['127.0.0.1', 'http://127.0.0.1:4000/', true], + ['127.0.0.1:4000', 'http://127.0.0.1:4001/', false], + ['[::1]', 'http://[::1]:4000/', true], + ['::1', 'http://[::1]:4000/', true], + ['localhost, .corp ,internal.test', 'https://db.corp/', true], + ['localhost;other', 'https://other/', false], + ['', 'https://example.com/', false], + ] as const)('%j exempts %s: %s', (list, url, expected) => { + expect(no(list, url)).toBe(expected); + }); + + it('is applied before a proxy is chosen', () => { + expect(proxyFor(settings({ HTTPS_PROXY: 'http://p:1', NO_PROXY: 'internal.test' }), target('https://api.internal.test/'))).toBeUndefined(); + }); +}); + +describe('describeProxy', () => { + it('shows nothing for no proxy', () => { + expect(describeProxy(undefined)).toEqual([]); + }); + + it('hides the user name and password and names the variables', () => { + expect(describeProxy(settings({ HTTPS_PROXY: 'http://alice:s3cret@proxy.corp:3128', HTTP_PROXY: 'http://other.corp:80', no_proxy: 'localhost,.corp' }))).toEqual([ + 'https http://***@proxy.corp:3128 (HTTPS_PROXY)', + 'http http://other.corp (HTTP_PROXY)', + 'no proxy for localhost, .corp (no_proxy)', + ]); + const text = describeProxy(settings({ https_proxy: 'http://alice:s3cret@proxy.corp:3128' })).join('\n'); + expect(text).not.toMatch(/alice|s3cret/); + expect(text).toContain('http requests go direct'); + }); + + it('reports a proxy it cannot use without printing its value', () => { + const lines = describeProxy(settings({ HTTPS_PROXY: 'socks5://u:pw12345@proxy:1080' })); + expect(lines.join('\n')).toContain('unsupported proxy scheme "socks5"'); + expect(lines.join('\n')).not.toMatch(/pw12345|u:/); + }); +}); + +describe('proxyFetch', () => { + const closers: Array<() => Promise> = []; + afterEach(async () => { + for (const close of closers.splice(0)) await close(); + }); + + async function origin(): Promise<{ url: string; hits: string[] }> { + const hits: string[] = []; + const server: Server = createServer((req, res) => { + hits.push(`${req.method} ${req.url} ${String(req.headers.host)}`); + res.writeHead(200, { 'content-type': 'text/plain' }).end('from origin'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + closers.push(() => new Promise((resolve) => (server.closeAllConnections(), server.close(() => resolve())))); + return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/hello`, hits }; + } + + async function proxy(): Promise { + const p = await startProxy(); + closers.push(p.close); + return p; + } + + it('is undefined without a proxy variable: the caller keeps the plain fetch', () => { + expect(proxyFetch({ PATH: '/bin', NO_PROXY: '*' })).toBeUndefined(); + }); + + it('sends an http request through HTTP_PROXY', async () => { + const [o, p] = [await origin(), await proxy()]; + const send = proxyFetch({ HTTP_PROXY: p.url })!; + const response = await send(o.url); + expect(await response.text()).toBe('from origin'); + expect(p.log.map((l) => l.line)).toEqual([`GET ${o.url}`]); + expect(o.hits).toHaveLength(1); + }); + + it('gives the proxy its credentials and tells the redactor about them', async () => { + const [o, p] = [await origin(), await proxy()]; + const tracked: string[] = []; + const url = new URL(p.url); + url.username = 'alice'; + url.password = 'pa:ss'; + const send = proxyFetch({ http_proxy: url.href }, { track: (s) => tracked.push(s) })!; + await (await send(o.url)).text(); + expect(p.log[0]?.authorization).toBe(`Basic ${Buffer.from('alice:pa:ss').toString('base64')}`); + expect(tracked).toEqual(['alice', 'pa:ss']); + }); + + it('does not use the proxy for a host NO_PROXY lists', async () => { + const [o, p] = [await origin(), await proxy()]; + const send = proxyFetch({ HTTP_PROXY: p.url, NO_PROXY: '127.0.0.1' })!; + expect(await (await send(o.url)).text()).toBe('from origin'); + expect(p.log).toEqual([]); + expect(o.hits).toHaveLength(1); + }); + + it('does not send an http request through a lone HTTPS_PROXY', async () => { + const [o, p] = [await origin(), await proxy()]; + const send = proxyFetch({ HTTPS_PROXY: p.url })!; + expect(await (await send(o.url)).text()).toBe('from origin'); + expect(p.log).toEqual([]); + }); + + it('tunnels an https request with CONNECT', async () => { + const [o, p] = [await origin(), await proxy()]; + // The origin speaks plain http, so the TLS handshake inside the tunnel fails; that the proxy was asked + // to CONNECT to it is what is checked here (the cause is a TLS error, not a refused proxy). + const send = proxyFetch({ HTTPS_PROXY: p.url })!; + const port = new URL(o.url).port; + await send(`https://127.0.0.1:${port}/hello`).catch(() => undefined); + expect(p.log.map((l) => l.line)).toEqual([`CONNECT 127.0.0.1:${port}`]); + }); + + it('throws the configuration error for an unusable proxy, with no request sent', async () => { + const o = await origin(); + const send = proxyFetch({ HTTP_PROXY: 'socks5://127.0.0.1:1' })!; + await expect(send(o.url)).rejects.toBeInstanceOf(ProxyConfigError); + expect(o.hits).toEqual([]); + }); + + it('keeps the error code of an unreachable proxy and scrubs credentials from its message', async () => { + const send = proxyFetch({ HTTP_PROXY: 'http://alice:topsecret@127.0.0.1:1' })!; + const error = (await send('http://example.invalid/').catch((e: unknown) => e)) as Error & { cause?: Error & { code?: string } }; + expect(error).toBeInstanceOf(Error); + expect(JSON.stringify({ message: error.message, cause: error.cause?.message })).not.toContain('topsecret'); + expect(error.cause?.code).toMatch(/^E|^UND_ERR/); + }); +}); From b1ef1413df80a28eecdfdba03161e65cdc651a73 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:36:57 +0000 Subject: [PATCH 6/9] test: x-mcp-header mirroring, embedding requests through a proxy; docs and measurements Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- CHANGELOG.md | 4 ++-- docs/development/implementation.md | 2 +- docs/spec/auth.md | 3 ++- docs/spec/daemon.md | 2 +- src/cli/semantic.ts | 12 +++++++----- test/cli/proxy.test.ts | 19 +++++++++++++++++++ test/client/protocol.test.ts | 12 ++++++++++++ test/fixtures/mcp/flavors.mjs | 3 +++ test/fixtures/mcp/mock-server.mjs | 5 +++++ 9 files changed, 52 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd0e91..f491c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - 支持 MCP `2026-07-28` 的无状态协议,新版本优先、自动回退(路线图 e2,[ADR 0017](docs/adr/0017-protocol-versions-newest-first-with-fallback.md)、[daemon.md](docs/spec/daemon.md#协议版本与无状态连接)):建立连接时先发 `server/discover`,server 支持 `2026-07-28` 就按新协议谈(无 `initialize`、无会话 ID,请求带 `MCP-Protocol-Version` 与 `Mcp-Method` 头,`serverInfo` 读自响应的 `_meta`),被拒绝(旧 server 对陌生方法的 4xx)时在同一条连接上退回 `initialize` 握手(`2025-11-25` 至 `2024-10-07`);用户不需要配置,旧版 server 上的行为不变。要点: - 变更通知:新协议下 daemon 订阅 `subscriptions/listen` 流,与旧协议的 `notifications/tools/list_changed` 进同一个刷新入口;流意外断开时按 1 秒到 30 秒的退避重订,重订成功后立即刷新一次目录。 - server 在 `tools/list` 结果里给的 `ttlMs` 与 24 小时的目录 TTL 取较短者,随缓存保存;`0` 或缺省视为没有提示(SDK 构造的 server 默认发 `0`,照字面当作立刻过期会让浏览命令每次都连接 server)。 - - 目录缓存文件记下协商出的 `protocol`(`{version, stateful}`)和 `ttlMs`,都是可缺省的新字段,旧文件照常读取。stdio server 据此跳过协议探测:SDK 在 server 进程的副本上探测,对旧版 stdio server 等于每次连接多启动一个进程,所以缓存新鲜且记录是旧协议时直接握手(失败,超时除外,才再探测一次);缓存为空或过期后的第一次连接仍会多启动一个进程。stdio 的探测 3 秒无应答视为旧版 server。 + - 目录缓存文件记下协商出的 `protocol`(`{version, stateful}`)和 `ttlMs`,都是可缺省的新字段,旧文件照常读取。stdio server 据此跳过协议探测:SDK 在 server 进程的副本上探测,对旧版 stdio server 等于每次连接多启动一个进程,所以缓存新鲜且记录是旧协议时直接握手(失败,超时除外,才再探测一次);缓存为空或过期后的第一次连接仍会多启动一个进程(升级前写下的缓存没有 `protocol`,同样要探测一次,`via sync` 或 `via doctor` 刷新后记下)。stdio 的探测 3 秒无应答视为旧版 server。 - `roots` 在新协议里经 `input_required` 由 SDK 用同一个处理器应答,配置与声明条件不变;`logging/setLevel` 被新协议去掉,via 从不发它;无状态连接没有会话要终止、也没有会话 ID 存盘。 - 新协议的 `tools/call` 走 SDK 的 `callTool()`,按 `x-mcp-header` 镜像 `Mcp-Param-*` 请求头;结果仍按 server 给的样子显示。 - 配置新增 `servers..protocolVersion`(取值、语义与可见性见 config.md、cli.md,路线图 e3);daemon 的 `list` 应答与 `status` 多了可选的 `protocol`。 @@ -38,7 +38,7 @@ - 行为差异:v2 的 HTTP 传输默认只跟随同源重定向(1.x 会跟随到任何地方并带上自定义请求头);重定向到别处时报错并带目标地址。错误映射改为识别 v2 的 `SdkError`/`SdkHttpError`/`ProtocolError`(退出码不变)。OAuth 错误码从 `errorCode` 改读 `code`。 - OAuth 登录的探测请求(`src/client/oauth/login.ts`)不再写死 `2025-06-18`:先发无状态协议的 `server/discover`,再发带 SDK 最新版本的 `initialize`,取先得到 401/403 的那个的挑战头。 - 新增 `test/protocol-versions.test.ts` 与 `src/protocol-versions.ts`:via 自己保存的协议版本列表(配置加载器要校验 `protocolVersion`,不能为此加载 SDK)对照 node_modules 里的 SDK 检查,SDK 升级后增减版本会让测试失败。 - - 代价([implementation.md](docs/development/implementation.md#mcp-sdkv2)):进程内模式的一次调用中位数多约 50 ms,走 daemon 的 CLI 与浏览命令不受影响,daemon 建立会话后常驻内存约多 5–10 MiB(测量噪声 ±7 MiB)。 + - 代价([implementation.md](docs/development/implementation.md#mcp-sdkv2)):进程内模式的一次调用中位数多约 40 ms,走 daemon 的 CLI 与浏览命令不受影响,daemon 建立会话后常驻内存约多 5 MiB(测量噪声 ±5 MiB),单文件可执行 86 → 89 MiB,热 daemon 的调用开销不变。 - `serve` 成为内置子命令的保留字。名字恰好是 `serve` 的 server 需要在配置里用 `alias` 改名,改名前不出现在任何视图里(cli.md「内置子命令」)。动词表与单数化规则没有变化,其余规范写法不受影响。 ## [0.3.1] - 2026-10-21 diff --git a/docs/development/implementation.md b/docs/development/implementation.md index 26ebe8e..c6bf89d 100644 --- a/docs/development/implementation.md +++ b/docs/development/implementation.md @@ -50,7 +50,7 @@ via 用官方 TypeScript SDK v2:client 端 `@modelcontextprotocol/client`,`v - **结果不经 SDK 的类型化方法校验。** `listTools()`、`readResource()` 等会按完整的 MCP schema 校验整个结果,一个畸形条目就让整次操作失败。via 一直用通用请求加一个什么都接受的 schema(`src/client/session.ts` 的 `RAW`),只留下自己要用的条目(有 `name` 的工具、有 `uri` 的内容),其余原样交给渲染。迁移后保持这一点;`2026-07-28` 连接上的 `tools/call` 例外,走 SDK 的 `callTool()`:只有它会按 `x-mcp-header` 声明镜像 `Mcp-Param-*` 请求头,缺了头 server 会拒绝调用。它的输出校验被换成什么都接受的校验器,结果仍按 server 给的样子显示。 - **重定向。** v2 的 HTTP 传输默认只跟随同源重定向(1.x 跟随到任何地方,并把自定义请求头一起带过去)。via 保持 v2 的默认:指向别处的重定向报错,错误信息带目标地址,让用户直接配置那个地址。 - **协议版本常量。** `src/protocol-versions.ts` 重复了 SDK 的版本列表和 `serverInfo` 的 `_meta` 键,因为配置加载器要校验 `protocolVersion`,不能为此加载 SDK。`test/protocol-versions.test.ts` 对照 node_modules 里的 SDK 检查它们:旧版列表等于 `SUPPORTED_PROTOCOL_VERSIONS`,无状态版本等于 SDK server 在 `server/discover` 里给出的列表,并用真正的 SDK client 钉住每个版本连一次。SDK 升级后增减版本会让这个测试失败。OAuth 登录的探测请求(`src/client/oauth/login.ts`)不再写死版本:用 SDK 的 `LATEST_PROTOCOL_VERSION` 发 `initialize`,用 via 的无状态版本发 `server/discover`,哪个先得到 401 就用哪个的挑战头。 -- **代价**(`npm run bench:perf`,同一台机器上与迁移前的构建交替运行,噪声明显):调用路径多加载约 0.55 MiB 的分块(SDK 与 zod 4 共约 1.0 MiB,1.x 约 0.26 MiB 加其中的 zod),加载时要构造全部 schema;进程内模式(无 daemon)的一次 `gh echo_text` 中位数多约 50 ms(HTTP 654 → 702 ms,stdio 797 → 858 ms);走 daemon 的 CLI、浏览类命令不加载 SDK,不受影响(`test/cli/dist.test.ts`、`test/daemon/e2e.test.ts` 的加载追踪断言);daemon 建立 HTTP 会话与调用后的常驻内存同一台机器上约多 5–10 MiB,单次测量的波动(±7 MiB)与之相当。热 daemon 的调用开销不变(约 5–6 ms)。 +- **代价**(`npm run bench:perf`,同一台机器上与迁移前的构建交替运行,噪声明显):调用路径多加载约 0.55 MiB 的分块(`gh echo_text` 加载的分块 1.0 → 1.6 MiB:SDK 与 zod 4 共约 1.0 MiB,1.x 连同其中的 zod 约 0.3 MiB),加载时要构造全部 schema;进程内模式(无 daemon)的一次 `gh echo_text` 中位数多约 40 ms(HTTP 514 → 554 ms,stdio 735 → 771 ms;各自用独立的目录缓存,交替运行 25 次);单文件可执行 86 → 89 MiB,进程内的一次调用没有可见差别(232 → 236 ms);走 daemon 的 CLI、浏览类命令不加载 SDK,不受影响(`test/cli/dist.test.ts`、`test/daemon/e2e.test.ts` 的加载追踪断言,冷启动数字在噪声内);daemon 建立 HTTP 会话与调用后的常驻内存约多 5 MiB(三次交替测量的均值 80 → 85 MiB,单次波动 ±5 MiB)。热 daemon 的调用开销不变(5.3 ms)。undici(出站代理)是另一个按需加载的分块(约 1 MiB),只有设置了代理变量才加载。 评估后**没有**保留的做法:把目录和索引常驻在 daemon 里再让 CLI 查询(协议要新增 `ls`/`find`,渲染要在两处实现,而预构建缓存已经把 CLI 一侧压到几毫秒,收益不抵复杂度);在命令之间预取(并行 `import()`,收益在噪声内);用非密码学哈希给缓存键(字面量凭证会进入键的原文);关闭 socket 后立即释放(会破坏进程内测试的「空闲退出」语义)。 diff --git a/docs/spec/auth.md b/docs/spec/auth.md index 45cc2b1..f266a3c 100644 --- a/docs/spec/auth.md +++ b/docs/spec/auth.md @@ -20,11 +20,12 @@ ### 流程 -- 发现:先向 server 发一次未带凭证的请求,从 `WWW-Authenticate` 取 `resource_metadata` 和 `scope`;取不到就按 RFC 9728 的 well-known 路径找受保护资源元数据,再据此找授权服务器元数据(RFC 8414,退回 OpenID Connect Discovery)。授权请求带 RFC 8707 的 `resource`。 +- 发现:先向 server 发未带凭证的探测请求(先按无状态协议发 `server/discover`,没有得到 401/403 再按 SDK 支持的最新旧版本发 `initialize`,版本号不写死在代码里,[ADR 0017](../adr/0017-protocol-versions-newest-first-with-fallback.md)),从 `WWW-Authenticate` 取 `resource_metadata` 和 `scope`;取不到就按 RFC 9728 的 well-known 路径找受保护资源元数据,再据此找授权服务器元数据(RFC 8414,退回 OpenID Connect Discovery)。授权请求带 RFC 8707 的 `resource`。 - 注册:授权服务器支持动态客户端注册(RFC 7591)时自动注册,客户端名为 `via`,公开客户端(`token_endpoint_auth_method: none`);不支持时必须配置 `clientId`,否则 `login` 退出码 2 并说明。 - 授权:PKCE(S256)授权码流程,带随机 `state`,回调时校验 `state`;授权被拒绝、`state` 不匹配、超时(默认 300 秒,`--timeout` 覆盖)都退出码 4。 - 回调:`via auth login ` 在 `127.0.0.1` 的随机端口监听 `/callback`(只接受 `Host` 为该地址的请求),同时用平台的打开命令(macOS `open`、Linux `xdg-open`、Windows `rundll32 url.dll,FileProtocolHandler`)打开浏览器,并总是把授权 URL 打印出来。只打开 `http://` 与 `https://` 的地址,其他 scheme(`file:`、`ms-settings:` 等)不交给系统。Windows 上不用 `cmd /c start`:授权 URL 来自授权服务器而不是用户,`cmd.exe` 会解释其中的 `|`、`^`、`(` 和 `%VAR%`;`rundll32.exe` 以完整路径(`%SystemRoot%\System32`)直接启动,URL 只是它的一个参数,没有 shell 看到它。动态注册的客户端每次登录都按本次的回调地址重新注册,因为端口是随机的;配置了 `clientId` 时需要授权服务器按 RFC 8252 接受任意端口的 loopback 回调地址。 - 无头环境:通过 SSH 登录的 VPS、容器里,浏览器和 via 不在同一台机器,本地回调端口收不到重定向。`via auth login --manual` 只打印授权 URL,不打开浏览器、不监听回调端口;用户在任意机器上完成授权后,把落地页的完整 URL(落地页本身打不开是预期行为)粘贴回终端(从 stdin 读一行),via 从中取出 `code` 和 `state`。PKCE 与 state 校验不变。检测不到可用浏览器时(Linux 无 `DISPLAY` 与 `WAYLAND_DISPLAY`、处于 SSH 会话),不尝试打开浏览器,默认打印授权 URL 并提示可改用 `--manual`;打开命令不存在或启动失败也只是提示,不报错、不等待。 +- 代理:登录、刷新与撤销令牌的请求同样遵守 `HTTPS_PROXY`、`HTTP_PROXY`、`NO_PROXY`([config.md](config.md#代理));回调监听在本机回环上,不经代理。 - 输出:授权 URL 写 stdout(它是命令的数据),进度与提示写 stderr,以 `·` 开头;成功后 stdout 输出 `logged in: ` 与 scope、有效期、存放位置。输出中不出现 token。 ### 存储 diff --git a/docs/spec/daemon.md b/docs/spec/daemon.md index 03974e7..13929c2 100644 --- a/docs/spec/daemon.md +++ b/docs/spec/daemon.md @@ -126,7 +126,7 @@ type ErrorInfo = { MCP 的 `2026-07-28` 把协议改成无状态:没有 `initialize` 握手,没有 server 端会话和会话 ID,每个请求自己带协议版本、客户端信息和能力。via 用官方 SDK v2 同时支持新旧两代([ADR 0017](../adr/0017-protocol-versions-newest-first-with-fallback.md)),**用户不需要配置**;旧版 server 上的行为与迁移前相同。 - **协商:新版本优先,自动回退。** 建立连接时先发 `server/discover`(探测)。server 在 `supportedVersions` 里给出 `2026-07-28` 就按新协议谈;探测被拒绝(HTTP 400、404、405 或 JSON-RPC 错误,旧 server 对陌生方法的常见答复,**不算错误**)时,在同一条连接上退回 `initialize` 握手,取 server 接受的最新旧版本(`2025-11-25` 至 `2024-10-07`)。401 和 403 是真的鉴权问题,按[鉴权错误](auth.md#调用时的行为)报告;5xx 与网络错误也如实报告,不当成旧 server。 -- **stdio 的探测。** SDK 在 server 进程的一个副本上探测,探测完了才启动真正的进程,所以一次探测多启动一个进程;探测 3 秒没有应答就当作旧版 server(有些旧 server 对握手前的请求不作答)。为了不让每个旧版 stdio server 都被启动两次,目录缓存记着上次协商出的版本(`protocol`):缓存新鲜且记录的是旧版协议时,直接用普通的 `initialize` 握手、不探测,失败(超时除外)才再用探测试一次;daemon 里同一个会话重连时沿用上次的结果。缓存为空或过期后重新探测。HTTP 的探测只多一个请求,不需要这个记录。 +- **stdio 的探测。** SDK 在 server 进程的一个副本上探测,探测完了才启动真正的进程,所以一次探测多启动一个进程;探测 3 秒没有应答就当作旧版 server(有些旧 server 对握手前的请求不作答)。为了不让每个旧版 stdio server 都被启动两次,目录缓存记着上次协商出的版本(`protocol`):缓存新鲜且记录的是旧版协议时,直接用普通的 `initialize` 握手、不探测,失败(超时除外)才再用探测试一次;daemon 里同一个会话重连时沿用上次的结果。缓存为空、过期或是升级前写的(没有 `protocol` 记录)时重新探测,目录刷新(`via sync`、`via doctor`、daemon 的自动刷新)后记下结果。HTTP 的探测只多一个请求,不需要这个记录。 - **无状态连接。** 请求头带 `MCP-Protocol-Version` 与 `Mcp-Method`(方法名,路由用),请求的 `_meta` 带版本、客户端信息和 via 声明的能力;`serverInfo` 从响应的 `_meta` 读取。daemon 只保留连接和缓存:不发 `Mcp-Session-Id`,关闭时没有会话要终止,连接断了下次请求重新协商。via 从不把会话 ID 存到磁盘,所以升级后没有「过期的会话 ID」要丢弃。 - **有状态是什么意思。** `via servers`、`via doctor` 和协议的 `ProtocolInfo.stateful` 里:新协议连接是无状态的;旧协议下 stdio 进程本身就是会话,HTTP server 只有分配了会话 ID 才有状态(没有分配的,如无状态部署的旧 server,算无状态)。 - **变更通知。** 旧协议里 server 推送 `notifications/tools/list_changed`;新协议里 client 在连接上开一条 `subscriptions/listen` 流,server 把变更事件发在这条流上。两者进同一个处理入口(上节的刷新)。daemon 在 server 声明了 `tools.listChanged` 时订阅;流被对端意外断开时按 1 秒起翻倍(最长 30 秒)的间隔重新订阅,成功后当作可能错过了事件,立即刷新一次目录。server 主动结束(正常关闭)的流不重订。进程内模式不订阅。 diff --git a/src/cli/semantic.ts b/src/cli/semantic.ts index e6ed467..626e535 100644 --- a/src/cli/semantic.ts +++ b/src/cli/semantic.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import type { Catalog } from '../catalog'; import { semanticScores, type SemanticResult } from '../catalog/semantic'; import type { SemanticSearchConfig } from '../config'; -import { proxyFetch } from '../proxy'; +import { proxyFetch, proxySettings } from '../proxy'; import type { App } from './app'; /** Authentication headers of the embedding endpoint; credential references are resolved here, in memory. */ @@ -21,13 +21,15 @@ async function authHeaders(app: App, config: SemanticSearchConfig): Promise { + if (!proxySettings(app.io.env)) return {}; + const resolver = await app.resolver(); + const send = proxyFetch(app.io.env, { track: (value) => resolver.track?.(value) }); return send ? { fetch: send as typeof fetch } : {}; } /** Embedding scores for `query` against every tool of the catalog; see `semanticScores` for the failure contract. */ -export function semanticFind(app: App, config: SemanticSearchConfig, catalog: Catalog, query: string): Promise { +export async function semanticFind(app: App, config: SemanticSearchConfig, catalog: Catalog, query: string): Promise { return semanticScores( query, catalog.providers.flatMap((p) => p.tools), @@ -38,6 +40,6 @@ export function semanticFind(app: App, config: SemanticSearchConfig, catalog: Ca timeoutMs: config.timeout * 1000, indexTimeoutMs: config.indexTimeout * 1000, }, - { headers: () => authHeaders(app, config), cacheDir: join(app.cacheDir, 'embeddings'), ...proxied(app) }, + { headers: () => authHeaders(app, config), cacheDir: join(app.cacheDir, 'embeddings'), ...(await proxied(app)) }, ); } diff --git a/test/cli/proxy.test.ts b/test/cli/proxy.test.ts index 9680708..8d35415 100644 --- a/test/cli/proxy.test.ts +++ b/test/cli/proxy.test.ts @@ -3,6 +3,7 @@ // the variable that matters is HTTP_PROXY; the CONNECT path for https is in test/proxy.test.ts. import { describe, expect, it } from 'vitest'; import { startOAuthServer } from '../fixtures/mcp/oauth-server.mjs'; +import { startEmbeddingsServer } from '../helpers/embeddings-server'; import { startProxy } from '../helpers/proxy-server'; import { FAKE_TOKEN, ghConfig, makeSandbox, startFixtureServer, useCleanup } from './helpers'; @@ -113,3 +114,21 @@ describe('OAuth', () => { expect(out.code).toBe(0); }); }); + +describe('via find with search.semantic', () => { + it('sends the embedding requests through the proxy', async () => { + const srv = await startFixtureServer(); + useCleanup(() => srv.close()); + const emb = await startEmbeddingsServer({ concepts: { zorp: 'echo' } }); + useCleanup(() => emb.close()); + const proxy = await startProxy(); + useCleanup(() => proxy.close()); + const sb = makeSandbox({ GH_TOKEN: FAKE_TOKEN }); + sb.userConfig(`${ghConfig(srv.url)}search:\n semantic:\n url: ${emb.url}\n model: fake-embed\n`); + const r = await sb.via(['find', 'zorp'], { env: { HTTP_PROXY: proxy.url } }); + expect(r.code).toBe(0); + expect(r.stdout.trim().split('\n')[0]).toContain('echo'); + expect(proxy.log.some((l) => l.line.startsWith(`POST ${emb.url}`))).toBe(true); + expect(emb.requests.length).toBeGreaterThan(0); + }); +}); diff --git a/test/client/protocol.test.ts b/test/client/protocol.test.ts index 92d22bc..7f17639 100644 --- a/test/client/protocol.test.ts +++ b/test/client/protocol.test.ts @@ -226,3 +226,15 @@ describe('a pinned protocol version', () => { expect(err.message).toBe('cannot start mock: it does not support protocol version 2026-07-28; servers.mock.protocolVersion pins it'); }); }); + +describe('tool arguments mirrored into headers (x-mcp-header, stateless protocol)', () => { + it('are sent as Mcp-Param-* headers, also when via listed the tools itself and the SDK holds no definitions', async () => { + const http = await startHttpServer({ protocol: 'modern' }); + servers.push(http); + const s = session(httpServer(http.url)); + await s.listTools(); + expect(textOf(await s.callTool('regional', { region: 'eu-west-1' }))).toBe('region: eu-west-1'); + const calls = http.requests.filter((r) => r['mcp-method'] === 'tools/call'); + expect(calls.at(-1)?.['mcp-param-region']).toBe('eu-west-1'); + }); +}); diff --git a/test/fixtures/mcp/flavors.mjs b/test/fixtures/mcp/flavors.mjs index 5ba9771..14794f1 100644 --- a/test/fixtures/mcp/flavors.mjs +++ b/test/fixtures/mcp/flavors.mjs @@ -10,6 +10,7 @@ // h.clientCapabilities() what the client declared // h.listRoots() ask the client for its roots (a result `{ roots }`, or undefined when not declared) // h.toolsChanged() tell listeners the tool list changed +// h.header(name) an HTTP request header (stateless protocol over HTTP only; undefined otherwise) // h.fail(code, message) answer with a JSON-RPC error import { Server as LegacyServer } from '@modelcontextprotocol/sdk/server/index.js'; import { @@ -57,6 +58,7 @@ export function buildServer(flavor, info, capabilities, table, options = {}) { clientCapabilities: () => server.getClientCapabilities(), listRoots: async () => (server.getClientCapabilities()?.roots ? server.listRoots() : undefined), toolsChanged: () => server.sendToolListChanged(), + header: () => undefined, fail: (code, message) => { throw new McpError(code, message); }, @@ -79,6 +81,7 @@ export function buildServer(flavor, info, capabilities, table, options = {}) { if (answered) return answered; throw new NeedRoots(); }, + header: (name) => ctx.http?.req?.headers.get(name) ?? undefined, toolsChanged: () => (options.notifier ? options.notifier.toolsChanged() : server.sendToolListChanged()), fail: (code, message) => { throw new ProtocolError(code, message); diff --git a/test/fixtures/mcp/mock-server.mjs b/test/fixtures/mcp/mock-server.mjs index efc63a4..da75948 100644 --- a/test/fixtures/mcp/mock-server.mjs +++ b/test/fixtures/mcp/mock-server.mjs @@ -15,6 +15,7 @@ export const TOOLS = [ { name: 'max_busy', description: 'Highest number of overlapping busy calls so far', inputSchema: object() }, { name: 'crash_once', description: 'Exits the first time (marker file in MOCK_MARKER), answers afterwards', inputSchema: object() }, { name: 'pid', description: 'Process id of the server', inputSchema: object() }, + { name: 'regional', description: 'Echoes the region it was called with; declares x-mcp-header on region', inputSchema: object({ region: { type: 'string', 'x-mcp-header': 'Region' } }, ['region']) }, { name: 'add_tool', description: 'Adds a tool to the list and sends notifications/tools/list_changed', inputSchema: object() }, ]; @@ -77,6 +78,10 @@ export function createMockServer({ pageSize = 0, state = createState(), flavor = } case 'pid': return text(process.pid); + case 'regional': + // What a server that enforces SEP-2243 does: the argument must also arrive as a header (-32020 otherwise). + if (flavor === 'modern' && h.header('mcp-param-region') === undefined) h.fail(-32020, 'Mcp-Param-Region header is required'); + return text(`region: ${args.region}`); case 'add_tool': { const added = `extra_${state.extra.length + 1}`; state.extra.push({ name: added, description: 'Added at runtime', inputSchema: object() }); From 064672a7f5aaffeed9af0a359f134f932ac48f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:04:22 +0000 Subject: [PATCH 7/9] docs(roadmap): mark e1, e2, e3, e8 and f2 implemented Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- docs/roadmap.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4e52946..2120176 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -83,9 +83,11 @@ flowchart LR | c5 `via serve` | 已实现,未发布 | stdio MCP server,七个元工具,每次调用就是一次 CLI 命令([ADR 0016](adr/0016-via-serve-meta-tools-over-stdio.md)、[cli.md](spec/cli.md#via-serve)) | | d1–d5 | 计划中(0.4) | 见[验证与打磨](#验证与打磨) | | d6 引号审计 | 已实现,未发布 | 可粘贴的命令行统一由一处转义,无法安全引用的值写成占位符([output.md](spec/output.md)、[security.md](spec/security.md)) | -| e1–e9 | 计划中(0.4),必须做 | 见 [MCP 协议对齐](#mcp-协议对齐);不依赖 d1 的评测结论,与验证同时开工 | +| e1 SDK v2、e2 2026-07-28 无状态协议、e3 协议版本可见与固定、e8 一致性测试 | 已实现,未发布 | `@modelcontextprotocol/client` 2.3.1;`server/discover` 探测并自动回退([ADR 0017](adr/0017-protocol-versions-newest-first-with-fallback.md)、[daemon.md](spec/daemon.md));`protocolVersion` 配置字段;CI 新增 `conformance` 与 `stateless` 任务,预期失败的场景列在 `test/conformance/expected-failures.yml` | +| e4–e7、e9 | 计划中(0.4),必须做 | 见 [MCP 协议对齐](#mcp-协议对齐) | | f1 schema 快照校验 | 已实现,未发布 | `--schema-snapshot <文件>`、`--schema-mode compatible\|strict`([arguments.md](spec/arguments.md#schema-快照校验)) | -| f2、f3 | 计划中(0.4) | 见[借鉴与补齐](#借鉴与补齐) | +| f2 出站代理 | 已实现,未发布 | MCP HTTP、OAuth 与 embedding 请求遵守 `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`,`via doctor` 显示生效的代理([config.md](spec/config.md)、[security.md](spec/security.md)) | +| f3 | 计划中(0.4) | 见[借鉴与补齐](#借鉴与补齐) | | f4 `-h` 调用示例 | 已实现,未发布 | [arguments.md](spec/arguments.md#调用示例) | | f5 SKILL.md | 已实现,未发布 | `via prompt --skill`([cli.md](spec/cli.md)) | From ffddc92ebf0a292ca81794c846e5fc7a10f321c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:10:04 +0000 Subject: [PATCH 8/9] fix(client): never let the SDK reject a tool result over its outputSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK v2's callTool() throws when a tool with an outputSchema returns no structuredContent, returns one that does not match, or has a schema that does not compile. via only warns about a mismatch (arguments.md, 输出校验) and does that in the CLI. The SDK applies these checks once its own tools cache is filled, which via's raw listing does not do, but its x-mcp-header mismatch retry does. The validator provider now returns no validator, so callTool() skips the checks on every path. The mock server gains two tools that ignore their outputSchema; both protocol generations return them as sent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- src/client/session.ts | 8 +++++--- test/client/protocol.test.ts | 7 +++++++ test/fixtures/mcp/mock-server.mjs | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/client/session.ts b/src/client/session.ts index a7c6df2..beb4726 100644 --- a/src/client/session.ts +++ b/src/client/session.ts @@ -152,10 +152,12 @@ const RAW: StandardSchemaV1 = { }; /** - * The SDK's `callTool()` checks `structuredContent` against the tool's `outputSchema`. via shows a - * result as the server sent it, as it did before the SDK upgrade, so this validator accepts everything. + * The SDK's `callTool()` checks the result against the tool's `outputSchema` and throws when + * `structuredContent` is missing, does not match, or the schema does not compile. via shows a result + * as the server sent it and only warns about a mismatch (arguments.md, "输出校验"; done in the CLI), + * so this provider hands the SDK no validator at all: with none, `callTool()` skips every check. */ -const LENIENT_VALIDATOR = { getValidator: () => (input: unknown) => ({ valid: true, data: input, errorMessage: undefined }) } as unknown as NonNullable; +const LENIENT_VALIDATOR = { getValidator: () => undefined } as unknown as NonNullable; /** * A lazily connected session to one server. Operations are limited to `ServerConfig.concurrency` diff --git a/test/client/protocol.test.ts b/test/client/protocol.test.ts index 7f17639..68a3ceb 100644 --- a/test/client/protocol.test.ts +++ b/test/client/protocol.test.ts @@ -62,6 +62,13 @@ describe.each(['legacy', 'modern'] as const)('%s server', (generation) => { expect((await s.callTool('structured', undefined)).structuredContent).toEqual({ items: [{ id: 1 }, { id: 2 }] }); }); + it('returns a result that ignores its outputSchema as the server sent it (the CLI only warns)', async () => { + const s = await connect(); + await s.listTools(); + expect(textOf(await s.callTool('typed_text', undefined))).toBe('count: 3'); + expect((await s.callTool('typed_wrong', undefined)).structuredContent).toEqual({ count: 'three' }); + }); + it('maps a failure of the server to exit 6 with its text', async () => { const s = await connect(); await s.listTools(); diff --git a/test/fixtures/mcp/mock-server.mjs b/test/fixtures/mcp/mock-server.mjs index da75948..edcd6e6 100644 --- a/test/fixtures/mcp/mock-server.mjs +++ b/test/fixtures/mcp/mock-server.mjs @@ -9,6 +9,18 @@ export const TOOLS = [ { name: 'echo', description: 'Echo a message', inputSchema: object({ message: { type: 'string' } }, ['message']) }, { name: 'fail', description: 'Always returns isError', inputSchema: object() }, { name: 'structured', description: 'Returns structuredContent', inputSchema: object() }, + { + name: 'typed_text', + description: 'Declares an outputSchema but answers with text only', + inputSchema: object(), + outputSchema: { type: 'object', properties: { count: { type: 'integer' } }, required: ['count'] }, + }, + { + name: 'typed_wrong', + description: 'Declares an outputSchema and answers with structuredContent that breaks it', + inputSchema: object(), + outputSchema: { type: 'object', properties: { count: { type: 'integer' } }, required: ['count'] }, + }, { name: 'slow', description: 'Answers after ms milliseconds', inputSchema: object({ ms: { type: 'number' } }) }, { name: 'read_env', description: 'Reads a variable from the server environment', inputSchema: object({ name: { type: 'string' } }, ['name']) }, { name: 'busy', description: 'Holds a slot for 100 ms, tracking concurrency', inputSchema: object() }, @@ -54,6 +66,10 @@ export function createMockServer({ pageSize = 0, state = createState(), flavor = content: [{ type: 'text', text: '{"items":[{"id":1},{"id":2}]}' }], structuredContent: { items: [{ id: 1 }, { id: 2 }] }, }; + case 'typed_text': + return text('count: 3'); + case 'typed_wrong': + return { content: [{ type: 'text', text: '{"count":"three"}' }], structuredContent: { count: 'three' } }; case 'slow': await sleep(Number(args.ms ?? 1000)); return text('done'); From 1152e1543e74f90acd128c3ca3b7bee80c665bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:26:31 +0000 Subject: [PATCH 9/9] test(daemon): start the stdio server before timing the shutdown check On the Windows runner the shutdown test timed out in the daemon handshake. The test starts a 300 ms call on a stdio server that is not running yet, waits a fixed 100 ms and then expects a new connection's handshake to finish within 1 s. Since the protocol probe, the first connection to a stdio server starts two processes (the probe copy, then the server), and spawn blocks the event loop while CreateProcess runs, which is slow on Windows runners. The test is about shutdown, not connecting, so it now makes one call first and times only the shutdown. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016bCAHTTNtGTFAPNcsrjeS2 --- test/daemon/protocol.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/daemon/protocol.test.ts b/test/daemon/protocol.test.ts index 707dbe5..2f6b701 100644 --- a/test/daemon/protocol.test.ts +++ b/test/daemon/protocol.test.ts @@ -615,6 +615,10 @@ describe('lifecycle timers', () => { it('shutdown answers, stops listening at once and exits when the requests are done', async () => { const h = await startDaemon(); const conn = await h.connect(); + // Start the stdio server first: the first connection probes the protocol in a copy of the server + // before starting it for real, and two process starts on a Windows runner can outlast the + // fixed wait below. Only shutdown is under test here, not connecting. + expect((await conn.request({ type: 'call', ...target(stdioServer()), rawName: 'echo', args: { message: 'warm' } })).ok).toBe(true); const slow = conn.request({ type: 'call', ...target(stdioServer()), rawName: 'slow', args: { ms: 300 } }); await new Promise((r) => setTimeout(r, 100)); const other = await h.connect();