返回 AIPA 笔记
AIPA Day 50

MCP server 构建 I — 把 agent-v2 检索封装成无状态 MCP server

MCP server 构建 I — 把 agent-v2 检索封装成无状态 MCP server

2026-08-03
mcpstateless-servertool-registrationjson-rpc

日期: 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 都能挂上来用。今天回答三个问题:

  1. 怎么把 hybridSearch 这种「agent 内部检索」封装成一个 stateless MCP server? 关键是理解 2026-07-28 规范把「会话」整个删掉后,server 怎么写才不踩坑。
  2. 工具怎么注册、schema 怎么声明? MCP 的 tools/list 不是随便返回个函数名——它要求 JSON Schema 2020-12,client 靠它做参数校验和 UI 渲染。
  3. 静态导出仓库(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-Id header;
  • 删掉 initialize / initialized 握手;
  • 删掉 sticky routing(不再需要把同一 client 的请求粘到同一 server 实例);
  • 删掉 tasks/list(理由:没有会话就无法安全地 scope「列出我的任务」)。

取而代之,Streamable HTTP transport 要求请求带 Mcp-MethodMcp-Name header,让负载均衡器不看 body 就能路由;client 的能力/信息改放在每个请求的 _meta 里随包旅行(MCP Blog, 2026-07-28)。

反直觉洞察①:把检索封装成 MCP server,最难的不是「写工具」,是「彻底戒掉服务端状态」。 直觉会想在 server 里 cache 一个「当前用户的检索上下文」(上一次查了什么、过滤条件是什么)。RC 之后这是反模式——任何请求可能命中任意实例,server 端 cache 在水平扩缩容下必然不一致。正确做法:把全部上下文塞进 tools/callarguments + _meta,server 端纯函数化。对我们恰好天然契合:hybridSearch(query, filters) 本来就是纯函数,它本质上比一个会话式聊天 server 更适合做 MCP server

封装的最小映射关系:

agent-v2 进程内MCP server 暴露说明
hybridSearch(query, k, filters)tool search_aml_notesRRF 融合检索,纯函数
searchNotes / getNotetool get_note按 path 取全文
返回 { chunks, scores }CallToolResult.content[] + structuredContent双通道:人读 text + 机读结构化
orchestrator 内部 budget不进 server,留在 clientserver 无状态,预算属调用方

这里要想清楚一个边界划分:我们的 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,                                    // 给程序读
    }
  },
)

两个关键工程点:

  1. schema 已升到 JSON Schema 2020-12(MCP Blog, 2026-07-28):inputSchema / outputSchema 现在允许 oneOf/anyOf/allOf、条件式、$ref/$defs,但 input 的根仍必须 type:"object"。这意味着我们能把 typology 这种联合类型干净地表达,而不用退化成裸字符串。

  2. 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 静态托管),所以分两层落地:

  1. 协议层(真代码):在 src/agent/mcp/ 写一个传输无关的 MCP server 核心——工具注册表 + JSON-RPC 请求 dispatcher(handleRpc(req): res)。它不依赖 HTTP,输入输出都是 JSON-RPC 对象。这样同一份代码:(a) 浏览器内可直接喂消息演示;(b) 未来包一层 StreamableHTTPServerTransportsessionIdGenerator: undefined 即 stateless 模式,TS SDK server guide 2026)就能部署成真 server。

  2. 教学层(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,不进 serverserver 无状态,预算是调用方责任
trace_meta.traceparent 接 useTraceStoreMCP 调用进同一条 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 鉴权。

参考资料

  1. 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-Name header 路由、_meta 携带 clientInfo+W3C Trace Context、inputSchema/outputSchema 升 JSON Schema 2020-12 (2026-07)
  2. MCP TypeScript SDK — Server Guide / docs/server.mdnew McpServer({name,version})registerTool(name,{title,description,inputSchema,outputSchema},handler)、handler 返回 content[](type:text)+structuredContentStreamableHTTPServerTransport({sessionIdGenerator:undefined}) 即 stateless (2026)
  3. typescript-sdk Issue #745 / #1143 — Zod→JSON Schema 转换坑:曾生成 draft-07 与 2020-12 client 不兼容;Zod 4 下 .describe() 描述不传播 (2025-2026)
  4. 本仓库 src/agent/rag/hybridSearch.ts(RRF 融合检索)、src/agent/knowledge/tools/getNote.tssrc/agent/trace/useTraceStore.tssrc/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 的可用性。