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 484b5fb..f491c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ ### 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 上的行为不变。要点: + - 变更通知:新协议下 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 等于每次连接多启动一个进程,所以缓存新鲜且记录是旧协议时直接握手(失败,超时除外,才再探测一次);缓存为空或过期后的第一次连接仍会多启动一个进程(升级前写下的缓存没有 `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`。 - `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 与解析器、校验器逐条检查。不涉及动词表与规范写法。 @@ -17,11 +27,18 @@ ### 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`。 ### 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)):进程内模式的一次调用中位数多约 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/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 94d535c..0af7a5a 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'; @@ -42,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/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/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/development/implementation.md b/docs/development/implementation.md index 9b0a8ad..c6bf89d 100644 --- a/docs/development/implementation.md +++ b/docs/development/implementation.md @@ -6,7 +6,9 @@ | 用途 | 包 | | --- | --- | -| 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」) | +| 出站 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` | | 配置解析 | `yaml`、`smol-toml`、`jsonc-parser`(部分框架配置带注释) | @@ -41,6 +43,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 的分块(`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 后立即释放(会破坏进程内测试的「空闲退出」语义)。 仍然偏高的部分(真实的下限):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/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/docs/guides/agent-integration.md b/docs/guides/agent-integration.md index f2e8781..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 各占一行并附 `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 <路径>`,凭证缺失时用凭证解析给出的提示)。 +- **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/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/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)) | 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/cli.md b/docs/spec/cli.md index edf616a..90d090e 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)`;之后一节 `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 81ca91b..d0d1f29 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,10 +130,22 @@ 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`:这些本地路径会发往远端服务。 +### 代理 + +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/daemon.md b/docs/spec/daemon.md index 654c14d..13929c2 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 里同一个会话重连时沿用上次的结果。缓存为空、过期或是升级前写的(没有 `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 主动结束(正常关闭)的流不重订。进程内模式不订阅。 +- **缓存提示。** 新协议的 `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/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 73d4681..f578e20 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", @@ -17,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": { @@ -24,6 +26,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 +518,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 +566,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 +667,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 +1999,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 +2089,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 +2114,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 +2144,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 +2164,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 +2178,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 +2238,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 +2252,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 +2262,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 +2272,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 +2282,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 +2314,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 +2332,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 +2353,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 +2368,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 +2400,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 +2410,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 +2427,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 +2482,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 +2499,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 +2540,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 +2584,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 +2604,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 +2665,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 +2699,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 +2709,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 +2734,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 +2756,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 +2781,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 +2795,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 +2808,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 +2821,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 +2834,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 +2844,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 +2865,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 +2882,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 +2899,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 +2909,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 +2973,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 +3299,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 +3309,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 +3323,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 +3336,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 +3346,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 +3376,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 +3415,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 +3432,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 +3446,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 +3456,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 +3483,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 +3496,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 +3506,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 +3525,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 +3667,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 +3685,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 +3702,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 +3716,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 +3846,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 +3863,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 +3897,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 +3917,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 +3945,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 +3965,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 +3982,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 +4001,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 +4054,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 +4197,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 +4284,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 +4303,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" @@ -4204,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", @@ -4215,6 +4375,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 +4385,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 +4595,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 +4626,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..3d5495c 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", @@ -39,7 +41,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", @@ -47,10 +50,13 @@ "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": { "@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/backend.ts b/src/cli/backend.ts index 865161f..bb55122 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,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 } = 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 } : {}), @@ -137,6 +141,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 +149,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 +174,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 +317,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/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 1d45318..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 { @@ -19,9 +20,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; @@ -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/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/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..3a19489 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. */ @@ -33,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 { @@ -97,6 +106,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 +116,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 +143,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 +152,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)}`); @@ -153,6 +168,13 @@ function renderServers(rows: readonly Row[], home: string | undefined): string[] 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 { @@ -292,7 +314,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), ...(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/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/src/cli/semantic.ts b/src/cli/semantic.ts index 9f90083..626e535 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, proxySettings } from '../proxy'; import type { App } from './app'; /** Authentication headers of the embedding endpoint; credential references are resolved here, in memory. */ @@ -19,8 +20,16 @@ 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), @@ -31,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') }, + { headers: () => authHeaders(app, config), cacheDir: join(app.cacheDir, 'embeddings'), ...(await proxied(app)) }, ); } 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/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 cc8db1e..d051320 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -131,9 +131,17 @@ 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}`; + // 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). if (err instanceof Error && err.name === 'LoginRequiredError') { @@ -180,7 +188,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 +211,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 +268,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'})`; @@ -249,33 +295,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 { @@ -302,9 +359,15 @@ 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(/^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/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/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..b96095a 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, OAuthClientInformationContext, 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. @@ -294,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/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..beb4726 100644 --- a/src/client/session.ts +++ b/src/client/session.ts @@ -1,15 +1,16 @@ // 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, 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 { proxyFetch } from '../proxy'; import { Limiter } from './limiter'; import { resolveRoots } from './roots'; import type { ViaOAuthProvider } from './oauth/provider'; @@ -28,6 +29,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; } @@ -38,8 +45,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; /** @@ -48,6 +57,17 @@ 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; + /** + * 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 }; } @@ -65,10 +85,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; @@ -76,27 +131,34 @@ 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 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: () => 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. @@ -109,6 +171,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; @@ -131,10 +195,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); + }), ); } @@ -162,8 +234,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 +255,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 +276,15 @@ 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 } : {}) }; + // 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; }); @@ -269,14 +339,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; @@ -343,9 +413,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; @@ -354,7 +425,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, []); @@ -371,25 +442,52 @@ 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; 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, + ...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() } } } : {}), + }, + ); + attempt.client = client; + if (roots.length > 0) client.setRequestHandler('roots/list', () => ({ roots })); 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/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,19 +512,27 @@ export class McpSession { } catch { throw internal(`invalid url for ${server.provider}: not a URL`); } - const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); - const http = new StreamableHTTPClientTransport(url, { requestInit: { headers: resolved.headers }, ...(authFetch ? { fetch: authFetch } : {}) }); + const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); + // 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; } - 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 }); @@ -436,6 +542,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. @@ -446,6 +585,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; @@ -457,7 +597,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/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..22801b8 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,9 @@ 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, + // 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 }, @@ -359,12 +372,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 +409,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-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/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/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/bench/bench.test.ts b/test/bench/bench.test.ts index 682a1b8..45f303c 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'; @@ -62,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 () => { @@ -69,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; @@ -82,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)); @@ -112,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/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/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/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/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/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/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/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/cli/proxy.test.ts b/test/cli/proxy.test.ts new file mode 100644 index 0000000..8d35415 --- /dev/null +++ b/test/cli/proxy.test.ts @@ -0,0 +1,134 @@ +// 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 { startEmbeddingsServer } from '../helpers/embeddings-server'; +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); + }); +}); + +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/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/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..68a3ceb --- /dev/null +++ b/test/client/protocol.test.ts @@ -0,0 +1,247 @@ +// 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('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(); + 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'); + }); +}); + +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/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/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']); 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 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/daemon/protocol.test.ts b/test/daemon/protocol.test.ts index 23ec969..2f6b701 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)); @@ -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(); 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..14794f1 --- /dev/null +++ b/test/fixtures/mcp/flavors.mjs @@ -0,0 +1,99 @@ +// 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.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 { + 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(), + header: () => undefined, + 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(); + }, + 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); + }, + }; + 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..edcd6e6 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 }); @@ -10,12 +9,25 @@ 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() }, { 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() }, ]; @@ -28,65 +40,74 @@ 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 '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'); + 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 '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() }); + 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)); +} 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/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); + }); +}); 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/); + }); +});