Pi Agent 1.0 的 MCP 集成源码解析:工具发现、代码编排与提示词压缩
给 Agent 接上几个 MCP 服务器以后,很容易碰到一个问题:还没真正调用工具,请求里就已经带上了一大批工具名字、参数和使用说明。等工具开始返回数据,一份很长的 JSON 又进入上下文,后面的模型请求还要继续带着它。
前面写过 Pi Agent 的工具系统和 Pi Agent 的双层主循环。这次继续看 2026 年 10 月 1 日发布的 Pi 1.0,重点是 MCP 工具怎样进入模型请求,以及 Pi 怎样减少这部分上下文开销。
MCP(Model Context Protocol)约定了客户端怎样连接服务器、发现工具、调用工具和读取资源。接入协议以后,Agent 还要决定哪些工具提前告诉模型、工具调用怎样编排、结果保留多少。Pi 这次改动主要落在这些位置。
官方提到 Codemode 的提示词约减少 40%。这个数字很容易让人想知道:到底删了什么,原来的提示词是什么样,缩短后模型又靠什么找到工具?下文会把改前、改后的代码直接放在一起。
先确认版本:MCP 在 0.99.0 已经进入内置扩展
Pi 1.0 的发布文章把 MCP、Codemode 和延迟工具加载放在一起介绍。按具体 tag 的变更记录看,这套能力分几次进入仓库:
| 版本 | 日期 | MCP 相关变化 |
|---|---|---|
| 0.99.0 | 2026-09-29 | 首次内置 MCP、Codemode 和 tool_search |
| 0.99.2 | 2026-09-30 | 默认 MCP 工具按需发现;工具描述不再随服务器连接变化;首次请求只等待 direct 服务器 |
| 1.0.0 | 2026-10-01 | 进一步压缩固定提示词,增加纠错提示,修复 OAuth 和恢复已加载工具的问题 |
版本归属可以在固定到 1.0.0 的 CHANGELOG 中核对。官方 “You Said No MCP!” 也解释了接入思路:工具需要容易发现、能够返回结构化数据,并能像函数一样组合调用。
本文的主要分析对象固定为:1
2
3
4
5
6
7repository: https://github.com/earendil-works/pi
tag: v1.0.0
commit: a13d35a742c6ef8462812a28fbe1d8c8b7431c32
提示词压缩的具体对照:
before: 88ff80b986e34d4fbd1fa94a4df65c60ae964516
after: 6f1072cc081f06b86a673bd142f03720d17afe15
before 是提示词压缩 commit 的父版本。它已经包含图像生成 API,因此不能拿 0.99.2 的整段提示词直接替代这个对照。本文后面的压缩代码来自该 commit 前后;其余实现以 1.0.0 为准。
本文根据源码、commit diff 和仓库测试断言分析。没有运行真实 MCP 服务或复现官方的 provider token 样本。字符数对照来自提示字符串统计,官方 token 数会单独标明。
工具已经注册,模型仍可以暂时看不到它
过去比较直接的做法,是连接服务器、拿到工具列表,然后把所有工具 schema 都交给模型。schema 在这里指输入参数及其类型、必填字段等说明。
Pi 为工具增加了不同的 exposure,也就是模型通过什么路径接触工具:
| exposure | 默认行为 | 适合的使用方式 |
|---|---|---|
codemode | MCP 默认值;工具可从脚本调用,完整声明不预先进入模型请求 | 在脚本里组合、并行或筛选 MCP 调用 |
deferred | 工具被 tool_search 找到后,在下一次模型请求中声明 | 按需加载后直接调用 |
direct | 工具直接声明给模型,也可以从 Codemode 调用 | 少量经常使用的工具 |
hidden | 工具不可调用 | 关闭某些服务器或工具能力 |
服务器级的 exposure 还可以被单个工具的 toolExposure 覆盖。比如把查询工具交给 Codemode,把某个常用工具直接声明,把不需要的工具隐藏。固定版本配置说明
这里还有一层名称转换:MCP 配置中的 codemode 会被映射为通用工具层的 deferred。两种 MCP 配置都不会把完整声明预先放进 Codemode description;MCP 扩展再根据配置决定激活 Codemode 还是 tool_search,供模型发现这些工具:1
2
3export function toToolExposure(exposure: McpExposure): ToolExposure {
return exposure === "codemode" ? "deferred" : exposure;
}
因此,下面通用工具层的 codemode 分支与 MCP 配置中的同名值处在不同层次。默认 MCP 工具在这一步落入 deferred 分支。源码见 toToolExposure。
产品层把已注册工具、脚本可调用工具和模型直接看到的工具分开。AgentSession._getCallableTools() 负责计算脚本可调用的集合:1
2
3
4
5
6private _getCallableTools(active: ReadonlySet<string> = new Set(this.getActiveToolNames())): AgentTool[] {
return [...this._toolRegistry.values()].filter((tool) => {
const exposure = this._getToolExposure(tool.name);
return exposure === "codemode" || exposure === "deferred" || (exposure === "direct" && active.has(tool.name));
});
}
codemode 和 deferred 工具只要注册完成,就能走脚本调用路径。direct 工具还要求它在 active tool 集合中。这样就可以把大量工具留在运行时,同时控制进入模型请求的声明数量。源码见 agent-session.ts。
searchTools 与 tool_search 做了两件不同的事
默认不把所有 schema 塞进请求以后,模型需要有地方查工具。Pi 提供了脚本内查询和模型工具加载两条路径。
searchTools() 是 Codemode 脚本中的查询函数。它搜索可调用工具,返回匹配项的名称和描述;这里的描述实际包含生成后的 TypeScript 声明。因此,它能按需拿到参数结构。调用方式可以写成下面这样,mcp__tracker 只是示例命名空间:1
2
3
4
5
6text(await searchTools("search issues", {
namespace: "mcp__tracker",
limit: 3,
}));
text(await describeNamespace("mcp__tracker"));
describeNamespace() 返回服务器的 instructions 和工具名,describeTool() 按具体名称返回工具描述及声明。这些数据先进入脚本,只有通过 text() 或 return 输出的部分才会进入模型上下文。源码见 createDiscoveryGlobals。
tool_search 则是模型可以直接调用的工具。它查找尚未加载的工具,将匹配项加入 active tools,从下一次模型请求开始声明。已经加载的工具会记录在会话中。源码见 searchAndLoad。
这两个接口的区别会影响上下文:脚本里查询并调用工具,可以一直把处理留在脚本中;通过 tool_search 加载以后,模型会开始直接接收匹配工具的声明。
当前内置检索器采用 BM25,即根据查询词和工具说明中的词项匹配程度排序。检索内容包括工具名、description、输入 schema 的属性名和说明,以及 namespace 的描述与 instructions。Codemode 的 searchTools() 默认返回 8 个匹配项。检索实现
Codemode 把完整结果留在脚本中
Codemode 让模型写一段 JavaScript,再在 QuickJS 沙箱中运行。QuickJS 是一个可以编译成 WebAssembly 的 JavaScript 引擎;脚本没有 Node API、文件系统、网络或计时器,需要通过注入的工具访问外部能力。
一次 MCP 调用的路径可以按下面的顺序读:1
2
3
4
5
6
7
8模型生成 JavaScript
→ QuickJS 运行脚本
→ tools.<name>(args)
→ ctx.executeTool()
→ MCP 服务器
→ 完整结果回到脚本
→ 脚本筛选、聚合
→ text() / return 输出给模型
executeCodemode() 没有直接绕过产品层调用 MCP 客户端。它把嵌套调用交给 ctx.executeTool()。下面是其中连续的调用片段:1
2
3const outcome = await ctx.executeTool(tool.name, args, { signal: callSignal });
record.id = outcome.toolCall.id;
record.durationMs = performance.now() - callStartedAt;
这让嵌套调用继续经过正常工具流水线,参数校验和扩展的 tool_call、tool_result 回调仍然生效。配置了权限扩展时,扩展也能拦截这些调用。最终发送给模型的是脚本输出,并不会自动把每个嵌套 MCP 结果全文放进对话。源码见 executeCodemode。
例如,需要查询一批 issue,再读取各自的评论,可以在脚本里并行取数据、筛选、排序,只把需要的条目交给模型。这是用于说明机制的场景,本文没有对它测量耗时或 token。
MCP 结果进入产品层以后,convertMcpResult() 会生成两种视图:模型可以看的 content,以及脚本可以拿到的完整结构化结果。下面是源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21export async function convertMcpResult(
server: string,
tool: string,
result: CallToolResult,
options: ConvertMcpResultOptions = {},
): Promise<AgentToolResult<McpToolDetails>> {
// Without content blocks, toLlmContent falls back to the structured content as JSON.
const converted: (TextContent | ImageContent)[] =
result.content.length > 0 ? await toModelContent(server, result.content, options) : toLlmContent(result);
if (result.isError && textOf(converted) === "") {
converted.push({ type: "text", text: `MCP tool ${server}/${tool} returned an error` });
}
const { content, fullOutputPath } = await limitMcpContent(converted, options.saveOutput);
const { _meta: _ignored, ...scriptResult } = result;
return {
content,
details: { server, tool, ...(fullOutputPath ? { fullOutputPath } : {}) },
structuredContent: scriptResult as unknown as JsonValue,
...(result.isError ? { isError: true } : {}),
};
}
limitMcpContent() 限制直接给模型的文本,默认上限为 20KB,超出后保留首尾、把完整文本写到临时文件。structuredContent 则保留服务器的 CallToolResult,只移除 _meta。源码见 MCP 结果转换。
因此,脚本能拿到 content、structuredContent 和 isError,先处理数据再输出。服务器能提供结构化字段时,这种写法比较容易组合;如果结果只有一大段自然语言,脚本还需要自己解析它。
这里有一个实际写脚本时要留意的返回规则:1
2
3
4
5
6
7function toScriptValue(tool: AgentTool<any>, outcome: AgentToolCallOutcome): unknown {
const { result } = outcome;
if (tool.outputSchema && result.structuredContent !== undefined) return result.structuredContent;
const text = textOf(result);
if (outcome.isError) throw new Error(text || `Tool "${tool.name}" failed`);
return text;
}
toScriptValue() 先返回有 output schema 的 structuredContent,再判断其他结果是否失败。MCP 的 isError: true 因而仍可能正常 resolve。脚本需要检查 result.isError;Promise.allSettled() 只能接住真正的 rejection,不能替代这个判断。源码见 toScriptValue。
1.0 的提示词到底从什么缩到什么
官方给出的整体样本是:默认工具加 Codemode 开启时,GPT-5.6 请求约从 5300 降到 3300 prompt tokens。用这两个约数计算,减少约 37.7%,发布说明概括为约 40%。这个样本描述请求中的提示词,不能直接推成所有 MCP 任务的总费用或耗时都下降 40%。
对应 commit 是 6f1072c。它主要修改提示词的构造方式:合并重复解释,复用已有工具 schema,把完整 API 参考移到按需读取的文档。
执行规则与 helper:逐项说明改成分组短说明
改前的 DESCRIPTION_INTRO 包含 13 条执行规则和 11 条 helper 解释,后面还有独立的工具发现说明。以下是改前源码原文:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30const DESCRIPTION_INTRO = `Run JavaScript code to orchestrate/compose tool calls
- Evaluates the provided JavaScript code in a fresh QuickJS sandbox as the body of an async function: top-level \`await\` and \`return\` work.
- All nested tools are available on the global \`tools\` object, for example \`await tools.read(...)\`. Tool names are exposed as normalized JavaScript identifiers, for example \`await tools.mcp__ologs__get_profile(...)\`.
- Nested tool methods take an object as their input argument.
- Nested tools return either an object or a string, based on the description.
- A nested tool call that fails, is blocked, or gets invalid arguments rejects with an Error carrying the tool's error text.
- Runs raw JavaScript -- no Node, no file system, no network access, no timers.
- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
- You may optionally start the tool input with a first line like \`// @options: {"max_output_tokens": 1000, "timeout_ms": 60000}\`.
- \`max_output_tokens\` sets the token budget for the script's output. Defaults to 10000 tokens.
- \`timeout_ms\` sets a hard deadline for the whole script. By default there is none.
- When the JS code is fully evaluated, calls that are still running are cancelled and unawaited promises are silently discarded.
- Tool calls are real and have side effects. If the script fails partway, earlier calls are not undone.
- Scripts have a 256 MB memory limit; exceeding it throws \`InternalError: out of memory\`. Filter or aggregate large data instead of accumulating it.
- Global helpers:
- \`exit()\`: Immediately ends the current script successfully (like an early return from the top level).
- \`text(value: string | number | boolean | undefined | null)\`: Appends a text item. Non-string values are stringified with \`JSON.stringify(...)\` when possible.
- \`image(imageUrlOrItem: string | { image_url: string } | ImageContent)\`: Appends an image item. \`image_url\` should be a base64-encoded \`data:\` URL. To forward an MCP tool image, pass an individual \`ImageContent\` block from \`result.content\`, for example \`image(result.content[0])\`.
- \`store(key: string, value: any)\`: stores a serializable value under a string key for later \`codemode\` calls in the same session. Storing \`undefined\` deletes the key. Writes are kept only if the script succeeds.
- \`load(key: string)\`: returns the stored value for a string key, or \`undefined\` if it is missing.
- \`ALL_TOOLS\`: metadata for the enabled nested tools as \`{ name, description }\` entries.
- \`searchTools(query: string, options?: { limit?: number; namespace?: string })\`: resolves to the nested tools that best match the query (BM25, default limit 8), as \`{ name, description }\` entries like \`ALL_TOOLS\`.
- \`describeTool(name: string)\`: resolves to the description and declaration of a nested tool, or \`undefined\`.
- \`describeNamespace(name: string)\`: resolves to \`{ name, description?, instructions?, tools }\` for a namespace of nested tools, such as an MCP server: its usage instructions and the names of its tools, or \`undefined\`.
- \`console.log(...)\` and the other \`console\` methods append a text item like \`text()\`.
- \`return value\` at the top level appends the value like \`text()\`.`;
const DEFERRED_TOOLS_GUIDANCE = `Some nested tools may be omitted from this description, such as deferred tools and MCP tools. They are still available on the global \`tools\` object and listed in \`ALL_TOOLS\`.
To find one, call \`await searchTools(query)\` (pass \`{ namespace }\` to search one namespace), or filter \`ALL_TOOLS\` by \`name\` and \`description\`. \`await describeNamespace(name)\` returns a namespace's usage instructions and the names of its tools.`;
改后,执行方式被合并成一段和两条说明,helper 合并成输出、存储和工具发现三组。启用 models 时,再加一条文档指引:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17const DESCRIPTION_INTRO = `Run JavaScript that calls other tools. The input is raw JavaScript (not JSON, no code fence), run as an async function body in a QuickJS sandbox: top-level \`await\` and \`return\` work. No Node, file system, network, or timers.
- \`await tools.<name>({ ...args })\` resolves to a string, or an object if the tool's declaration says so, and rejects with an Error on failure. Calls still running when the script ends are cancelled.
- Optional first line: \`// @options: {"max_output_tokens": 10000, "timeout_ms": 60000}\``;
/** One line per global. The details live in {@link CODEMODE_DOCS_PATH}. */
function describeGlobals(models: boolean): string {
const lines = [
"Globals:",
"- `text(value)`, `image(dataUrlOrImageBlock)`, `console.log(...)`, and top-level `return` add output; `exit()` ends the script.",
"- `store(key, value)` and `load(key)` keep JSON values across codemode calls.",
"- `ALL_TOOLS`, `searchTools(query, { limit?, namespace? })`, `describeTool(name)`, `describeNamespace(name)`: find unlisted tools, such as MCP tools.",
];
if (models) {
lines.push(`- \`models\`: classifiers and image generation. Read ${CODEMODE_DOCS_PATH} first.`);
}
return lines.join("\n");
}
这里可以直接看到缩短的内容:text()、image()、console.log() 和 return 合在一起,store() 与 load() 合在一起,几种工具发现方法合在一起。关于内存上限、图片格式、store 写入规则、超时默认值等细节,则放到 codemode.md 中。
对运行时说明字符串统计,改前的 intro 加独立工具发现说明为 3619 字符;改后的 intro 加不含 models 行的 globals 为 878 字符。统计包含两段之间的换行,不包含 models API、嵌套工具声明和共享 MCP 类型,也没有把源码里的反斜杠转义计作运行时字符。这个数字只说明该段文本的变化。改前源码、改后源码
models API:完整类型和方法声明改成文档入口
改前,Codemode 的常驻 description 还会注入完整模型 API。为了看清被移走的具体内容,下面直接列出原来的类型和方法元数据:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85const MODEL_TYPES = `type ModelType = "chat" | "image" | "classifier";
/** A model catalog entry. \`provider\` and \`id\` identify it; the other fields depend on the type. */
interface ModelInfo {
type?: ModelType;
provider: string;
id: string;
name: string;
api: string;
input: ("text" | "image")[];
contextWindow?: number;
[key: string]: unknown;
}
type ClassifierQuestion =
| { type: "choice"; instructions: string; criteria: Record<string, string> }
| { type: "score"; instructions: string; criteria: string[] }
| { type: "bool"; instructions: string; criteria: { true: string; false: string } };
type ClassifierAnswer =
| { type: "choice"; choice: string; probabilities: Record<string, number>; confidence: number }
| { type: "score"; score: number; confidence: number }
| { type: "bool"; probability: number };
interface ClassifierContext {
state: Record<string, unknown>;
questions: Record<string, ClassifierQuestion>;
}
/** Token counts reported by the service. Cost is in USD. */
type ModelUsage = { input: number; output: number; totalTokens: number; cost: { total: number } };
interface ClassifierResult {
api: string;
provider: string;
model: string;
answers: Record<string, ClassifierAnswer>;
usage?: ModelUsage;
stopReason: "stop" | "error" | "aborted";
errorMessage?: string;
timestamp: number;
}
type ModelTextBlock = { type: "text"; text: string };
/** \`data\` is base64. Show it with \`image(block)\`; never print \`data\` with \`text()\`, \`console\`, or \`return\`. */
type ModelImageBlock = { type: "image"; data: string; mimeType: string };
interface ImagesContext {
/** The prompt as text blocks, plus image blocks to edit or use as references. */
input: (ModelTextBlock | ModelImageBlock)[];
}
interface ImagesResult {
api: string;
provider: string;
model: string;
/** Generated images, and text blocks for models that also return text. */
output: (ModelTextBlock | ModelImageBlock)[];
usage?: ModelUsage;
stopReason: "stop" | "error" | "aborted";
errorMessage?: string;
timestamp: number;
}`;
/** Declarations of the `models` globals; codemode-execute.ts implements them. */
export const MODEL_GLOBAL_DECLARATIONS: readonly Omit<CodemodeTool, "execute">[] = [
{
name: "models.getModelsOfType",
description: "Every known model of a type, optionally for one provider.",
signature: "(type: ModelType, provider?: string): Promise<ModelInfo[]>",
},
{
name: "models.getAvailableOfType",
description: "Models of a type whose provider has working credentials.",
signature: "(type: ModelType, provider?: string): Promise<ModelInfo[]>",
},
{
name: "models.getModelOfType",
description: "One catalog entry, or undefined.",
signature: "(type: ModelType, provider: string, id: string): Promise<ModelInfo | undefined>",
},
{
name: "models.classify",
description:
"Run a classifier model on one state. Only `provider` and `id` of `model` are used. Provider errors do not throw: check `stopReason` and `errorMessage`.",
signature: "(model: ModelInfo, context: ClassifierContext): Promise<ClassifierResult>",
},
{
name: "models.generateImages",
description:
'Generate images with an image model. Only `provider` and `id` of `model` are used. Provider errors do not throw: check `stopReason` and `errorMessage`. Generation can take minutes, so do not set a short `timeout_ms`. Show results with `for (const block of result.output) if (block.type === "image") image(block);`.',
signature: "(model: ModelInfo, context: ImagesContext): Promise<ImagesResult>",
},
];
这些声明通过下面的代码进入 description:1
2
3
4
5
6
7if (options.models) {
const noop = () => undefined;
const models = renderDeclarations({
globals: MODEL_GLOBAL_DECLARATIONS.map((global) => ({ ...global, execute: noop })),
});
sections.push(`Model API:\n\`\`\`ts\n${MODEL_TYPES}\n\n${models}\n\`\`\``);
}
改后删除了这段常驻注入,只在 describeGlobals() 中留下:1
2
3if (models) {
lines.push(`- \`models\`: classifiers and image generation. Read ${CODEMODE_DOCS_PATH} first.`);
}
MODEL_TYPES 本身有 54 行,此外还有 5 个方法的签名和说明。它们从每次请求中的 API 参考,变成需要时读取的文档。运行时的 API 仍然存在。改动源码
已声明工具:完整 TypeScript 声明改成调用与返回字段速览
默认 codemode.mode = "on" 时,bash 这样的直接工具已经有正常输入 schema。旧版又在工具 description 中追加一份完整 TypeScript 声明,构造代码是:1
descriptions[tool.name] = renderToolSample(toCodemodeDeclaration(tool));
以 bash 为例,下面是根据旧版 schema 和声明生成器还原的追加部分。共同保留的自然语言工具说明没有重复列出:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// codemode tool declaration:
declare const tools: {
bash(args: {
// Shell command to execute
command: string;
// Timeout in seconds (optional, no default timeout)
timeout?: number;
}): Promise<{
exit_code: number;
// Temp file with the full output, when truncated
full_output_path?: string;
// Combined stdout and stderr, up to 1 MiB. Longer output keeps its first and last 512 KiB around an omission marker.
output: string;
// Whether `output` omits part of the command output
truncated: boolean;
wall_time_seconds: number;
}>;
};
1.0 改用 describeScriptCall(),输出类型通常只列字段名和 optional 标记:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25function describeOutput(schema: CodemodeJsonSchema | undefined): string {
const type = renderToolOutputType(schema);
if (type === "string") return "a string";
const object = typeof schema === "object" ? schema : undefined;
const properties = object?.properties;
if (
object?.type === "object" &&
typeof properties === "object" &&
properties !== null &&
mcpStructuredContentSchema(schema) === undefined
) {
const required = new Set(Array.isArray(object.required) ? object.required : []);
const fields = Object.keys(properties).map((name) => (required.has(name) ? name : `${name}?`));
return `\`{ ${fields.join(", ")} }\``;
}
return `\`${type.replace(/\s+/g, " ")}\``;
}
/**
* A declared tool's description followed by how scripts call it and what the call resolves to. The
* arguments are the tool's declared parameters, so they are not repeated.
*/
function describeScriptCall(tool: AgentTool<any>): string {
return `${tool.description.trim()}\n\nCodemode: \`tools.${toCodemodeIdentifier(tool.name)}(args)\` resolves to ${describeOutput(toCodemodeDeclaration(tool).outputSchema)}.`;
}
生成的 bash 追加说明变成:1
Codemode: `tools.bash(args)` resolves to `{ output, truncated, full_output_path?, exit_code, wall_time_seconds }`.
模型已经能从正常工具 schema 读到 command 和 timeout,description 只需要补充脚本调用方式及返回值概况。这省掉了重复参数和冗长输出字段注释。旧版声明构造、新版构造
codemode.mode = "only" 的情况不同:模型只通过 Codemode 调用这些工具,正常直接声明被隐藏,Codemode description 会在预算内列出非 deferred 工具的完整嵌套声明。工具创建时默认采用下面的预算:1
2export const DEFAULT_CODEMODE_INLINE_BUDGET = 3000;
const CHARS_PER_TOKEN = 4;
这里的 3000 是按字符数除以 4 得到的估算 tokens。声明选择按 namespace 轮流进行,每组优先放成本较低的声明;没有 namespace 的工具也单独成组。超出预算的工具继续通过发现函数查询。因此,完整声明仍然保留在需要它的路径中,但不会无条件列出全部工具。预算与选择算法、创建工具时使用默认预算
MCP 服务器段:固定解释所有路径改成按实际路径生成
旧版的服务器段开头始终说明 Codemode、工具搜索、namespace instructions 和 tool_search:1
2const SERVERS_SECTION_INTRO =
"MCP servers whose tools are not declared to you. Call the tools of `codemode` servers from codemode scripts: find them with `searchTools(query, { namespace })` and read a server's instructions and tool names with `describeNamespace(name)`. Load the tools of `tool_search` servers with `tool_search`.";
改后根据当前服务器实际采用的路径拼接:1
2
3
4
5
6function serversSectionIntro(reaches: ReadonlySet<string>): string {
let intro = "MCP servers whose tools are not declared to you.";
if (reaches.has("codemode")) intro += " Call the tools of `codemode` servers from codemode scripts.";
if (reaches.has("tool_search")) intro += " Load the tools of `tool_search` servers with `tool_search`.";
return intro;
}
固定 299 字符的说明,变成单一路径 108 字符、两种路径都有时 168 字符。工具发现方法已经在 globals 中出现,服务器段就不再重复详细用法。改前源码、改后源码
系统提示中的使用建议也缩短了
改前:1
2
3
4
5
6export const codemodeToolSystemPromptContribution = {
snippet: "Run JavaScript that calls other tools (chains, loops, Promise.all, filtering large results)",
guidelines: [
"Use codemode to batch or chain several tool calls, or to filter large tool output down to what you need, instead of issuing many individual tool calls. Batch independent calls in one codemode call using await Promise.allSettled([...]).",
],
} as const;
改后:1
2
3
4
5
6export const codemodeToolSystemPromptContribution = {
snippet: "Run JavaScript that calls other tools",
guidelines: [
"Use codemode to batch independent tool calls (Promise.allSettled), chain them, or filter large output, instead of many separate calls.",
],
} as const;
摘要从 91 字符缩到 37 字符,使用建议从 235 字符缩到 134 字符。独立调用用 Promise.allSettled()、串联调用、筛选大结果这几项建议都保留了,只减少重复解释。
这些修改共同解释了官方 5300 → 3300 prompt tokens 的样本。后续如果需要读取文档,文档内容仍会进入相应请求;真实任务的收益取决于工具规模、调用方式和查询频率。
描述保持稳定,服务器在后台连接
0.99.2 已经把默认 MCP 工具从 Codemode description 中移出。服务器连接完成或工具列表变化时,不需要重写整份 Codemode 描述,也不再把工具数量和完整 instructions 塞进去。1.0 延续了这个结构。
MCP 服务器改为列在 mcp_servers 系统提示段中。renderServersSection() 使用一行摘要,单个摘要最多 250 字符,整个段最多 4096 字符;完整 instructions 通过 describeNamespace() 查询。服务器段源码
服务器段变化时,产品层计算系统提示 section 的差异,把更新追加到会话。稳定前缀有利于缓存复用,但本文没有测量各家 provider 的实际缓存命中率。系统提示差异构造
后台连接也有明确的等待规则:会话启动时,Pi 开始连接所有启用的服务器;首次 prompt 只等待配置了 direct 工具的服务器,默认最多 10 秒。其余连接在工具需要时等待。
脚本需要哪些服务器,当前通过下面的代码判断:1
2
3
4function scriptNeedsServer(code: string, server: string): boolean {
if (/\b(searchTools|describeNamespace|describeTool|ALL_TOOLS)\b/.test(code)) return true;
return code.includes(mcpNamespace(server));
}
直接写出某个 mcp__<server> 命名空间,会等待对应服务器。代码中出现 searchTools、describeNamespace、describeTool 或 ALL_TOOLS 时,则等待全部尚在连接的启用服务器。即使给 searchTools() 指定 namespace,这个前置等待也依旧覆盖全部服务器。
这里使用正则和字符串匹配,没有分析 JavaScript 语法树。代码注释中出现这些名字也可能命中。因此,首次请求可以很快发出去,首次搜索工具仍可能承担服务器连接等待。启动与等待实现
工具调用时的 waitForServers() 会等到对应服务器的 ready promise 完成或 AbortSignal 取消;这次按需等待不受首次 prompt 的 10 秒上限约束。按需等待实现
1.0 的纠错提示让模型有明确的恢复路径
说明变短以后,模型可能写错工具名。1.0 为 tools 和 models 等对象加了 Proxy guard:读取不存在的成员时,立即抛出错误,并给出相近名称或可用成员列表。例如 tools.Bash 会提示 tools.bash。
这样做也改变了 JavaScript 探测行为。下面的旧式写法可能直接抛错:1
typeof tools.some_missing_tool === "function"
需要改成成员存在性检查:1
"some_missing_tool" in tools
相近名称匹配使用大小写和标点归一化,以及字符串包含关系。它给模型一个修正入口;工具搜索和完整文档仍是另一条恢复路径。Proxy guard 源码
OAuth:少一些反复登录,账号和权限也要存对
远程 MCP 接上以后,常见的麻烦是登录成功了,换一个操作又提示权限不足;或者工作账号和个人账号指向同一个服务,后登录的账号覆盖了前一个。1.0 的 OAuth 改动主要解决这些状态问题。
补权限时保留已经拿到的权限
假设 token 已有 issues:read,服务端返回 insufficient_scope,要求补上 issues:write。这个错误表示当前授权不够用。0.99.2 的适配器只把 challenge 中的 scope 交给下一轮授权,新的 token 可能只剩写权限,下一次读操作又要求登录。
1.0 的 stepUpScope() 合并已有权限和本次要求,再去重。下面是源码原文:1
2
3
4
5export function stepUpScope(granted: string | undefined, challenged: string | undefined): string | undefined {
if (!challenged) return undefined;
const scopes = [granted, challenged].flatMap((scope) => scope?.split(/\s+/).filter(Boolean) ?? []);
return [...new Set(scopes)].join(" ");
}
记录旧权限也得跟上。token 响应省略 scope 时,流程保存本次请求的 scope;refresh 响应省略它时,保存旧 token 的 scope。这分别沿用 OAuth 对授权响应和刷新的规则,见 withScope() 的调用。权限提升会跳过 refresh,重新走浏览器授权,因为刷新原来的 grant 无法补出新权限。
仓库的登录测试 明确断言,授权 URL 的 scope 从 issues:read 变成 issues:read issues:write。旧记录如果没有 scope,代码无法还原过去实际授予的权限;challenge 没给 scope 时,函数也会返回 undefined,交给流程选择默认来源。
同一个服务地址可以保留多个账号
McpOAuthCredentialStore 把 mcp-auth.json 的键从 URL 改成 server name 加 URL:1
2
3
4function storeKeys(name: string, serverUrl: string): { key: string; legacyKey: string } {
const legacyKey = String(new URL(serverUrl));
return { key: `${mcpNamespace(name)}|${legacyKey}`, legacyKey };
}
于是 work 和 personal 即使连接相同 URL,也有独立的 token、client registration 和刷新锁。升级时,URL-only 旧记录归第一个加载它的 server,迁移在存储锁内完成,随后删除旧键;其他同 URL 配置需要重新登录。测试 还说明,my-work 和 my_work 经 namespace 规范化后算同一个名称。由键的结构可以推断,改名也可能让配置失去原来的凭据关联。
检查授权回调来自哪个 issuer
1.0 在交换 authorization code 之前加入 RFC 9207 的 iss 检查。iss 是回调携带的授权服务器身份,防止把一个服务器签发的 code 送给另一个服务器。浏览器回调和手动粘贴 redirect URL 都会传递它。核心判断见 runFlow():1
2
3if (metadata && (iss !== undefined || metadata.authorization_response_iss_parameter_supported)) {
if (iss !== metadata.issuer) throw new OAuthIssuerMismatchError(metadata.issuer, iss);
}
提供了 iss,就必须严格等于 metadata 中的 issuer;服务器声明支持 iss 却没有返回,也拒绝交换。没有声明支持、回调也没有它时,保持兼容。测试 检查了这些组合,并断言错误 code 没有发到 token endpoint。这个判断以存在 metadata 为前提,旧式 fallback 没有 metadata 时,不能据此认定回调 issuer 得到了验证。
Discovery 有问题时可以指定 metadata
oauth.authServerMetadataUrl 让配置直接指向授权服务器的 metadata 文档,适合 MCP 服务报错授权地址或没有可用发现信息的情况。它下传为 authorizeMcp() 的 authorizationServerMetadataUrl。实现 信任这个配置,使用文档中的 issuer 和端点,不把 issuer 与文档 URL 做自动发现时的匹配校验。回调 iss 仍与这个 issuer 比较。
runFlow() 要求该 URL 使用 HTTPS 或 loopback,并在配置覆盖时停用 discovery 缓存,确保改 URL 后立即生效。代价是每次流程都要重新请求 metadata。指定文档返回 404 也会直接报错。这项能力把授权服务器的选择交给配置维护者,不能理解成自动跳过所有认证检查。
空字段不再破坏整条登录流程
有些服务端会返回 scope: ""、refresh_token: "" 或 expires_in: null。1.0 的解析器 将可选字段的空串和 null 按未提供处理,必需的 access_token 等字段仍要校验。过期时间解析 也避免了 Number(null) === 0 导致刚拿到 token 就被当作过期。
这些修正与恢复登录状态有关,但并发 401 共用一次 refresh、避免轮换 refresh token 被重复使用的机制,在 0.99.2 已存在。1.0 补上的是 scope 记录、账号隔离和输入兼容性。本文对照了源码及测试断言,没有执行测试,也没有据此计算登录成功率或延迟改善。
恢复会话时,先保存还没有注册的工具名
tool_search 加载的工具会记录在 transcript,也就是会话记录中。恢复会话或执行 /reload 时,读取工具列表的时间可能早于 MCP 服务器重连完成。工具尚未注册,就会被旧的 active tool 构造过程过滤掉。
1.0 增加 _pendingToolNames,让恢复的工具名先进入待处理集合:1
2
3
4
5
6
7
8private _restoreToolsFromTranscript(): void {
this._pendingToolNames.clear();
const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages);
if (!current) return;
const names = (current.toolsAdded ?? []).map((tool) => tool.name);
this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name)));
this._setActiveTools(names);
}
注册工具、重建运行时时,再把这些名字加入下一组 active tools:1
2
3
4// Pending tools that are registered now become active.
nextActiveToolNames.push(...this._pendingToolNames);
this._setActiveTools([...new Set(nextActiveToolNames)]);
这个集合有生命周期:工具注册完成后会从 pending 删除;扩展主动移除 active tool 时,会清掉原恢复集合;下一次 agent run 开始时,仍未注册的名字也会清掉。它修复了及时重连时的工具丢失,极慢连接仍可能需要重新发现。
仓库测试分别覆盖了 resume、/reload、扩展修改 loadout,以及服务器在下一次 prompt 之后才连接的情形。修复 commit、测试断言
当前实现还有哪些边界
工具搜索的分词逻辑值得直接看一眼:1
2
3
4
5
6
7
8
9export function tokenize(text: string): string[] {
return text
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((term) => term.length > 0 && !STOP_WORDS.has(term))
.map(stem);
}
split(/[^a-z0-9]+/) 只保留 ASCII 字母和数字。实际传给 searchTools() 或 tool_search 的纯中文 query 会没有词项,BM25 随即返回空匹配。中文对话仍可以由模型转换成英文关键词、使用工具标识符,或改走 describeNamespace()、describeTool() 和 ALL_TOOLS。当前这两条检索路径内没有自动翻译或 embedding fallback。分词与排序源码
传输层支持 stdio 和 Streamable HTTP。stdio 是启动本地进程,通过标准输入输出交换协议消息;Streamable HTTP 用于远程服务器。1.0.0 不支持旧式 SSE transport,也不渲染 MCP Apps 的 ui:// 资源。MCP 文档、资源边界
QuickJS 隔离的是脚本运行环境。脚本仍能通过注入工具产生真实外部操作,脚本失败也不会撤销已完成的调用。参数校验和权限扩展继续留在正常工具流水线上。脚本执行说明
1.0 加入官方 MCP conformance suite 的 CI,但它按已提交的测试基线检查回归。覆盖的协议版本为 2025-03-26、2025-06-18 和 2025-11-25;1.0.0 仍有 elicitation、CIMD 等已知缺口,也没有实现 2026-07-28 的 stateless 协议。后续版本文档里的新增能力,需要单独核对 tag。测试范围说明
按使用方式选择工具曝光
接入时可以按工具使用方式配置 exposure。下面是一个示例,URL 是占位地址,工具名和通配符需要按实际服务器调整:1
2
3
4
5
6
7
8
9
10
11
12
13
14{
"mcpServers": {
"tracker": {
"url": "https://example.com/mcp",
"description": "Search and read issue tracker data",
"exposure": "codemode",
"toolExposure": {
"search_issues": "direct",
"list_*": "codemode",
"delete_*": "hidden"
}
}
}
}
少量、经常直接调用的工具可以提前声明;适合批量处理和筛选结果的工具可以留给 Codemode;希望发现后再直接调用的工具可以使用 deferred。hidden 控制工具在当前 Agent 中是否可达,服务器端的账号授权仍需独立配置。
阅读这套实现时,可以沿着 mcp/index.ts 看注册与等待,沿着 codemode/tool.ts 看模型能读到的说明,再沿着 codemode/execute.ts 和 mcp/tools.ts 看调用与结果转换。这样能把服务器连接、模型上下文和脚本执行三个过程对应起来。