MCP(Model Context Protocol)这两年几乎成了 AI Agent 接入外部工具的事实标准。我最初接触是在 2024 年底,随手写了个返回天气的 demo,觉得这协议无非是套了一层 JSON-RPC 的壳,不难。直到后来要把 MCP Server 真正部署上线,给团队所有人用,问题才一个一个冒出来:调用方怎么鉴权?多轮对话里的上下文放在哪里?流式返回能不能稳住不断?出了问题从哪里查日志?这篇文章我把手写一个生产级 MCP Server 的完整思路过一遍,重点放在鉴权、流式传输和状态管理三个模块上,顺带聊聊 MCP Server 端自定义日志管理的实操方案。适合已经写过 MCP demo、正要往生产环境推的开发者。
1. 生产级 MCP Server 的整体设计
1.1 先想清楚:你的 Server 要解决什么问题
开始写代码之前,最好先把“MCP Server 在生产环境到底意味着什么”想清楚。MCP 协议本身规定了工具(tools)、资源(resources)、提示词(prompts)三块能力,但协议不管你的工具背后的业务逻辑、不管谁来调用、也不管调用发生故障后怎么恢复。
我见过不少团队一上来就用官方脚手架生成一个服务,然后只顾埋头加工具函数,结果部署后频繁出问题。生产级的含义其实很朴素:稳定的连接、可控的权限、可查的日志、可恢复的状态。这四个目标会反过来决定你在代码层面怎么做取舍。比如,为了权限可控,你需要在传输层之下加一道鉴权;为了状态可恢复,你不能只把会话存在进程内存里;为了可查日志,你不能依赖 print 或者 MCP SDK 自带的那点输出。
所以我的建议是:先画清楚分层,再写业务代码。传输层负责连接建立和数据推拉,协议层负责 JSON-RPC 编解码和消息路由,业务层负责真正的工具逻辑,横切层放鉴权、日志、状态管理。这个分层不用很重,但边界一定得清楚,否则后面加功能就是一场灾难。我见过有的项目把鉴权逻辑直接写进工具函数里,每个工具里都复制一遍 Token 校验,后面要改签名算法的时候差点改到崩溃。
1.2 传输层选型与 SDK 选择
MCP 目前主流的传输方式有三种:stdio、SSE(Server-Sent Events)、Streamable HTTP。stdio 适用于本地场景,比如 Claude Desktop 或 IDE 插件直接拉起一个子进程;SSE 是 2025 年前的主流远程方案,客户端和服务端各开一条连接,服务端通过 SSE 把事件推给客户端;Streamable HTTP 则是协议更新后推荐的远程传输方式,它把请求响应统一成 HTTP POST,同时允许服务端用 SSE 流式返回。
如果你要写一个面向线上服务的 MCP Server,我建议直接走 Streamable HTTP,兼顾了普通 HTTP 的易调试性和 SSE 的流式能力。SDK 方面,Python 生态推荐 mcp 官方 SDK,TypeScript 生态推荐 @modelcontextprotocol/sdk。我后面的示例基于 Python,因为 Python SDK 对 FastMCP 封装做得比较成熟,写工具函数就像写普通函数一样简单,开发期的热重载体验也让调试舒服得多。但这不意味着 TypeScript 不能用在生产,只是我个人的项目偏好,各有各的顺手场景。
2. 鉴权模块:认证、授权与 Token 生命周期
2.1 鉴权模型选型:API Key、Bearer Token 还是 OAuth
MCP 规范在鉴权方面推荐的是 OAuth 2.1,但说实话,绝大多数内部团队的 MCP Server 用不上完整 OAuth 那套复杂的授权码流程。我实际落地的方案分两种情况:如果是给公司内部系统用,直接 API Key 或 Bearer Token 就足够了;如果要开放给第三方开发者,才需要考虑 OAuth 2.1 的授权码模式,让第三方应用通过授权服务器拿到访问令牌。
我自己的项目用的是 JWT 形式的 Bearer Token。选择 JWT 是因为我们已经有用户体系,Token 里可以直接带上用户 ID、角色和权限作用域,不需要每次请求都回查数据库。生产环境要注意几个硬性要求:Token 必须有过期时间,不能签发永不过期的 Token;必须用强随机密钥进行 HS256 或 RS256 签名;必须校验 JWT 的签名算法,防止算法混淆攻击。这些都是老生常谈,但我在排查线上问题时确实见过有人把密钥硬编码在代码里、Token 设置了三十天过期,这些都要避免。
2.2 用 ASGI 中间件实现统一鉴权
在 FastMCP 的 Streamable HTTP 模式下,服务本质上是一个 Starlette ASGI 应用,所以最自然的做法就是加一道 ASGI 中间件,统一处理所有进来的 HTTP 请求。我把鉴权中间件的核心逻辑写成这样:
import jwt from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse class AuthMiddleware(BaseHTTPMiddleware): def __init__(self, app, jwt_secret: str): super().__init__(app) self.jwt_secret = jwt_secret async def dispatch(self, request, call_next): path = request.url.path if path in ("/healthz",): return await call_next(request) auth_header = request.headers.get("authorization", "") if not auth_header.startswith("Bearer "): return JSONResponse({"code": 401, "message": "missing bearer token"}, status_code=401) token = auth_header.removeprefix("Bearer ").strip() try: payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"]) except jwt.ExpiredSignatureError: return JSONResponse({"code": 401, "message": "token expired"}, status_code=401) except jwt.InvalidTokenError: return JSONResponse({"code": 401, "message": "invalid token"}, status_code=401) request.state.user_id = payload["sub"] request.state.scope = set(payload.get("scope", [])) return await call_next(request)这段代码有几个细节值得讲。第一,健康检查路径要放行,否则负载均衡器的探活请求会一直打到 401,影响服务在平台上的可用性状态。第二,中间件解析完 JWT 后,把用户信息和权限作用域挂到request.state上,后面的工具函数就能通过请求对象读取当前调用者的身份。第三,所有的校验必须在进入业务代码之前完成,不要等工具函数执行到一半才发现 Token 失效,这对流式请求尤其重要。
注意:JWT 密钥必须通过环境变量或密钥管理服务注入,不要硬编码在代码仓库里。密钥泄露意味着任何人都可以签发合法 Token。
2.3 权限作用域与会话绑定
鉴权不只是让请求通过,还要决定调用者能用到哪些工具。我习惯在 JWT 里放一个 scope 列表,比如["order:read", "order:execute"],然后在每个工具函数上做细粒度校验。FastMCP 没有内置的权限注解,但我们可以写一个装饰器统一处理:
from functools import wraps from mcp.server.fastmcp import Context def require_scope(permission: str): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): ctx: Context = kwargs.get("ctx") if not ctx: return {"error": "missing context"} user = getattr(ctx.request, "state", None) if not user or permission not in user.scope: return {"error": "permission denied"} return await func(*args, **kwargs) return wrapper return decorator不过要注意,ctx.request拿到的请求对象在不同 SDK 版本里表现不太一样,更稳妥的做法是在鉴权中间件里把用户信息写进一个全局的上下文字典,键为连接标识,值为用户身份。每次请求进来时,先根据连接 ID 找到用户,再做权限校验。另外一个容易被忽视的点是会话绑定:用户 A 创建的会话,用户 B 不能继续操作。会话 ID 必须和用户 ID 绑定存储,每次鉴权时都校验 session 的归属方。我在生产环境就遇到过测试账号越权访问他人会话的情况,最后排查发现是代码只校验了 Token 有效性,没有校验 Token 里的用户与会话上的 owner 是否一致。
3. 流式传输:从 SSE 到 Streamable HTTP
3.1 两种主流传输方式的取舍
说了传输层选型,还得搞清楚 MCP 里流式传输到底是怎么工作的。SSE 本质上就是服务端往客户端单向推送文本事件,客户端通过 EventSource 或 fetch 读取text/event-stream响应。它的优点是协议简单、浏览器原生支持、断线重连也由客户端自动处理;缺点是客户端只能接收,不能通过同一条连接发送请求,所以早期的 MCP 远程模式需要同时维护两条连接:一条客户端到服务端的 POST 通道,一条服务端到客户端的 SSE 通道。
Streamable HTTP 把这两条路合并了。客户端通过 POST 发送 JSON-RPC 请求,服务端既可以立即返回普通 JSON 响应,也可以返回text/event-stream类型的响应体,把事件逐个推给客户端。这样做的好处很明显:单连接、兼容普通 HTTP 基础设施、更容易挂到网关后面做路由和限流。我在实际部署时选了 Streamable HTTP,因为公司的 API 网关对这一套的支持更成熟,JWT Token 可以直接走标准的 Authorization 头,不需要像 SSE 时代那样单独设计消息通道的认证方案。
3.2 用 FastMCP 快速搭建流式接口
FastMCP 把传输层细节屏蔽得很干净,你只需要声明 transport 参数,SDK 会自动处理 JSON-RPC 编解码和 SSE 事件封装。下面是一个最简的 Streamable HTTP 模式示例,工具函数本身和写普通函数没有区别:
from mcp.server.fastmcp import FastMCP mcp = FastMCP("prod-mcp-server") @mcp.tool() def generate_report(metric: str, days: int = 7) -> str: """生成指定指标的趋势报告。""" report = build_report(metric, days) return report if __name__ == "__main__": mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)不过 FastMCP 的高层封装里,工具返回值是一次性全部返回的,真正的流式逐字输出通常需要你直接用底层 SDK 的Server和StreamableHTTPTransport来写,或者在工具内部通过异步生成器把结果慢慢喂给客户端。我建议这样判断:如果你的工具只是查询数据、算个结果,一次返回足够了;如果工具涉及长耗时任务,比如生成报告、拉取远程日志、批量分析文件,就要用异步任务加流式事件推送的方式,避免客户端等一个超长的 HTTP 响应。
3.3 心跳、超时与背压控制
流式传输在生产环境最容易出的问题就是连接被中间设备掐断。很多网关和负载均衡器默认会对空闲连接做超时回收,比如 Nginx 默认proxy_read_timeout是 60 秒。MCP 的 SSE 连接虽然一直在等事件,但如果没有事件产生,这 60 秒内就可能被判定为无数据传输而断开。常见的解决办法是让服务端周期性发送 SSE 注释行,也就是心跳包:
: ping客户端收到这种以冒号开头的注释行会直接忽略,但网络链路会因此认为连接有数据流动,从而刷新空闲超时计时。我在服务端把心跳间隔设为 15 秒,同时把网关的proxy_read_timeout调到至少 5 分钟,双管齐下,流式连接再没有莫名其妙断开过。
提示:如果服务挂在 Nginx 后面,记得单独对 MCP 路径关闭缓冲,否则 SSE 流式事件可能被 Nginx 攒到一块儿才发给客户端,表现就是数据迟迟不出来。
背压问题是另一个隐蔽的坑。当客户端处理慢、或者网络抖动时,服务端如果无限制地向客户端写事件,事件会在缓冲区堆积,内存飙升,最终拖垮整个服务。解决思路是给每个连接的发送队列设置上限,比如最多缓存 100 个事件,超过就丢弃最旧的事件并记录日志,或者直接断开连接让客户端重新拉起会话。我个人偏向丢弃旧事件,因为这符合流式场景“最新结果最重要”的直觉。
4. 状态管理:会话记忆与多租户隔离
4.1 状态存哪里:内存、Redis 还是数据库
MCP 的会话状态,说白了就是“这个连接、这轮对话进行到哪里了”。最简单的做法是直接用进程内存里的字典,键为 session_id,值为会话上下文。这个方案在小规模内部工具里完全够用,而且速度极快。但一旦服务以多实例方式部署,或者进程重启过,内存里的状态就全丢了。
我现在的项目用 Redis 做会话状态存储。Redis 相比内存方案多了集中存储和共享访问,相比关系型数据库又多了一个天然利器:过期时间(TTL)。MCP 会话的上下文一般不是永久数据,给每个会话设置一个合理的过期时间,比如 30 分钟无操作就自动清理,既避免了数据库表无限膨胀,也符合“会话超时重开”的产品预期。Redis 里存的不是完整的消息历史,而是关键状态:当前正在生成的报告片段、用户在多步表单里已填写的字段、最近一次游标位置、当前工具链的中间结果等。真正需要长期留存的业务数据,还是应该落库,交给下游系统处理。
4.2 状态读写与 TTL 过期策略
一个值得注意的点是,Redis 里 MCP 会话状态的数据结构应当精细设计,避免大家把整个聊天记录都塞进一个字符串。我用的结构是每个 session 对应一个 hash,hash 的每个 field 是一个状态项,value 是 JSON 序列化后的数据。示例如下:
import json import redis.asyncio as aioredis class SessionStore: def __init__(self, redis: aioredis.Redis): self.redis = redis async def get(self, session_id: str, key: str): raw = await self.redis.hget(f"mcp:session:{session_id}", key) return json.loads(raw) if raw else None async def set(self, session_id: str, key: str, value, ttl: int = 1800): pipe = self.redis.pipeline() pipe.hset(f"mcp:session:{session_id}", key, json.dumps(value)) pipe.expire(f"mcp:session:{session_id}", ttl) await pipe.execute() async def clear(self, session_id: str): await self.redis.delete(f"mcp:session:{session_id}")用 pipeline 把 hset 和 expire 合成一个原子操作,是为了避免“状态写进去了但过期时间没刷新”这种情况。每次会话有活动时,我都会调用一次set或单独刷新 TTL,保证长期使用的会话不会被中途清理。另外,value 一律 JSON 序列化,这能避免不同语言、不同 SDK 之间对 Python 对象 pickle 格式不兼容的尴尬,也让 Redis 里的数据可以用命令行直接查看,排障的时候非常方便。
4.3 多租户隔离和并发安全
多租户隔离在状态管理里是一个不能回避的问题。如果你是给多个团队或者多个外部客户提供同一个 MCP Server,那么 session_id 本身必须是全局唯一的字符串,不能两个租户产生相同的 ID。更关键的是,每次读写状态之前都要校验:这个 session 的 owner 是不是当前 Token 对应的用户。我在 SessionStore 里额外增加了一个 owner 字段,查询前先比对:
async def get_scoped(self, session_id: str, user_id: str, key: str): owner = await self.redis.hget(f"mcp:session:{session_id}", "_owner") if owner is None: return None if owner.decode() != user_id: raise PermissionError("session ownership mismatch") return await self.get(session_id, key)并发安全主要体现在同一时刻多个请求同时修改同一会话状态。HTTP 环境下客户端一般是串行调用,但在流式场景里,可能有推送线程和用户新请求同时操作状态。最稳妥的办法是让每次状态更新都走 Redis 的原子操作,比如hset本身就是原子的;如果需要“读取-修改-写入”这种复合操作,最好加一个简单的分布式锁,或者把状态版本号带上,在写入时用 Lua 脚本校验版本号,防止覆盖旧状态。我早期图省事直接用进程内锁,等服务上了多实例立刻出问题,之后才改成 Redis 原子操作,这一点是一定要提前规划好的。
5. MCP Server 的自定义日志管理
5.1 为什么默认日志不够用
MCP SDK 自带的日志输出主要用于协议调试,到了生产环境就远远不够了。你不仅要看到“哪个 JSON-RPC 方法被调用”,还要看到“哪个用户调用的、调用了多久、这次调用的鉴权结果如何、流式事件推送了多少条、状态有没有写入成功”。这些信息如果只是打成几行裸文本,排障时基本上是靠猜。我从项目一开始就给 MCP Server 单独设计了日志模块,而不是沿用 SDK 的默认 logger。
这里有个细节很多人会忽略:MCP SDK 内部也会打日志,如果你直接配置 root logger,很可能会被 SDK 的大量调试信息刷屏,真正的业务日志反而被淹没。我的做法是把自定义日志组件独立命名,比如mcp_server.core,日志级别设为 INFO,而把 SDK 的 logger 级别调成 WARNING,只保留关键错误。这样生产环境下日志文件里基本只会有我们自己打的内容,干净可控。
5.2 搭建结构化日志体系
我的做法是每条日志都输出成一行 JSON。结构化的好处是程序可以方便地对日志做检索、聚合和告警,人也能在复杂链路里快速过滤出想要的关键字段。最小字段集包括时间戳、日志级别、请求 ID、会话 ID、用户 ID、事件名和请求耗时:
import json import logging import uuid class JsonFormatter(logging.Formatter): def format(self, record): payload = { "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), "level": record.levelname, "logger": record.name, "msg": record.getMessage(), } for key in ("request_id", "session_id", "user_id", "event", "duration_ms"): if hasattr(record, key): payload[key] = getattr(record, key) return json.dumps(payload, ensure_ascii=False)然后在线程安全的 handler 基础上用 RotatingFileHandler 按大小轮转文件,避免单个日志文件无限膨胀。对于高并发服务,建议把日志写到本地文件后交给 Filebeat 之类的采集器统一送进日志平台,而不是让每个服务实例直接和日志平台建连。另外,敏感信息绝对不要写进日志。生产环境的排查中,Token、数据库密码、完整报文的输出都要做脱敏处理。我的做法是在输出前对 message 里的 authorization 头、用户密码等字段做正则替换,确保日志即使被人翻到也不会泄露关键凭据。
5.3 请求 ID 贯穿与关键链路追踪
一个好的日志体系必须有个贯穿所有环节的请求 ID。客户端每次调用工具时,可能在 HTTP 头里带上X-Request-Id,服务端如果没有就自己生成一个 UUID。从鉴权中间件开始,这个请求 ID 要一路保持到业务代码、日志打印、Redis 写入、流式事件推送。这样,当线上某个会话报错时,只需要在日志平台里按 request_id 搜索一次,就能看到该请求完整的事件链。
class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): request_id = request.headers.get("X-Request-Id", uuid.uuid4().hex) request.state.request_id = request_id response = await call_next(request) response.headers["X-Request-Id"] = request_id return response日志记录点我重点埋了这么几个:鉴权成功或失败时记录用户 ID 和来源 IP,工具调用开始和结束时记录参数摘要、耗时和结果状态,流式推送每批次记录推送条数和耗时,状态写入记录 key 和 TTL。这些日志在后续排查问题时极其有用。有一次线上出现偶发超时,我就是通过流式推送日志发现某一条大事件的推送耗时异常,进而定位到是跨机房网络抖动,而不是代码问题。
6. 常见问题与排查实录
6.1 鉴权类问题速查
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 请求返回 401 | Token 过期或签名密钥不一致 | 用服务端密钥重新验签,检查服务器时钟是否偏移 |
| 工具报权限不足 | scope 列表与工具要求不匹配 | 打印 JWT 解码后的 scope 和工具要求 |
| Token 有效却 401 | Authorization 头格式不对 | 确认是Bearer前缀,注意 Bearer 和值之间有一个空格 |
| 会话越权访问 | 只校验了 Token,未校验 session owner | 在 SessionStore 里增加 owner 字段并强制比对 |
这里我要重点说一个我在生产环境踩过的坑:服务器时间漂移会导致 JWT 校验时把未过期的 Token 判定为过期。JWT 的 iat、exp 都是绝对时间戳,如果跑服务的那台机器时钟快了半分钟,线上就会间歇性出现 401。后来我们在鉴权中间件加了对 exp 的宽容窗口,允许 30 秒内的时钟偏移,问题才消除。这种问题非常隐蔽,日志里只会看到一堆 401,让人误以为是客户端 Token 配置有问题。
6.2 流式中断和超时
流式连接断开大多不是业务代码的问题,而是传输链路配置问题。最典型的场景是:服务端事件推送到一半,客户端迟迟读不到,过一会儿连接被网关断开。排查顺序是先看服务端日志里有没有推送异常,再看网关的超时参数,最后看客户端有没有正确处理 SSE 格式。很多客户端的 fetch 实现如果没设置text/event-stream的解析器,就会把事件当成普通文本缓冲起来,表现就是“一直不输出”。这种情况下先确认客户端对事件流的解析姿势对不对,再怀疑服务端。
我遇到过的另一个超时场景是客户端在建立 MCP 连接时长时间没有发送 initialize 消息,服务端一直等着,最后连接被网关回收。处理办法是在传输层设置握手超时,比如 30 秒内客户端没有完成 initialize 就直接断开,同时记录一条 WARNING 日志,方便后续分析是不是有客户端在异常重试。
6.3 状态丢失与会话串线
状态丢失最常见的原因有三个:Redis 中的 key 被 TTL 清掉、服务多实例间状态不同步、序列化失败后被静默吞掉。我建议在状态模块里加一个“状态操作成功率”的统计日志,每次 get 和 set 都记一笔,一旦发现成功率下降就能提前告警。会话串线则基本都是 owner 校验缺失导致的,解决方式前面已经说过,SessionStore 的每次访问都校验 session 归属。
另外,序列化的坑很值得单独提醒。Python 里如果直接用 pickle 序列化状态对象,换一个 SDK 版本或者换一种语言客户端,读出来的很可能是一堆乱码。统一 JSON 序列化之后,即使某个字段类型不对,至少还能看清是什么内容,不会出现“状态读出来啥都不是”的黑洞。
6.4 客户端兼容性坑
不同客户端对 MCP 传输方式的支持程度不一样。比如一些老版本的桌面客户端只支持 stdio 或 SSE,不支持 Streamable HTTP;有些客户端要求服务端在/mcp固定路径暴露端点,不能自定义路径。我在部署时同时保留了两套入口:标准 Streamable HTTP 路径给新版客户端,另留一个 SSE 兼容端给老客户端,两边共用同一套鉴权和状态逻辑。这个兼容策略实际跑下来很稳,也是我建议所有做开源工具型 MCP Server 的人提前考虑的兼容性设计。
最后再说一个我一直在用的习惯:每次改动鉴权、流式或状态模块,我都会先把以前的线上日志回放一遍,确认没有破坏已有调用方的行为,再重新部署。改动 MCP Server 这类基础服务,最怕的就是悄悄破坏客户端协议。宁可多花十分钟做回归,也别等线上事故来教你做人。