☰
从零手写生产级 MCP Server:鉴权、流式传输与状态管理实战
2026/9/26 12:40:03 网站建设 项目流程

1. 为什么我要从零手写一个 MCP Server

1.1 现成方案用着挺香,但到了生产环境就露馅

MCP 这个协议刚火起来那阵子,我跟大多数人一样,直接拿官方 SDK 跑了个 demo,本地连上客户端,工具调用跑通,心里美滋滋。但真把它往生产环境一放,问题就全冒出来了。官方 SDK 给的示例代码,本质上是个“能跑就行”的玩具:没有鉴权、没有连接状态管理、流式响应一断就丢上下文、日志全靠console.log满屏乱飞。你拿它做内部工具演示没问题,但要让多个用户同时用、要对接真实业务系统、要保证调用链路可追溯,那就得自己动手把骨架重新搭一遍。

我这次的目标很明确:写一个生产级的 MCP Server。所谓生产级,不是说要搞得多复杂,而是三件事必须做到位——鉴权要能扛住越权、流式传输要稳、状态管理要清晰。这三个点恰好也是社区里问得最多、踩坑最密集的地方。比如“鉴权绕过”这个词,在安全圈里一直是个高频话题,很多人以为加个 token 校验就完事了,实际上路径遍历、参数注入、会话固定这些坑一个都躲不掉。再比如“对话状态管理”,MCP 的会话是有生命周期的,客户端可能随时断开重连,你如果状态全放内存里,重启就全丢了。

这篇文章我会把整个实现过程拆开讲,从协议理解、鉴权设计、流式传输实现到状态管理,每一步都给出我实际写代码时的取舍和理由。代码我会用 TypeScript 写,因为 MCP 生态里 TS 的 SDK 最成熟,但思路是跨语言的,你用 Python、Go 照样能套。读完之后,你应该能自己搭出一个能扛住多用户并发、能追溯每次调用、断线还能恢复上下文的 MCP Server。

1.2 先搞清楚 MCP Server 到底在干什么

在动手之前,得先把 MCP 的通信模型理清楚。MCP 本质上是一个基于 JSON-RPC 2.0 的双向通信协议,客户端和服务端之间通过 stdio 或者 HTTP+SSE 来传消息。它定义了三种核心能力:Tools(工具调用)、Resources(资源读取)、Prompts(提示模板)。生产环境里最常用的是 Tools,也就是让 LLM 通过服务端去执行一些实际操作,比如查数据库、调内部 API、读写文件。

这里有个关键点很多人一开始会忽略:MCP Server 不是无状态的 HTTP 接口,它是有**会话(Session)**概念的。客户端初始化时会发一个initialize请求,服务端返回自己的能力列表,然后双方进入一个持续的消息交换过程。这个会话里会维护一些上下文,比如客户端支持哪些能力、当前协商的协议版本、以及后续工具调用需要的临时状态。如果你把每次请求都当成独立的 HTTP 调用来处理,那流式传输和状态管理就无从谈起。

我选择用 HTTP + SSE 的传输方式,而不是 stdio。原因很简单:stdio 适合本地进程间通信,但生产环境里服务端通常要部署在远端,多个客户端通过网络连接,HTTP 是更自然的选择。SSE(Server-Sent Events)负责服务端到客户端的流式推送,客户端到服务端的请求则走普通的 POST。这个组合在 MCP 的规范里是明确支持的,也是目前社区里远程 MCP Server 的主流做法。

1.3 生产级到底意味着什么:三个硬指标

我把“生产级”拆成三个可衡量的指标,后面所有设计都围绕它们展开。

第一是鉴权不能形同虚设。很多 demo 里鉴权就是检查一个写死的 token,这在实际场景里等于没有。生产级鉴权至少要解决:每个客户端有独立的身份凭证、凭证有有效期和权限范围、每次工具调用都要校验调用者是否有权限执行该工具、以及防止重放攻击和会话劫持。

