教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
MCP 集成
把外部 MCP Server 适配到已有的工具、资源、权限和审计边界。
扩展篇 · 预计 60 分钟 · 前置:主线 04 工具系统与 09 权限与安全已经通过 Checkpoint。本页目录
主线 04 工具系统与 09 权限与安全已经通过 Checkpoint;本章复用它们的 ToolRegistry、authorize 与 executeToolCall,不重复讲解工具协议。
src/mcp/transport.tssrc/mcp/client.tssrc/mcp/schema.tssrc/mcp/tools.tssrc/cli.tstests/mcp.test.ts
对 Harness 来说,MCP 不是新的执行通道,而是又一批工具:必须经过同一个 Schema 校验、同一个 authorize 和同一个 Executor。任何绕过这条链路的“直连 MCP”实现,都等于在权限层上开了一个后门。
Step 1定义传输边界
创建 src/mcp/transport.ts。上层只认识这几个方法,不认识 stdio、WebSocket 或 HTTP;JSON-RPC 错误在传输层就折叠成两种结果,避免调用点各自解析协议。
export type McpResponse = | { ok: true; result: unknown } | { ok: false; error: { code: number; message: string } };export interface McpTransport { request(method: string, params: unknown, signal: AbortSignal): Promise<McpResponse>; close(): Promise<void>;}result 保持为 unknown,因为每个方法的返回形状不同,应由调用点在紧邻位置校验,而不是在传输层做笼统断言。超时与取消的策略值统一放在 McpRequestContext(signal 与 timeoutMs)里传递。线上实现(例如子进程 stdio)负责用 signal 中止底层读写、超时后返回 error 而不是挂起、并在 close() 中回收全部句柄。离线实现是本章的主角:
export class FakeTransport implements McpTransport { readonly calls: { method: string; params: unknown }[] = []; closed = false; constructor(private readonly handlers: Record<string, (params: unknown) => McpResponse>) {} async request(method: string, params: unknown, signal: AbortSignal): Promise<McpResponse> { this.calls.push({ method, params }); if (signal.aborted) return { ok: false, error: { code: -32_800, message: "cancelled" } }; const handler = this.handlers[method]; if (!handler) return { ok: false, error: { code: -32_601, message: "method_not_found" } }; return handler(params); } async close(): Promise<void> { this.closed = true; }}只依赖这个接口带来三个好处:测试不需要网络和子进程就能覆盖超时、取消与崩溃;权限与审计层完全不需要知道 MCP 的存在;换传输实现时 src/mcp/client.ts 以上的代码一行都不用改。真实接入应使用官方 @modelcontextprotocol/sdk 的传输实现,本章不引入该依赖。
Step 2发现并映射能力
创建 src/mcp/client.ts。先 initialize 握手并校验服务端声明的协议版本,再用 tools/list 拉取能力清单。把远端声明映射成本地描述符,字段含义对齐已有的 SkillSummary:只有名称、描述和 Schema 进入模型可见的工具列表。
export interface McpToolDescriptor { server: string; remoteName: string; description: string; inputSchema: unknown;}export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"] as const;/** context 中的 signal 与 timeoutMs 由调用方注入,不来自服务端。 */export async function initializeServer( transport: McpTransport, context: McpRequestContext,): Promise<{ protocolVersion: string }> { const request = { protocolVersion: "2025-06-18", capabilities: { tools: {} }, clientInfo: { name: "agent-from-scratch", version: "0.1.0" }, }; const response = await transport.request("initialize", request, context.signal); if (!response.ok) throw new Error("mcp_initialize_failed: " + response.error.message); const protocolVersion = asRecord(response.result).protocolVersion; if (typeof protocolVersion !== "string" || !SUPPORTED_PROTOCOL_VERSIONS.some((v) => v === protocolVersion)) { throw new Error("unsupported_protocol_version: " + String(protocolVersion)); } await transport.request("notifications/initialized", {}, context.signal); return { protocolVersion };}export async function listTools( server: string, transport: McpTransport, context: McpRequestContext,): Promise<McpToolDescriptor[]> { const response = await transport.request("tools/list", {}, context.signal); if (!response.ok) throw new Error("mcp_tools_list_failed: " + response.error.message); const tools = asRecord(response.result).tools; if (!Array.isArray(tools)) throw new Error("invalid_tools_list"); return tools.map((tool) => { const record = asRecord(tool); if (typeof record.name !== "string" || !record.name) throw new Error("invalid_tool_name"); if (typeof record.description !== "string") throw new Error("invalid_tool_description"); return { server, remoteName: record.name, description: record.description, inputSchema: record.inputSchema }; });}工具名必须加命名空间,例如 mcp__<server>__<tool>,并把结果记录进 McpServerDescriptor。服务器名与工具名都限制为 [a-zA-Z0-9_-],其余字符直接拒绝。注册时调用已有的 ToolRegistry.register:它在重名时抛错,所以“远端工具覆盖本地 read_file”会变成启动失败而不是静默替换。本地工具是经过审查、有确定沙箱语义的实现,绝不能被一个远端声明顶掉;同一服务器返回两个同名工具同样属于启动失败。
Step 3转换并校验参数
MCP 传回的是 JSON Schema 文本,本地 RegisteredTool.parameters 与 PreparedToolCall 的校验策略必须一起生成。创建 src/mcp/schema.ts,把转换失败变成显式拒绝,而不是“跳过校验先跑起来”。
import { z, type ZodType } from "zod";const ALLOWED_KEYWORDS = new Set([ "type", "properties", "required", "additionalProperties", "items", "enum", "const", "description", "default", "minimum", "maximum", "minLength", "maxLength", "oneOf", "anyOf",]);export function toLocalSchema(inputSchema: unknown): { schema: ZodType; parameters: Record<string, unknown> } { if (!isSupportedJsonSchema(inputSchema)) throw new Error("unsupported_input_schema"); const schema = z.fromJSONSchema(inputSchema); const { $schema: _, ...parameters } = z.toJSONSchema(schema); return { schema, parameters };}export function isSupportedJsonSchema(value: unknown): boolean { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; return Object.keys(value).every((keyword) => ALLOWED_KEYWORDS.has(keyword));}z.fromJSONSchema 得到校验器、z.toJSONSchema 生成模型可见的 JSON Schema,保证运行时校验与模型看到的定义来自同一来源。远端 Schema 里的未知关键字(例如 $ref、patternProperties 或自定义扩展)一律拒绝:转换器无法保证它们的语义,放任不管就会出现“模型以为会被校验、实际没有”。该 API 目前是半实验特性,升级 Zod 时必须连同本章测试一起跑。转换之后还要为 strict function calling 补齐三项:每个对象必须显式带 additionalProperties: false,否则模型可以塞入未声明字段;所有字段都要列入 required,因为 strict 模式不接受可选字段;可选值改用 ["string", "null"] 这样的联合类型表达。这些补齐只用于规范化模型可见定义,不能反过来放宽本地校验器。
最后把描述符注册成本地工具:
const tool = defineTool({ name: "mcp__" + descriptor.server + "__" + descriptor.remoteName, description: descriptor.description, schema, effect: "external", execute: (input, context) => callRemoteTool(descriptor, input, context),});defineTool 的 prepare 会在任何副作用之前运行 schema.parse(input),所以模型传错参数时得到的是 invalid_arguments observation,而不是一次带坏参数的远端调用。
Step 4复用权限与沙箱
MCP 工具默认 effect: "external",这让它们自动落入第 09 章的既有策略:authorize(tool, prepared.input, policyContext) 会返回 ask 或 deny,产生 ApprovalRequest,把运行置为 waiting,并在人工批准后把执行放进 sandbox。不要为 MCP 新增第二条授权路径,也不要让 MCP 传输层自己在请求里附带凭证。
const prepared = tool.prepare(rawInput);const decision = authorize(tool, prepared.input, policyContext);if (decision.type === "deny") return observation(callId, { ok: false, error: "policy_denied", reason: decision.reason });if (decision.type === "ask" && !(await store.consumeApprovalGrant(state.runId, decision.request.actionDigest))) { await store.saveApprovalRequest(state.runId, decision.request); return { type: "waiting", requestId: decision.request.id, reason: "approval_required" };}return executeInSandbox(() => prepared.execute(toolContext), { network: policyContext.network });远端调用与 run_command 走同一道门禁,没有例外。远端服务器声明的 annotations.readOnlyHint 或 title 只是提示,不能作为信任依据:能力清单来自对端进程,只读声明无法被本地验证,最多用于生成 ApprovalRequest.summary 帮助操作者判断,绝不能用来把 effect 降级成 read。批准的 actionDigest 必须继续绑定工具名、规范化后的 input、cwd 与 network 策略;服务器在两次调用之间换掉 Schema 或工具列表,也必须让旧批准失效。
Step 5管理生命周期与取消
一个受控的 MCP 连接包含四个阶段,每个阶段都必须有超时和取消:
- 握手:
initialize携带客户端协议版本,服务端返回它的版本,双方取交集;没有交集就拒绝启动该服务器,而不是带着未知协议继续。 - 发现:
tools/list的结果必须重新生成注册表,工具集合的任何变化都要让旧批准与旧会话失效。 - 调用:每次
tools/call都绑定一个AbortController与timeoutMs;运行被取消或超时时,先signal.abort(),再把结构化失败写回 observation。 - 关闭:CLI 退出、配置变更或连接长时间空闲时调用
transport.close(),等待子进程退出并清理句柄。
取消必须沿同一条链传播:ToolContext.signal 来自 executeToolCall 的超时控制器,传给 McpTransport.request,再由传输实现中止底层读写。传输层返回的 cancelled 错误要继续向上传递,而不是被吞掉变成空结果。
async function callRemoteTool( descriptor: McpToolDescriptor, input: unknown, context: ToolContext,): Promise<unknown> { const response = await transport.request( "tools/call", { name: descriptor.remoteName, arguments: input }, context.signal, ); if (!response.ok) return { ok: false, error: "mcp_error", code: response.error.code }; const result = asRecord(response.result); if (result.isError === true) return { ok: false, error: "mcp_tool_error", content: result.content }; return { ok: true, content: result.content };}close() 同样要有幂等语义与超时,失败时通过 AgentEvent 记录 mcp_close_failed,不能把关闭异常抛回退出的 CLI。服务端崩溃、进程提前退出或连接断开,都必须被翻译成结构化 observation,而不是让 request 抛出未处理异常直达循环。这样模型能基于 observation 换工具或报告失败,Harness 也能按既有规则决定停止原因。initialize、mcp_tool_error、mcp_close_failed 这些节点都要写入 AgentEvent 事件流,包含服务器名、方法名、耗时与结果码,绝不记录凭证或原始大响应。
Step 6用 Fake Transport 验证
创建 tests/mcp.test.ts,全部用例只使用 Fake Transport。先验证正向路径:initialize 与 tools/list 被依次调用,工具以 mcp__<server>__<tool> 注册进 ToolRegistry,并且 registry.get(name)?.effect === "external"。
it("maps remote tools into namespaced external tools", async () => { const transport = new FakeTransport({ initialize: () => ok({ protocolVersion: "2025-06-18" }), "tools/list": () => ok({ tools: [{ name: "search", description: "Docs search", inputSchema: OBJECT_SCHEMA }] }), }); await initializeServer(transport, context); const tools = await listTools("docs", transport, context); const registry = new ToolRegistry(); registerMcpTools(registry, tools.map(toLocalTool)); expect(registry.names()).toEqual(["mcp__docs__search"]); expect(registry.get("mcp__docs__search")?.effect).toBe("external"); expect(transport.calls.map((call) => call.method)).toEqual(["initialize", "notifications/initialized", "tools/list"]);});然后覆盖六组负向用例,每组都先注册一个同名本地工具或固定策略,确认失败是显式且可审计的:
- 未知工具:模型调用
mcp__docs__missing,executeToolCall返回unknown_toolobservation,Fake Transport 一次都没被调用; - Schema 不匹配:
inputSchema缺少required字段或additionalProperties: false,prepare抛错并得到invalid_arguments,远端调用次数为零; - 服务器超时:handler 永不返回,
timeoutMs到期后signal被中止,结果是与主线一致的timeoutobservation; - 重名覆盖:工具名与本地
read_file冲突时register抛错,注册表内容不变; - 不可信只读声明:带
annotations.readOnlyHint: true的工具仍然走authorize,策略返回deny或ask,不会进入执行; - 崩溃与关闭:
tools/call返回传输错误时得到mcp_errorobservation;取消传播后close()被调用一次,且没有未处理拒绝。
最后在 executeToolCall 超时后断言 FakeTransport 记录到的 signal.aborted === true,确保信号真的传到了传输边界,而不是只停在 Executor 里。