1. 协议里到底有什么
同一个查订单接口,Claude Desktop 里定义一遍,Cursor 里再写一遍,自研 Agent 里还得来第三遍。鉴权各写各的,日志各存各的,出了事没人说得清是哪一次调用干的。MCP(Model Context Protocol)在 2024 年 11 月由 Anthropic 开源,做的事就一件:用 JSON-RPC 2.0 统一工具描述、调用和返回。
Server 侧暴露三种原语:
| 原语 | 谁来触发 | 典型用法 |
|---|---|---|
| Tools | 模型决定调用 | 查库、发消息、跑脚本 |
| Resources | 客户端或用户选择 | 读文件、日志、配置项 |
| Prompts | 用户显式选择 | 固定的提示模板 |
传输方式只有两种,先想清楚部署形态再选:
- stdio:客户端拉起 Server 进程,走标准输入输出。本地开发、单机 CLI 用它,不用管网络和证书。
- Streamable HTTP:远程部署,单端点,需要流式响应时在同一个连接上升级成 SSE。2025-03-26 版本用它替掉了早期的 HTTP+SSE 双端点方案,老 SSE 端点现在属于历史遗留。
initialize 阶段协商 protocolVersion,协商不上直接断连——这个报错在生产环境里的出现频率比预想中高。
2026 年初的生态大致是:客户端侧 Claude Desktop、Claude Code、Cursor、VS Code 的 Copilot Agent 模式、Cline、Zed、Windsurf 都能挂 MCP Server;SDK 官方维护 Python、TypeScript、Java、Kotlin、C#、Go、Ruby、Rust 等实现,Python 用 mcp 包,TypeScript 用 @modelcontextprotocol/sdk,第三方还有 FastMCP 这类更顺手的封装;官方 registry 在 2025 年开放预览,可以按名字找现成 Server。
现成 Server 能省掉检索类需求,但内部的权限模型、审计要求和数据边界,还是得自己写。协议细节以 modelcontextprotocol.io 上的 spec 页面为准,别拿二手博客当规范用。
2. 注册、鉴权、审计、限流四件事
工具怎么划
别把一个系统的所有接口塞进一个 Server。按业务域拆成 orders、jira、filesystem,每个独立部署、独立授权、独立限流。工具名用动词加名词,让模型看名字就能猜对用途。
参数校验压在函数签名上,别指望模型传对:
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP('orders')
class QueryInput(BaseModel):
order_id: str = Field(..., pattern=r'^ORD-[0-9]{8}$')
@mcp.tool()
def get_order(order_id: str) -> dict:
'''按订单号查订单状态。只读,不改任何数据。'''
...
上面是示意,装饰器参数名以你装的 SDK 版本文档为准。
工具注解(tool annotations)用来告诉客户端这个工具会不会动数据:readOnlyHint 标只读,destructiveHint 标破坏性操作,客户端据此决定要不要弹确认框。你不标,客户端只能一律弹框,用户点习惯了确认按钮,反而更危险。
远程鉴权必须校验 audience
2025-06-18 版本把远程 MCP Server 明确定位成 OAuth 2.1 的资源服务器。完整流程:
这里最容易被跳过的一步是校验 token 的 audience。不校验的话,给 orders Server 签发的 token 就能拿去打 filesystem Server,一个环节被攻破等于全线失守。RFC 8707 的 resource 参数就是干这个用的。
多租户场景还有一条硬规则:租户 ID 从 token 的 claim 里取,不要从工具参数里取。参数是模型填的,模型会被提示词牵着走。正确做法是 Server 侧用 token 里的 tenant 覆盖掉参数里的同名字段,或者干脆不暴露租户参数。
另外,别把长期 API Key 塞进 URL query string,日志、代理、浏览器历史都会留痕。stdio 场景没有网络鉴权,靠的是进程隔离和文件权限,配置文件别设成全局可读。
审计日志该记哪些字段
每次 tools/call 至少留下这些:
request_id:JSON-RPC 的 id,用来串起一次请求session_id:Streamable HTTP 的Mcp-Session-Id,用来串起多轮调用subject:调用方身份,来自 token 的 subtool_name:被调工具params_digest:参数的哈希或脱敏副本,别存明文手机号、身份证started_at/duration_ms:时间与耗时outcome:成功、参数校验失败、下游超时、配额拒绝
限流分三层做
- 会话层:每个 token 能开多少并发会话,防连接耗尽。
- 工具层:每租户每工具的 QPS 上限,写操作比读操作限得更紧。
- 结果层:单次调用返回多少条记录、多少字符。这条最容易被忽略,一个
list_all_orders返回五万行,上下文直接爆。
3. 接进 LangChain、Dify、Coze
LangChain
用 langchain-mcp-adapters,它把 MCP 工具转成 LangChain 的 BaseTool:
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
'orders': {
'url': 'https://mcp.internal/orders',
'transport': 'streamable_http',
'headers': {'Authorization': f'Bearer {token}'},
},
'files': {
'command': 'python',
'args': ['-m', 'local_fs_server'],
'transport': 'stdio',
},
})
tools = await client.get_tools()
配置字段会随适配器版本变,接之前先读一遍你装的那一版的 README。三个坑值得提前防:
- 工具重名。两个 Server 都叫
search,LangChain 侧直接撞。给工具名加 Server 前缀,比如orders__search。 - stdio 会话生命周期。进程是拉起来的,用完要关,否则在长时间运行的服务里会越堆越多。用异步上下文管理器管,别在模块顶层全局初始化一次就不管了。
- token 过期。长会话跑到一半 token 失效,工具调用返回 401,需要有刷新逻辑,而不是把错误丢给模型让它自己猜。
Dify
Dify 能做两个方向的事:作为客户端去连外部 MCP Server,也能把 Dify 应用本身暴露成 MCP 服务给别的客户端调。入口位置在插件市场和工作流节点里,不同版本不一样。
工作流里挂 MCP 工具时注意一点:Dify 的工具节点不一定把 MCP 的完整错误信息透出来。出错时先去 Dify 的外部日志里看原始响应,再回 MCP Server 侧对照,两边一起看比单看一侧快很多。
Coze / 扣子
扣子国内版和 coze.com 的功能开放节奏不同,MCP 入口在不同账号和套餐下的可见性有差异。动手写接入代码之前,先在控制台的工具或插件列表里搜一遍 MCP,确认当前账号能看到,再去翻对应的接入手册。开源版 Coze Studio 也带 MCP 相关能力,但版本迭代快,文档和实际界面对不上是常事。
三个平台有个共同点:凭据都存在平台侧。公司如果有凭据管理要求,先评估能不能用短期 token 加固定 IP 出网,别直接把长期密钥贴进配置。
4. 观测什么,怎么判断工具层做得好不好
指标分四组,别只盯延迟。
| 组别 | 看哪些 | 它能告诉你什么 |
|---|---|---|
| 协议层 | initialize 成功率、协商到的 protocolVersion 分布、JSON-RPC 错误码分布 | 版本不匹配和握手失败最先在这里露头 |
| 工具层 | 每工具调用量、p50/p95 延迟、失败率、参数校验失败率 | 参数校验失败率高,说明工具描述写得不够清楚 |
| 模型层 | 工具选择准确率、无效调用率、平均调用轮次 | 选错工具和调了不用,是两类不同的问题 |
| 成本层 | 单任务工具调用次数、工具返回内容占用的 token 数 | 直接对应账单 |
工具层好不好用,靠离线评估跑出来。做法是从真实工单里挑 30 条有代表性的请求,人工标注"应该调哪个工具、传什么参数",做成固定测试集。改一次工具描述就跑一遍,看工具选择准确率有没有掉。测试集不用大,但要稳定,而且不能拿调优时反复看过的样本自欺欺人。
5. 上线前逐项过一遍
- 每个工具的返回体有大小上限,超出部分走 Resource 引用或分页
- stdio Server 的日志全部输出到 stderr,stdout 只走协议帧
- Server 侧和客户端侧都设了超时,卡死的下游不会挂住整个会话
-
tools/call的结构化日志已接入,字段含 request_id、session_id、subject、tool_name、outcome - 远程 Server 校验了 token 的 audience,不是只看签名
- 租户 ID 从 token 取,参数里的同名字段被覆盖而不是被信任
- 写操作工具带了 destructiveHint 注解,客户端会弹确认
- 单 token 并发会话数、每工具 QPS、单次返回条数三个限流都配了
- 多副本部署时,Mcp-Session-Id 对应的会话状态已外置到 Redis,或明确禁用了会话
- 协议版本协商失败有日志和告警,不是静默断连
- 有一组固定的评估用例,改工具描述后能跑回归
几个踩过的坑
stdio 模式下用 print 打日志。 stdout 是 JSON-RPC 帧的通道,一行 print 就能让客户端解析失败。第一次撞上多半会以为是网络问题,排查很久才反应过来。
工具描述写给同事看,不是给模型看。 内部黑话、缩写、不说明返回格式,模型只能瞎选。描述里写清楚"什么时候该用这个工具",比写清楚"这个工具做什么"更有用。
只在开发机测过 stdio 就上线。 生产换成 Streamable HTTP 之后,鉴权、会话、超时全变了。远程化之前,先在一个测试环境把 OAuth 流程完整走一遍。
没有限流。 一个 Agent 在循环里反复调同一个工具几十次,账单和下游数据库一起炸。
忽略 initialize 的版本协商。 客户端降级、Server 升级,两边对不上,表现出来是"工具列表拉不到",而不是一个明确的版本错误,容易往错的方向查。
可以立刻做的一件事:挑一个你团队已经在用、且只读的内部接口,用 FastMCP 包一层,本地 stdio 跑通,再加一条 tools/call 的结构化日志。跑顺了再考虑远程化和鉴权,别一上来就把写操作接进去。