第二是流式传输不能断。LLM 生成的内容往往是流式的,工具调用的结果也可能很长,需要分块推送给客户端。如果网络抖动导致 SSE 连接断开,客户端重连后应该能继续接收,而不是从头再来或者直接报错。这要求服务端能缓存已发送但未被确认的消息,并在重连时做补偿。

第三是状态管理要可恢复。会话状态不能只存在进程内存里,否则服务重启或者多实例部署时就乱了。我的做法是把会话元数据存到外部存储(比如 Redis),进程内只保留热数据,这样既能快速访问,又能在重启后恢复。

这三个指标听起来简单,但每一个落地时都有一堆细节。下面我逐个拆开讲。

2. 鉴权体系:从“加个 token”到真正的权限控制

2.1 为什么简单的 token 校验会被绕过

先说说我踩过的第一个坑。最开始我图省事,在 HTTP header 里放了一个固定的 API Key,服务端收到请求就比对一下。测试的时候没问题,但后来做安全 review 时发现,这种设计有几个致命问题。

首先是凭证泄露后的无限授权。一个 Key 一旦泄露,攻击者可以无限次调用,你根本不知道是谁在用。而且这个 Key 通常没有过期时间,也没有权限范围,等于一把万能钥匙。社区里讨论的“无限授权鉴权系统”说的就是这种反面教材——看起来有鉴权,实际上授权是无限的。

其次是会话固定攻击。如果服务端在initialize时生成一个 session ID 返回给客户端,但后续请求只校验 API Key 不校验 session 归属,那攻击者只要拿到 API Key,就能伪造任意 session ID 去访问别人的会话状态。这就是典型的鉴权绕过。

还有一个容易被忽略的点是工具级别的权限缺失。很多实现只校验“你是不是合法客户端”,但不校验“你有没有权限调用这个特定工具”。比如一个只读权限的客户端,理论上不应该能调用删除数据的工具。如果服务端不做工具级鉴权,那权限模型就是形同虚设。

2.2 我的鉴权方案:JWT + 会话绑定 + 工具级权限

我最终的方案是三层校验,缺一不可。

第一层是传输层鉴权,用 JWT(JSON Web Token)。客户端在建立连接时,在 HTTP header 里带上Authorization: Bearer <token>。服务端校验 JWT 的签名、过期时间和签发者。JWT 的 payload 里我会放三个关键字段:sub(客户端唯一标识)、scopes(权限范围数组)、session_binding(可选的会话绑定标识)。用 JWT 而不是随机 token 的好处是,服务端不需要存储 token 本身,验签即可,天然支持分布式部署。

第二层是会话绑定。在initialize请求处理时,服务端生成一个 session ID,并把这个 session ID 和 JWT 里的sub绑定,存到 Redis 里,key 是 session ID,value 是{clientId, createdAt, lastActiveAt}。后续所有请求都必须带上这个 session ID,服务端会校验这个 session 是否属于当前 JWT 的sub。这样即使攻击者拿到了 JWT,没有对应的 session ID 也无法访问已有会话;反过来,即使猜到了 session ID,JWT 的sub对不上也会被拒。

第三层是工具级权限。每个工具在注册时声明自己需要的 scope,比如db:read、db:write、file:read。当客户端调用某个工具时,服务端从 JWT 的scopes里检查是否包含该工具所需的 scope。这一步是很多人会漏掉的,但它是防止越权操作的关键。

下面是我实际用的 JWT 校验中间件核心逻辑,用 TypeScript 写的:

import jwt from 'jsonwebtoken'; interface TokenPayload { sub: string; scopes: string[]; exp: number; } function verifyToken(authHeader: string | undefined): TokenPayload { if (!authHeader || !authHeader.startsWith('Bearer ')) { throw new AuthError('missing_token', 'Authorization header is required'); } const token = authHeader.slice(7); try { const payload = jwt.verify(token, process.env.JWT_SECRET!, { algorithms: ['HS256'], issuer: 'mcp-server', }) as TokenPayload; return payload; } catch (err) { if (err instanceof jwt.TokenExpiredError) { throw new AuthError('token_expired', 'Token has expired'); } throw new AuthError('invalid_token', 'Token verification failed'); } }

