MCP server 构建 I — 把 agent-v2 检索封装成无状态 MCP server
MCP server 构建 I — 把 agent-v2 检索封装成无状态 MCP server
日期: 2026-08-03 阶段: Phase 2 - AI-native 参考架构 标签: #mcp #stateless-server #tool-registration #json-rpc
核心问题
P1 把 AML Copilot 的检索能力堆在 src/agent/rag/hybridSearch.ts 里,是「进程内函数调用」——只有本仓库的 orchestrator 能用。但 MCP(Model Context Protocol)的整个意义是:把一个工具暴露成跨进程、跨厂商、跨 agent 都能调的标准接口,Claude Desktop / Cursor / 任意 MCP host 都能挂上来用。今天回答三个问题:
- 怎么把
hybridSearch这种「agent 内部检索」封装成一个 stateless MCP server? 关键是理解 2026-07-28 规范把「会话」整个删掉后,server 怎么写才不踩坑。 - 工具怎么注册、schema 怎么声明? MCP 的
tools/list不是随便返回个函数名——它要求 JSON Schema 2020-12,client 靠它做参数校验和 UI 渲染。 - 静态导出仓库(Cloudflare Pages,无后端)下怎么落地? 我们没有常驻服务器,所以这一篇落「协议结构的 TS 实现 + 浏览器内教学演示」,真正的 HTTP server 部署留到能用 Workers 时。
注意诚实边界:本仓库是纯静态前端,下面的 stateless HTTP server 是「协议正确的 TS 实现 + 教学 lab」,不是已部署的线上服务。涉及真实部署的部分一律用计划语气。
关键内容
A. 把 hybridSearch 封装成 stateless MCP server:为什么「无状态」是 2026 的硬约束
先看一个反直觉的事实——MCP 在 2026-07-28 RC 里把协议级会话整个删掉了(MCP Blog, 2026-07-28):
- 删掉
Mcp-Session-Idheader; - 删掉
initialize/initialized握手; - 删掉 sticky routing(不再需要把同一 client 的请求粘到同一 server 实例);
- 删掉
tasks/list(理由:没有会话就无法安全地 scope「列出我的任务」)。
取而代之,Streamable HTTP transport 要求请求带 Mcp-Method 和 Mcp-Name header,让负载均衡器不看 body 就能路由;client 的能力/信息改放在每个请求的 _meta 里随包旅行(MCP Blog, 2026-07-28)。
反直觉洞察①:把检索封装成 MCP server,最难的不是「写工具」,是「彻底戒掉服务端状态」。 直觉会想在 server 里 cache 一个「当前用户的检索上下文」(上一次查了什么、过滤条件是什么)。RC 之后这是反模式——任何请求可能命中任意实例,server 端 cache 在水平扩缩容下必然不一致。正确做法:把全部上下文塞进
tools/call的arguments+_meta,server 端纯函数化。对我们恰好天然契合:hybridSearch(query, filters)本来就是纯函数,它本质上比一个会话式聊天 server 更适合做 MCP server。
封装的最小映射关系:
| agent-v2 进程内 | MCP server 暴露 | 说明 |
|---|---|---|
hybridSearch(query, k, filters) | tool search_aml_notes | RRF 融合检索,纯函数 |
searchNotes / getNote | tool get_note | 按 path 取全文 |
返回 { chunks, scores } | CallToolResult.content[] + structuredContent | 双通道:人读 text + 机读结构化 |
| orchestrator 内部 budget | 不进 server,留在 client | server 无状态,预算属调用方 |
这里要想清楚一个边界划分:我们的 orchestratorAgent.ts 里有一整套 Budget(步数/工具调用次数/成本/超时的预算守卫),它不应该跟着工具下沉到 MCP server。原因是预算是「调用方的会话级关切」——一个 MCP host 这次愿意为这条查询花多少钱、调多少步,是 host 的策略,不是被调工具该管的事。同一个 search_aml_notes server,被 Claude Desktop 调和被我们自己的 orchestrator 调,预算口径完全不同。把预算留在 client、把检索纯函数化下沉到 server,恰好是「关注点分离」在跨进程边界上的体现:server 只负责「把这件事做对」,预算/重试/熔断这些「做多少」的决策留给调用方。这也解释了为什么 stateless 对我们不是约束而是顺水推舟——agent-v2 的检索本来就没有「跨调用要记住的东西」,唯一像状态的 budget 本就该在 client。
B. 工具注册与 schema 声明:registerTool 与 JSON Schema 2020-12
TS SDK(@modelcontextprotocol/sdk)的注册 API 形态(MCP TS SDK server guide, 2026):
// MCP server 工具注册伪代码(对照 TS SDK registerTool 真实签名)
const server = new McpServer({ name: 'aml-copilot', version: '0.1.0' })
server.registerTool(
'search_aml_notes',
{
title: 'AML 知识检索',
description: '在 312 篇 AML/合规笔记上做 BM25+向量 RRF 融合检索,返回最相关片段。',
// inputSchema 用 Zod 写,SDK 在 tools/list 时转成 JSON Schema 2020-12 上线
inputSchema: z.object({
query: z.string().min(3).describe('自然语言检索意图'),
k: z.number().int().min(1).max(20).default(8).describe('返回片段数'),
typology: z.enum(['structuring', 'layering', 'mule_network']).optional(),
}),
outputSchema: z.object({
chunks: z.array(z.object({ path: z.string(), text: z.string(), score: z.number() })),
}),
},
async ({ query, k, typology }) => {
const hits = hybridSearch(query, { k, filter: typology }) // 复用现成纯函数
const out = { chunks: hits.map(h => ({ path: h.path, text: h.text, score: h.score })) }
return {
content: [{ type: 'text', text: JSON.stringify(out) }], // 给模型读
structuredContent: out, // 给程序读
}
},
)
两个关键工程点:
-
schema 已升到 JSON Schema 2020-12(MCP Blog, 2026-07-28):
inputSchema/outputSchema现在允许oneOf/anyOf/allOf、条件式、$ref/$defs,但 input 的根仍必须type:"object"。这意味着我们能把typology这种联合类型干净地表达,而不用退化成裸字符串。 -
Zod→JSON Schema 的转换坑:TS SDK 历史上曾生成 draft-07,与要求 2020-12 的 client 不兼容(typescript-sdk Issue #745);Zod 4 下还出现过
.describe()描述不传播到 tool 定义的 bug(Issue #1143)。落地纪律:注册后必须断言tools/list返回的 JSON Schema 里$schema是 2020-12,且每个字段的description都在——这正好做成一个 CI 检查。
JSON-RPC 消息流(tools/call 一次完整往返):
Client Server (stateless)
│ ── tools/list ──────────────────────▶ │ 返回 [search_aml_notes, get_note] + JSON Schema
│ ◀───────────────────── tools 数组 ──── │
│ │
│ ── tools/call ──────────────────────▶ │ method=tools/call
│ { name:"search_aml_notes", │ name + arguments + _meta(clientInfo,traceparent)
│ arguments:{query:"结构化拆分",k:5}, │ → 执行 hybridSearch(纯函数,不碰状态)
│ _meta:{ "...clientInfo":{...}, │
│ traceparent:"00-..." } } │
│ ◀──────── CallToolResult ───────────── │ content[]+structuredContent
_meta 里携带 W3C Trace Context 的 traceparent/tracestate/baggage(MCP Blog, 2026-07-28)——这正好能接到 P1 已建的 src/agent/trace/useTraceStore.ts,让 MCP 调用进同一条 trace。
C. 静态导出仓库下的落地:协议结构 TS 实现 + 教学 lab
我们没有常驻后端(Cloudflare Pages 静态托管),所以分两层落地:
-
协议层(真代码):在
src/agent/mcp/写一个传输无关的 MCP server 核心——工具注册表 + JSON-RPC 请求 dispatcher(handleRpc(req): res)。它不依赖 HTTP,输入输出都是 JSON-RPC 对象。这样同一份代码:(a) 浏览器内可直接喂消息演示;(b) 未来包一层StreamableHTTPServerTransport(sessionIdGenerator: undefined即 stateless 模式,TS SDK server guide 2026)就能部署成真 server。 -
教学层(lab):仿
src/dsdb-lab/的交互式教学 lab 模式,做一个「MCP server explorer」面板——左边显示注册的工具 + 其 JSON Schema,右边让用户手填tools/call参数、点击发送、看 JSON-RPC 往返报文和检索结果。把抽象协议可视化成消息流,本身就是作品集的加分项。
为什么这种「传输无关 core + 两层落地」的拆法值得,而不是图省事直接写个绑死 HTTP 的 server?因为 MCP 协议的价值恰恰在于「同一份工具逻辑,可以被不同传输承载」。今天没有后端额度,明天接上 Cloudflare Workers,后天可能要支持 stdio 传输给本地 Claude Desktop——如果 server 核心把 JSON-RPC dispatch 和 HTTP 处理揉在一起,每换一种承载都要重写。反过来,把 handleRpc(req): res 这个纯 JSON-RPC 函数抽出来,传输层只是「把字节变成 JSON-RPC 对象、再把结果变回字节」的薄壳。这是 P1 一以贯之的纪律:核心逻辑纯函数化、副作用/IO 推到边缘——hybridSearch 是纯函数、evalChecks 是纯函数、现在 MCP core 也是纯函数。一个静态前端能完整跑通并演示一个「协议正确」的 MCP server,这件事本身对求职作品集的说服力,比一个跑在某个看不见的服务器上、观众无法验证的 demo 强得多——可被观众当场验证的协议实现 > 宣称已部署但不可见的服务。
| 部署形态 | 状态 | 适配代价 |
|---|---|---|
浏览器内 handleRpc + lab | 本篇落地(可跑) | 0,纯前端 |
| Cloudflare Workers + Streamable HTTP | 计划(需后端额度) | 包一层 transport,core 不动 |
| 接入真实 MCP host(Cursor) | 计划(需常驻 endpoint) | 同上 + OAuth(见 Day 51) |
设计要点/决策表
| 要点 | 决策 | 理由 |
|---|---|---|
| server 有无状态 | 无状态,上下文全进 arguments/_meta | 对齐 2026-07-28 RC,水平扩容不踩坑 |
| 暴露哪些工具 | search_aml_notes + get_note | 都是纯函数,最契合 stateless |
| schema 写法 | Zod 写、SDK 转 JSON Schema 2020-12 | 类型安全 + 协议合规 |
| 返回结构 | content[](text) + structuredContent 双通道 | 模型读 text、程序读结构化 |
| 传输 | core 传输无关;先浏览器内,后 Workers | 静态仓库无后端,分层落地 |
| 预算/budget | 留在 client,不进 server | server 无状态,预算是调用方责任 |
| trace | _meta.traceparent 接 useTraceStore | MCP 调用进同一条 trace |
对本项目的落地
- 新建
src/agent/mcp/server.ts:导出createAmlMcpServer()返回一个传输无关的注册表 +handleRpc(req: JsonRpcRequest): JsonRpcResponse,内部用registerTool注册search_aml_notes(复用src/agent/rag/hybridSearch.ts)和get_note(复用src/agent/knowledge/tools/getNote.ts)。纯函数化是硬约束:不在 server 内持有任何跨请求状态。 - 新建
src/agent/mcp/toolSchemas.ts:集中放 Zod inputSchema,并导出一个assertJsonSchema2020(schema)守卫,CI 里断言tools/list产物$schema为 2020-12 且字段 description 非空(防 Issue #745/#1143 复现)。 - 教学 lab:仿
src/dsdb-lab/做MCP server explorer面板,渲染工具+schema、手填参数发tools/call、显示 JSON-RPC 往返与检索结果——把协议消息流可视化。 - trace 接入:
handleRpc从_meta.traceparent取 W3C trace id,写入src/agent/trace/useTraceStore.ts,让 MCP 调用与 orchestrator 同 trace。 - 诚实标注:
server.ts头注写明「本实现为协议正确的 stateless 核心 + 浏览器内演示;真实 HTTP 部署(Cloudflare Workers + Streamable HTTP transport,sessionIdGenerator: undefined)为 P2 后续,需后端额度」。Day 51 在此基础上加 Tasks(长任务异步)与 OAuth 鉴权。
参考资料
- Model Context Protocol Blog — The 2026-07-28 MCP Specification Release Candidate:stateless core(删
Mcp-Session-Id/initialize/sticky routing/tasks/list)、Mcp-Method/Mcp-Nameheader 路由、_meta携带 clientInfo+W3C Trace Context、inputSchema/outputSchema升 JSON Schema 2020-12 (2026-07) - MCP TypeScript SDK — Server Guide / docs/server.md:
new McpServer({name,version})、registerTool(name,{title,description,inputSchema,outputSchema},handler)、handler 返回content[](type:text)+structuredContent、StreamableHTTPServerTransport({sessionIdGenerator:undefined})即 stateless (2026) - typescript-sdk Issue #745 / #1143 — Zod→JSON Schema 转换坑:曾生成 draft-07 与 2020-12 client 不兼容;Zod 4 下
.describe()描述不传播 (2025-2026) - 本仓库
src/agent/rag/hybridSearch.ts(RRF 融合检索)、src/agent/knowledge/tools/getNote.ts、src/agent/trace/useTraceStore.ts、src/dsdb-lab/(交互式教学 lab 模式参照)(2026-06)
SOTA 检查 (2026-06-11)
- MCP 2026-07-28 规范当前处于 RC 阶段(RC 2026-05-21 锁定 stateless core/Tasks/Extensions/MCP Apps,最终规范 2026-07-28 发布)。本篇写于 2026-08-03 的计划时点,按 RC 口径实现;执行当周须用 WebSearch 复核最终规范是否对 stateless 路由 header(
Mcp-Method/Mcp-Name)或 schema 版本有微调。 - stateless 是 2026 主线方向,未见回退到会话式的迹象;但「stateless HTTP transport variant」在 RC 时仍标注 in review,落地真实部署前须确认其稳定。
- TS SDK 版本快变:2026-06 口径 Claude Agent SDK / MCP TS SDK 仍在迭代,v2 稳定版预期 Q3 2026 随规范发布;
registerTool接受ZodType<object>(支持z.union)的改动(PR #816)须按执行当周实际版本确认 API 形态。 - 过时认知警示:任何「MCP server 要维护 session、要做 sticky routing」的旧资料(2025 上半年 SSE 时代)已过时;stateless 之后 server 端持有跨请求状态是反模式。
- 待跟踪:最终规范发布后,回填
_meta字段命名(io.modelcontextprotocol/clientInfo等)是否变化;以及 Cloudflare Workers 上 Streamable HTTP transport 的可用性。