无状态 MCP Server 要点
写 MCP Server 的技术门槛不高,难的是写对。三个地方最容易出错:把状态留在进程里、鉴权做得不完整、工具描述写得像给人看的而不是给模型看的。
本文基于 2026-07-28 版规范(截至 2026 年 10 月为当前版本)。协议本身的概念见《MCP 的工具、资源和提示模板》,这篇只讲实现。
1. 传输:stdio 还是 HTTP
stdio 用于本地:客户端以子进程方式拉起 Server,靠标准输入输出通信。没有端口、没有网络配置、也没有鉴权问题——因为调用方就是本机用户自己。个人工具、本地文件与本地数据库的集成,选它。
Streamable HTTP 用于远程:一个端点,POST 进 JSON-RPC,服务端可以返回 JSON,也可以返回一条与本次请求绑定的 SSE 流推进度。
这一版的关键变化是协议层不再有会话。早期版本要先握手拿 Mcp-Session-Id,之后每个请求都带上它,客户端因此被钉在签发会话的那台实例上——横向扩展必须配粘性路由和共享会话存储。
新版本取消握手与会话头,改成每个请求自描述:协议版本、客户端信息、能力都放在请求体的 _meta 里,任何实例都能处理任何请求。
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: searchOrder
这几个请求头把方法名和目标暴露给了基础设施,网关因此不解析请求体就能做路由、限流和鉴权。相应地有一条纪律:不要把令牌、密码、个人信息放进这些头里,它们可能被代理和网关记录下来。
2. 无状态:一个开关,和一次真正的改造
TypeScript SDK 里打开无状态只需要一个字段:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
const server = new McpServer({ name: 'order-mcp', version: '1.0.0' })
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // 不签发会话,每个请求自描述
enableJsonResponse: true, // 纯 JSON 响应,不挂 SSE(便于穿过某些代理)
})
await server.connect(transport)
// 每个 HTTP 请求交给 transport.handleRequest(req, res, body)
开关是一行,但真正的改造在别处:原来存在会话里的东西,要搬到进程外。
2.1 跨调用的状态怎么办
有些工具确实需要"上一次的结果"——典型是多步向导(先创建草稿,再往里加内容,最后提交)。
规范的说法很直接:协议没有"状态句柄"这个概念,需要就自己签发。做法是——创建类工具在返回值里给一个不透明标识符,后续工具把这个标识符当作普通入参接收,状态本身存在数据库或缓存里,用这个标识符做键。
server.registerTool('createDraft', {
description: '创建一份工单草稿,返回 draftId,后续步骤必须带上它。',
inputSchema: { title: z.string() },
}, async ({ title }) => {
const id = randomUUID()
await store.set(`draft:${id}`, { title, items: [] }, { ttl: 3600 })
return { content: [{ type: 'text', text: JSON.stringify({ draftId: id }) }] }
})
server.registerTool('addItem', {
description: '向已有草稿添加一条内容。draftId 由 createDraft 返回。',
inputSchema: { draftId: z.string(), text: z.string() },
}, async ({ draftId, text }) => {
const draft = await store.get(`draft:${draftId}`)
if (!draft) return { isError: true, content: [{ type: 'text', text: '草稿不存在或已过期' }] }
// ...
})
这里有一条安全纪律必须写进代码规范:句柄只是个名字,不是能力。每次调用都要重新校验调用方对这个句柄的权限,不能因为"他拿着这个 id"就放行。对未鉴权的 Server 来说,句柄等同于一个 bearer token,那就必须用足够熵的生成方式(UUIDv4,不是自增计数器)并设置有限的生命周期。
2.2 还有一条容易踩的
tools/list、resources/list、prompts/list 的返回不能按连接变化(可以按请求携带的授权变化)。也就是说,不能靠"这个连接之前做过什么"来决定给它看哪些工具——无状态下根本没有"这个连接"的概念。想给不同用户不同工具集,只能走鉴权维度。
3. 鉴权:OAuth 2.1 Resource Server
远程 Server 必须做鉴权,否则端点一旦被发现,任何人都能调你的工具。
资源服务器模型是 2025-06-18 版确立的:MCP Server 作为 OAuth 2.1 Resource Server——对外宣告 Protected Resource Metadata(RFC 9728),校验 bearer token,并且验证这个 token 是发给本服务的(RFC 8707 resource indicator)。中间还有 2025-11-25 版做过一轮修订,2026-07-28 版没有推翻这个模型,而是在它之上又加了三项。
audience 校验是最多人跳过的,也是最关键的一步:不做这一步,别人拿一个发给其他服务的 token 就能重放到你这里。
2026-07-28 版新增的三项认证要求,升级时要一并跟上:
- RFC 9207 issuer 校验:授权响应里必须带上签发方标识,客户端要校验它与自己发起请求时用的是同一个授权服务器,防的是混合授权服务器攻击。
- 凭据绑定签发方:拿到的 token 要能与签发它的授权服务器对应上,不能只看签名对不对。
- DCR 改为 CIMD:动态客户端注册(DCR)不再是推荐路径,改为客户端元数据文档(CIMD)——客户端把自己的元数据放在一个 HTTPS URL 上,注册时只交这个 URL。做客户端的要准备一份可公开访问的元数据文档。
TypeScript SDK 提供了配套组件:
import { mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js'
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'
const mcpServerUrl = new URL('https://your-host.example.com/mcp')
const tokenVerifier = {
verifyAccessToken: async (token) => {
const data = await introspect(token) // 问你的授权服务
if (!audienceMatches(data.aud, mcpServerUrl)) {
throw new Error('token 不是发给本服务的')
}
return { token, clientId: data.client_id, scopes: (data.scope || '').split(' '), expiresAt: data.exp }
},
}
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl, scopesSupported: ['mcp:tools'] }))
app.post('/mcp', requireBearerAuth({ verifier: tokenVerifier, requiredScopes: [], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) }), mcpPostHandler)
不要在工具内部手写 JWT 校验、把密钥硬编码进去——每个工具各写一遍,等于没有边界。鉴权应该是中间件层的事,工具只管业务。
4. 工具设计:描述是给模型看的
模型看不到工具的实现,只看得到描述和参数 Schema。所以描述的质量直接决定调用质量——Anthropic 在 Building Effective Agents 一文里把这一点说得很重:工具定义和描述应当获得与提示词同等的工程投入。
几条具体规则:
描述要写清楚"什么时候用",不只是"是什么"。 "查询订单"不如"按订单号查询订单状态,只读,不修改任何数据。需要订单号,形如 202601010001"。
参数用 describe 带上格式和示例。 模型要据此拼出参数值,只写 orderId: string 它不知道该传什么形态。
把边界写进去。 只读工具明确写"只读";有副作用的工具明确写"会产生副作用,需用户确认"。
别让两个工具的职责重叠。 重叠的工具会让模型在两者之间摇摆,表现为"有时用错"。能合并就合并。
server.registerTool('searchOrder', {
description: '按订单号查询订单状态。只读操作,不修改任何数据。',
inputSchema: {
orderId: z.string().describe('订单号,形如 202601010001'),
},
}, async ({ orderId }) => {
const order = await queryOrder(orderId)
if (!order) return { isError: true, content: [{ type: 'text', text: '未找到该订单' }] }
return { content: [{ type: 'text', text: JSON.stringify(order) }] }
})
错误处理上有一条要单独说:失败要区分"业务上没找到"和"系统出错"。前者用 isError: true 带上人能读懂的原因,让模型知道该怎么向用户解释;后者应该让错误暴露出来,而不是翻译成一句模棱两可的话——把 500 说成"查询失败",模型会当成"没有这个订单",然后给用户一个错的结论。
5. 出参那一半:结构化输出
前面讲的都是入参。工具的出参也有正式契约,由两个字段组成,很多人只用了其中一个。
outputSchema —— 出参的 JSON Schema,声明在工具定义里,客户端据此校验返回值。它和 inputSchema 有一处关键差别:outputSchema 没有根类型限制。入参的根必须是 object(一堆具名参数),出参没有这个约束——根可以是 object、array,也可以是 string、number 这类标量。返回"一组记录"或"一个数字"都不必硬包一层对象。
structuredContent —— 工具结果里与 content 并列的一个字段,可以是任意 JSON 值。分工是明确的:content 是给人(和给模型读)的呈现,structuredContent 是给程序消费的机器可读结果。
server.registerTool('searchOrder', {
description: '按订单号查询订单状态。只读操作,不修改任何数据。',
inputSchema: { orderId: z.string() },
outputSchema: { status: z.enum(['created', 'paid', 'shipped']), amount: z.number() },
}, async ({ orderId }) => {
const order = await queryOrder(orderId)
return {
content: [{ type: 'text', text: `订单 ${orderId}:${order.status}` }],
structuredContent: { status: order.status, amount: order.amount },
}
})
两条纪律:两者必须与 outputSchema 一致,声明了却不兑现,客户端校验会直接失败;structuredContent 不是"把文本再塞一遍",它是出参契约,消费方会按字段解析,所以字段要稳定、要版本化,别把给人看的排版塞进去。
5.1 长耗时工具走 Tasks 扩展
tools/call 默认是请求-响应模型。跑批、大规模检索、调用外部慢接口这类工具会撞上网关超时和客户端超时,把超时时间一再加长是错的——连接断了就什么都没有了。
规范给的官方做法是 Tasks 扩展:调用时先返回一个任务句柄,客户端之后用这个句柄查询状态、拿结果、或者取消。它把"调用"和"取结果"拆成两步,中间可以断连、可以重启、可以轮询。
用之前先确认两件事:客户端要在每次请求的 _meta 里声明 io.modelcontextprotocol/tasks 扩展——能力协商没过,服务端不会建任务;任务句柄必须按授权上下文绑定、不可枚举,否则别人拿到句柄就能看你的任务结果。
tools/call → 返回任务句柄(不是结果)
tasks/get → 查状态:进行中 / 完成 / 失败(完成时结果一并返回)
tasks/cancel→ 取消
注意 2026-07-28 版移除了独立的 tasks/result——现在结果就挂在 tasks/get 的响应里,直接调 tasks/result 会收到 -32601 Method Not Found。老代码迁过来时这是第一个要改的点。本版的错误码也有重编号(资源未找到从 -32002 并入 -32602,另有 -32020/-32021/-32022 三段新码),异常处理别按旧码写死。
这意味着长任务的实现要把进度和结果存在进程外(和 2.1 里的句柄是同一套做法),并且要能被重复查询——幂等和状态外置这两件事在这里不是优化,是能不能工作的前提。
6. 副作用与确认
工具是能直接产生副作用的。调用前的确认必须在 Server 侧有兜底,不能只依赖客户端:
const READ_ONLY = new Set(['searchOrder', 'listOrders'])
if (!READ_ONLY.has(name) && !allowlist.includes(name)) {
// 交给上层发起确认,不在这里直接执行
return { content: [{ type: 'text', text: '该操作需要用户确认,已暂停' }] }
}
权限这块更大的话题是 Agent 侧的失控风险——工具的越权调用、间接注入导致的越权,OWASP 在 2026 年发布的 Agentic Top 10 里专门列了几类,属于护栏设计的范畴,不在本文展开。
6. 调试与验证
本地开发用 stdio 起服务,配到客户端里直接调;远程部署前,先用 MCP 提供的调试方式把 tools/list 和几次 tools/call 跑通。
发布后要注意版本兼容:客户端会在请求头里声明协议版本,Server 要明确支持哪些版本,不匹配就明确拒绝,而不是"尽力解析"。协议演进很快(2026-07-28 这次就是自发布以来最大的一次修订),靠猜测兼容只会积累隐性故障。