这里有几个细节值得说。algorithms必须显式指定,否则存在算法混淆攻击的风险——攻击者可能把alg改成none来绕过验签。issuer也要校验,防止其他系统签发的 token 被拿来用。这些在 JWT 的官方文档里都有强调,但实际写代码时很容易忘。

2.3 会话绑定与防重放的具体实现

会话绑定这块,我在 Redis 里存的结构是这样的:

key: mcp:session:<sessionId> value: { clientId: "client-abc", createdAt: 1710000000, lastActiveAt: 1710003600, protocolVersion: "2024-11-05" } ttl: 3600

每次请求进来,先验 JWT 拿到sub,再从请求里取 session ID,然后去 Redis 查这个 session 的clientId是否等于sub。不相等直接返回 403。同时更新lastActiveAt,并刷新 TTL。如果 Redis 里查不到这个 session,说明会话已过期,返回 401 让客户端重新初始化。

防重放这块,我在每个请求的 header 里要求带一个X-Request-Id(UUID)和X-Timestamp(Unix 秒)。服务端会检查时间戳和当前时间的差值是否在 5 分钟内,超过就拒绝。同时把X-Request-Id存到 Redis 的一个短 TTL 集合里,如果同一个 ID 在 5 分钟内出现两次,就判定为重放,直接拒绝。这个机制对于工具调用这种有副作用的操作特别重要,能防止攻击者截获请求后重复执行。

注意:时间戳校验依赖服务端和客户端的时间同步。如果客户端时钟偏差较大,可能会被误拒。我的做法是允许 5 分钟的窗口,同时在错误信息里明确提示是时间戳问题,方便排查。

2.4 工具级权限的声明与校验

工具注册时,我会给每个工具定义一个元数据对象:

interface ToolDefinition { name: string; description: string; inputSchema: object; requiredScopes: string[]; } const tools: ToolDefinition[] = [ { name: 'query_database', description: 'Execute a read-only SQL query', inputSchema: { /* JSON Schema */ }, requiredScopes: ['db:read'], }, { name: 'delete_record', description: 'Delete a record by ID', inputSchema: { /* JSON Schema */ }, requiredScopes: ['db:write'], }, ];

调用时的校验逻辑:

function checkToolPermission(tool: ToolDefinition, tokenScopes: string[]) { const missing = tool.requiredScopes.filter(s => !tokenScopes.includes(s)); if (missing.length > 0) { throw new AuthError( 'insufficient_scope', `Missing required scopes: ${missing.join(', ')}` ); } }

这样设计的好处是权限模型是声明式的,新增工具时只要声明它需要的 scope,校验逻辑不用改。而且 scope 的粒度可以自己控制,比如db:read和db:write分开,就能实现只读客户端和读写客户端的区分。

2.5 密钥管理:别把 secret 写进代码里

关于“使用 LLM 时如何防止密钥等鉴权信息泄露”,我的经验是:密钥永远不要出现在代码、日志和 LLM 的上下文里。具体做法有三条。

第一,JWT 的签名密钥从环境变量或者密钥管理服务读取,绝对不硬编码。本地开发用.env文件,但.env必须加到.gitignore里。生产环境用容器编排平台的 secret 机制注入。

第二,日志里做脱敏。所有包含Authorization、token、secret、password的字段,在写日志前统一替换成[REDACTED]。我写了一个简单的日志脱敏函数,在日志中间件里调用:

const SENSITIVE_KEYS = ['authorization', 'token', 'secret', 'password', 'api_key']; function redact(obj: any): any { if (typeof obj !== 'object' || obj === null) return obj; const result: any = Array.isArray(obj) ? [] : {}; for (const [key, value] of Object.entries(obj)) { if (SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k))) { result[key] = '[REDACTED]'; } else { result[key] = redact(value); } } return result; }

