Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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.<name>.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 与解析器、校验器逐条检查。不涉及动词表与规范写法。
Expand All @@ -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` 语法字符的路径不再给出命令而是 `<path>`;`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
Expand Down
1 change: 1 addition & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 评测用它核对模型到底调了什么。

作为模块使用:
Expand Down
5 changes: 3 additions & 2 deletions bench/eval/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions bench/eval/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,7 +41,7 @@ export async function nativeTools(env: EvalEnv): Promise<AgentTools> {
const close = async (): Promise<void> => 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[]) {
Expand Down
5 changes: 3 additions & 2 deletions bench/harness/measure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,7 +114,8 @@ export async function measureSuite(options: MeasureOptions): Promise<SuiteMeasur
for (const name of loaded.suite.mounted) {
const calls: ReplayCall[] = [];
const entries: ReplayLogEntry[] = [];
const running = await startReplayHttp({ name, tools: loaded.tools.get(name)!, calls, log: entries });
const protocol = replayProtocolFromEnv();
const running = await startReplayHttp({ name, tools: loaded.tools.get(name)!, calls, log: entries, ...(protocol ? { protocol } : {}) });
servers.set(name, { calls, log: entries, close: running.close });
urls[name] = running.url;
}
Expand Down
Loading
Loading