第三,工具调用的参数在传给 LLM 之前要过滤。有些工具需要 API Key 作为参数,这种参数绝对不能出现在工具的inputSchema里让 LLM 去填。正确做法是服务端在工具实现内部从密钥管理服务读取,LLM 只负责传业务参数。

3. 流式传输:SSE 的稳定性设计与断线恢复

3.1 为什么选 SSE 而不是 WebSocket

MCP 的远程传输规范里,SSE 是官方推荐的方式。我对比过 WebSocket,最后选 SSE 的原因有三个。

第一,SSE 是单向的,服务端到客户端推送,客户端到服务端用普通 POST。这种单向性反而简化了设计——请求和响应分离,不需要在一条连接上同时处理双向消息的复用和分帧。MCP 的消息模式本来就是请求-响应式的,SSE 天然契合。

第二,SSE 基于 HTTP,穿透性和兼容性更好。企业网络里 WebSocket 有时会被代理拦截,但 SSE 就是普通的 HTTP 长连接,基本不会被拦。而且 SSE 自带重连机制,浏览器和大多数 HTTP 客户端都支持Last-Event-ID头,重连时能告诉服务端上次收到哪个事件。

第三,SSE 的实现更简单。WebSocket 需要处理握手升级、心跳、分帧,SSE 只需要设置正确的响应头然后往连接里写数据就行。对于 MCP 这种消息量不算特别大的场景,SSE 的简单性带来的收益远大于 WebSocket 的双向能力。

3.2 SSE 连接的生命周期管理

一个 SSE 连接从建立到关闭,我把它分成四个阶段:握手、活跃、空闲、关闭。每个阶段都有对应的处理逻辑。

握手阶段,客户端发一个 GET 请求到/sse,带上 JWT 和 session ID。服务端校验通过后,设置响应头:

res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', });

X-Accel-Buffering: no这个头很关键,如果你前面有 Nginx 之类的反向代理,不加这个头代理会缓冲响应,导致客户端收不到实时消息。我第一次部署时就踩了这个坑,本地测试好好的,一上生产就变成“批量延迟推送”,排查了半天才发现是代理缓冲。

活跃阶段,服务端通过这条连接推送消息。每条消息的格式是:

id: <event-id> event: message data: {"jsonrpc":"2.0","id":1,"result":{...}}

id字段是事件序号,客户端重连时会通过Last-Event-ID头带回来。event字段我统一用message,因为 MCP 的消息类型在 JSON-RPC 的 body 里已经区分了,不需要在 SSE 层面再分。

空闲阶段,如果一段时间没有消息,服务端要发心跳,否则中间的代理或负载均衡可能会把空闲连接掐掉。我设置的是每 15 秒发一个注释行: heartbeat\n\n。注释行不会被客户端当成事件处理,但能保持连接活跃。

关闭阶段,客户端断开或者服务端主动关闭时,要清理这个连接对应的资源:从连接池里移除、更新会话的lastActiveAt、如果有未确认的消息要保留在缓冲区里等重连。

3.3 消息缓冲与断线重连的补偿机制

这是流式传输里最容易被忽略但最重要的部分。设想一个场景:服务端正在推送一个长工具调用的结果,推了 10 条消息,客户端收到了 7 条,网络断了。客户端重连后,如果服务端不记得之前推过什么,那客户端就丢了 3 条消息,上下文就断了。

我的做法是给每个会话维护一个消息缓冲区,结构是一个有序列表,每条消息带一个自增的 event ID。缓冲区的大小设一个上限,比如 1000 条,超过就淘汰最旧的。当客户端重连并带上Last-Event-ID时,服务端从缓冲区里找到这个 ID 之后的所有消息,重新推送一遍。

class SessionBuffer { private messages: Array<{ id: number; data: string }> = []; private nextId = 1; private maxSize = 1000; append(data: string): number { const id = this.nextId++; this.messages.push({ id, data }); if (this.messages.length > this.maxSize) { this.messages.shift(); } return id; } getAfter(lastEventId: number): Array<{ id: number; data: string }> { return this.messages.filter(m => m.id > lastEventId); } }

这里有个细节:缓冲区不能无限大,否则内存会爆。但淘汰太激进又会导致重连时补不齐。我的经验是,对于工具调用结果这种可能很长的消息,在推送前先做分块,每块控制在 4KB 以内,这样 1000 条能覆盖 4MB 的内容,对大多数场景够用了。如果确实有超长结果,那应该走“结果存储 + 引用”的模式,推送一个引用 ID,客户端再单独拉取完整结果。

提示:Last-Event-ID是 SSE 规范里的标准头,但有些客户端实现不自动带。如果你自己写客户端,记得在重连时手动把这个头加上,值就是最后收到的那条消息的id。

3.4 背压处理:客户端消费不过来怎么办

流式推送还有一个现实问题:服务端推得太快,客户端消费不过来,消息在客户端侧堆积,最终导致内存问题或者消息丢失。这就是背压(backpressure)。

SSE 本身没有标准的背压机制,因为它是单向的,客户端没法告诉服务端“慢一点”。我的做法是在应用层加一个简单的流控:服务端维护每个连接的“未确认消息数”,客户端每收到一条消息,通过一个单独的 POST 接口发一个 ack。如果未确认消息数超过阈值(比如 100),服务端就暂停推送,等 ack 追上来了再继续。

这个机制会增加一点复杂度,但对于工具调用结果可能很大的场景是必要的。如果不想搞这么复杂,至少要在服务端设置一个推送速率上限,比如每秒最多 50 条消息,避免瞬间打爆客户端。

4. 状态管理:会话、工具上下文与持久化

4.1 会话状态到底要存什么

会话状态不是把所有东西都塞进去,而是要区分热数据和冷数据。热数据是每次请求都可能用到的,比如客户端的权限 scope、当前协商的协议版本、连接状态。冷数据是偶尔才访问的,比如历史工具调用记录、大结果集的缓存。

我的划分是这样的:

数据类型存储位置生命周期访问频率
会话元数据(clientId、scopes)Redis会话期间 + TTL每次请求
连接状态(SSE 连接引用)进程内存连接期间每次推送
消息缓冲区进程内存 + Redis 备份会话期间重连时
工具调用历史数据库长期审计时
大结果缓存对象存储可配置按需

会话元数据放 Redis 是因为它需要跨进程共享,多实例部署时任何一个实例都能校验会话。连接状态放内存是因为它本来就是进程绑定的,SSE 连接在哪个进程,状态就在哪个进程。消息缓冲区放内存是为了快,但定期备份到 Redis,这样进程崩溃后重连还能恢复。

4.2 工具调用的上下文传递

工具调用不是孤立的,它可能需要访问会话上下文。比如一个“继续上一步操作”的工具,需要知道上一步是什么。我的做法是在会话状态里维护一个调用栈,每次工具调用开始时压栈,结束时出栈,栈里存的是调用 ID、工具名、参数摘要和时间戳。

interface CallContext { callId: string; toolName: string; argsSummary: string; startedAt: number; } class SessionState { private callStack: CallContext[] = []; pushCall(ctx: CallContext) { this.callStack.push(ctx); } popCall(callId: string) { const idx = this.callStack.findIndex(c => c.callId === callId); if (idx >= 0) this.callStack.splice(idx, 1); } getCurrentCall(): CallContext | undefined { return this.callStack[this.callStack.length - 1]; } }

这个调用栈在工具实现里可以通过依赖注入拿到,这样工具就能知道“我是被谁调用的”“当前会话进行到哪一步了”。对于需要多步交互的工具,这个上下文非常有用。

4.3 持久化策略:Redis + 数据库的分工

持久化我分两层。Redis 负责会话级的快速读写,数据库负责审计级的长期存储。

Redis 里存的是会话元数据和消息缓冲区的备份。key 的设计是mcp:session:<sessionId>和mcp:buffer:<sessionId>。TTL 设成 1 小时,每次请求刷新。这样即使服务重启,客户端在 1 小时内重连还能恢复会话。

数据库里存的是每次工具调用的记录:调用 ID、会话 ID、客户端 ID、工具名、参数(脱敏后)、结果状态、耗时、时间戳。这张表主要用于审计和问题排查。我用的 PostgreSQL,表结构大概是:

CREATE TABLE tool_invocations ( id UUID PRIMARY KEY, session_id TEXT NOT NULL, client_id TEXT NOT NULL, tool_name TEXT NOT NULL, args JSONB, status TEXT NOT NULL, duration_ms INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_session ON tool_invocations(session_id); CREATE INDEX idx_created ON tool_invocations(created_at);

参数存 JSONB 是为了灵活查询,但存之前一定要脱敏,把敏感字段替换掉。这个表的数据量会增长很快,我设置了一个定期归档任务,把 30 天前的记录移到冷存储。

4.4 多实例部署下的状态一致性

多实例部署时,最大的挑战是 SSE 连接和会话状态的归属问题。客户端连到实例 A,但下一个请求可能被负载均衡打到实例 B。如果实例 B 没有这个会话的状态,就会出问题。

我的解决方案是会话状态全放 Redis,连接状态用路由表。具体来说,每个实例在启动时注册自己到一个服务发现组件,并维护一个“session ID -> 实例地址”的映射,存在 Redis 里。当实例 B 收到一个属于实例 A 的会话的请求时,它有两种选择:一是把请求转发给实例 A,二是从 Redis 加载会话状态后本地处理。

我选的是第二种,因为转发会增加延迟和故障点。实例 B 从 Redis 加载会话元数据后,可以正常处理工具调用。但 SSE 推送必须由持有连接的实例 A 来做,所以如果实例 B 产生了需要推送的消息,它会把消息写到 Redis 的一个 pub/sub 频道,实例 A 订阅这个频道,收到后推送给客户端。

// 实例 B 产生消息 await redis.publish(`mcp:push:${sessionId}`, JSON.stringify(message)); // 实例 A 订阅 const subscriber = redis.duplicate(); await subscriber.subscribe(`mcp:push:${sessionId}`); subscriber.on('message', (channel, data) => { sseConnection.write(`data: ${data}\n\n`); });

这个方案的好处是实例之间解耦,坏处是增加了一次 Redis 往返。对于消息量不大的场景完全够用,如果消息量特别大,可以考虑用专门的流处理组件。

5. 日志与可观测性:自定义日志管理的落地

5.1 为什么 console.log 在生产环境不可接受

“MCP Server 端的日志如何使用自定义日志管理”这个问题,本质上是问:怎么让日志既有用又不添乱。console.log的问题在于:没有级别区分、没有结构化、没有上下文、性能差(同步写)、无法按需开关。生产环境里,你需要的是结构化日志,每条日志是一个 JSON 对象,带时间戳、级别、请求 ID、会话 ID、消息内容,这样才能被日志系统采集和检索。

我选的是pino,因为它性能好(异步写)、结构化输出、生态成熟。配置大概是:

import pino from 'pino'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', redact: { paths: ['req.headers.authorization', '*.token', '*.secret'], censor: '[REDACTED]', }, formatters: { level: (label) => ({ level: label }), }, timestamp: pino.stdTimeFunctions.isoTime, });

redact配置直接在内置层面做脱敏,比自己在每个地方手动处理靠谱得多。

5.2 请求链路追踪:给每个请求打上唯一 ID

可观测性的核心是能把一次请求的完整链路串起来。我的做法是在请求入口生成一个traceId(UUID),然后通过 AsyncLocalStorage 在整个请求处理过程中传递。这样任何地方打日志都能带上这个traceId,排查问题时只要 grep 这个 ID 就能看到完整链路。

import { AsyncLocalStorage } from 'async_hooks'; const traceStorage = new AsyncLocalStorage<{ traceId: string }>(); function traceMiddleware(req, res, next) { const traceId = req.headers['x-trace-id'] || crypto.randomUUID(); traceStorage.run({ traceId }, () => { res.setHeader('X-Trace-Id', traceId); next(); }); } function log(level: string, msg: string, extra: object = {}) { const ctx = traceStorage.getStore(); logger[level]({ traceId: ctx?.traceId, ...extra }, msg); }

这样每条日志都自带traceId,配合会话 ID 和调用 ID,三层 ID 就能精确定位任何一次工具调用的完整过程。

5.3 关键指标监控:延迟、错误率、连接数

日志之外,还需要指标。我暴露了一个/metrics接口,用 Prometheus 格式输出几个关键指标:

  • mcp_active_connections:当前活跃的 SSE 连接数
  • mcp_tool_invocation_duration_seconds:工具调用耗时直方图
  • mcp_tool_invocation_errors_total:工具调用错误计数,按工具名和错误类型分标签
  • mcp_auth_failures_total:鉴权失败计数,按失败原因分标签

这几个指标能覆盖大部分运维场景。比如mcp_auth_failures_total突然飙升,可能是有人在尝试攻击;mcp_tool_invocation_duration_seconds的 P99 变高,说明某个工具变慢了。

5.4 日志分级与采样:别让日志淹没你

生产环境日志量很大,全量记录不现实。我的策略是:ERROR 和 WARN 全量记录,INFO 按需记录,DEBUG 默认关闭。INFO 级别主要记录工具调用的开始和结束,DEBUG 记录详细的参数和中间状态,只在排查特定问题时临时打开。

对于高频的 SSE 心跳和 ack,我直接不记日志,或者只记 DEBUG 级别。否则这些噪音会把真正有用的日志淹没。另外,对于工具调用的参数,我只记摘要(比如参数个数、关键字段),不记完整内容,既减少日志量又降低敏感信息泄露风险。

6. 常见问题与排查技巧实录

6.1 鉴权相关的高频问题

问题一:客户端报 401,但 token 明明没过期。排查顺序是:先看服务端日志里的auth_failures_total指标,确认失败原因标签。如果是invalid_token,检查 JWT 的签名密钥是否一致;如果是token_expired,检查服务端和客户端的时间是否同步;如果是missing_token,检查 header 是否正确传递,有些反向代理会过滤掉Authorization头。

问题二:工具调用报 403 insufficient_scope。这是工具级权限校验失败。检查 JWT 的scopes字段是否包含工具声明的requiredScopes。常见错误是 scope 命名不一致,比如工具声明的是db:read,但 token 里给的是database:read。建议把 scope 定义成常量,两边引用同一个常量。

问题三:会话绑定失败,报 session not found。检查 Redis 里是否有这个 session 的 key。如果没有,可能是 TTL 过期了,或者 Redis 连接有问题。如果 key 存在但clientId对不上,说明客户端用了别人的 session ID,这是攻击信号,要记录并告警。

6.2 流式传输的典型故障

问题一:客户端收不到实时消息,但连接没断。九成是反向代理的缓冲问题。检查 Nginx 配置里是否有proxy_buffering off,以及响应头里是否有X-Accel-Buffering: no。另外检查Content-Type是否是text/event-stream,有些代理会根据 Content-Type 决定是否缓冲。

问题二:重连后消息重复或丢失。检查Last-Event-ID是否正确传递。如果客户端没带这个头,服务端会从头推送,导致重复。如果带了但服务端缓冲区已经淘汰了对应的消息,就会丢失。解决方法是调大缓冲区,或者改用“结果存储 + 引用”模式。

问题三:连接数上不去,到一定数量就拒绝。检查操作系统的文件描述符限制(ulimit -n)和进程的最大连接数配置。另外检查是否有连接泄漏,比如客户端断开后服务端没有正确清理连接引用,导致连接池被占满。

6.3 状态管理的坑

问题一:服务重启后客户端全部掉线,需要重新初始化。这是正常的,因为 SSE 连接是进程绑定的。但如果会话状态存在 Redis 里,客户端重连后可以恢复会话,不需要重新走完整的initialize流程。关键是客户端要保存 session ID,重连时带上。

问题二:多实例部署时会话状态不一致。检查是否所有实例都连的同一个 Redis。如果用了 Redis 集群,注意 key 的分片是否一致。另外检查会话状态的更新是否有竞态条件,比如两个实例同时更新同一个会话的lastActiveAt,可能导致覆盖。用 Redis 的原子操作或者分布式锁来解决。

问题三:内存持续增长,疑似泄漏。检查消息缓冲区是否有上限,调用栈是否在异常时也能正确出栈,SSE 连接断开后是否从连接池移除。我遇到过一次泄漏是因为工具调用抛异常时没有 pop 调用栈,导致栈无限增长。后来加了 try-finally 保证出栈。

6.4 排查速查表

现象可能原因排查方法解决方案
401 鉴权失败token 过期/签名错误/header 丢失看 auth_failures 指标和日志检查密钥、时间同步、代理配置
403 权限不足scope 不匹配对比工具 requiredScopes 和 token scopes统一 scope 常量定义
SSE 无实时消息代理缓冲检查响应头和代理配置关闭 proxy_buffering
重连消息丢失缓冲区淘汰检查 Last-Event-ID 和缓冲区大小调大缓冲区或改引用模式
内存泄漏调用栈未清理/连接未释放看内存曲线和连接数指标try-finally 保证清理
多实例状态不一致Redis 不共享/竞态检查 Redis 连接和更新逻辑用原子操作或分布式锁

6.5 几个我踩过的独家坑

第一个坑是SSE 的id字段必须是字符串。我一开始用数字,某些客户端解析时会有问题。后来统一转成字符串,就稳了。

第二个坑是JWT 的exp是秒级时间戳,不是毫秒。我一开始传了毫秒,导致 token 一签发就过期,排查了半天。

第三个坑是Redis 的 pub/sub 在连接断开后不会自动重订阅。如果 Redis 连接抖动,订阅会丢失,导致推送静默失败。我的做法是加一个重连监听,连接恢复后重新订阅所有活跃会话的频道。

第四个坑是工具调用的参数校验不能只靠 JSON Schema。JSON Schema 能校验类型和格式,但校验不了业务逻辑,比如“这个 ID 必须属于当前用户”。业务校验必须在工具实现里做,而且要放在权限校验之后。

7. 写在最后:一些个人体会

这套 MCP Server 我从零写到能在生产环境跑,前后迭代了大概三周。最大的体会是:协议本身不复杂,复杂的是生产环境的各种边界情况。鉴权、流式、状态这三块,每一块单独看都不难,但组合在一起就会互相影响。比如鉴权的会话绑定依赖状态管理,流式传输的重连补偿又依赖消息缓冲区的状态,牵一发而动全身。

我的建议是,如果你也要做类似的事,先把状态模型想清楚,哪些是会话级的、哪些是连接级的、哪些是请求级的,画个图理一遍。状态模型清晰了,鉴权和流式传输的实现就是水到渠成的事。另外,日志和指标一定要从第一天就加上,不要等到出问题了才补,那时候排查成本会高很多。

最后分享一个小技巧:在开发阶段,我会用一个脚本模拟客户端做压力测试,同时开多个会话、频繁断线重连、故意发错误 token,看服务端的表现。这个脚本帮我提前发现了至少五个 bug,比等到线上出问题再修划算得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询