1. 从一个真实困惑说起MCP 到底在解决什么问题第一次看到 MCP 这个词是在折腾本地 AI 助手接入外部工具的时候。当时想让模型读一下本地文件、查一下数据库、再顺手调个接口结果发现每接一个工具就要写一套适配代码工具 A 的返回格式和工具 B 完全不一样模型这边还得反复改提示词去迁就。折腾到后面代码里全是胶水逻辑维护成本高得离谱。后来接触到MCPModel Context Protocol模型上下文协议才意识到它想干的事情其实很朴素把模型和外部能力之间的连接方式标准化。你可以把它理解成 AI 世界里的一个统一插座标准——以前每个电器都有自己的插头形状现在大家约定都用同一种接口插上去就能用。这篇内容我打算把 MCP 的架构从头到尾捋一遍包括它为什么这么设计、核心组件各自负责什么、通信层是怎么跑起来的、SDK 在里面扮演什么角色以及实际落地时容易踩的坑。适合两类人看一类是刚听说 MCP、想知道它到底是什么的开发者另一类是想自己写一个 MCP Server 或者把 MCP 集成进现有系统、需要搞清楚架构细节的工程师。全文基于公开的协议设计思路和常见工程实践展开涉及具体实现的地方我会说明这是通用做法还是我的个人经验。需要先明确一点MCP 不是某个具体产品也不是某个模型专属的功能它是一套开放的协议规范。协议本身只定义怎么通信、怎么描述能力、怎么交换上下文至于谁来用、用在哪个模型上那是上层的事。这个定位很重要因为很多人一开始会把它和某个具体的 AI 工具绑定结果理解就跑偏了。2. MCP 的三层架构Host、Client、Server 各管什么MCP 的架构最核心的一点是它把整个系统拆成了三个角色。这个拆分不是拍脑袋定的而是为了让能力提供方和能力使用方彻底解耦。下面逐个说。2.1 Host用户真正面对的那个程序Host 就是你实际在用的那个应用比如一个桌面 AI 助手、一个 IDE 插件、一个聊天客户端。它负责和用户交互也负责管理整个会话。Host 内部会创建并持有多个 Client每个 Client 对应一个 Server 连接。Host 的职责边界很关键它决定要不要把某个工具暴露给模型、决定用户授权哪些操作、决定上下文怎么裁剪。换句话说安全和策略的把关在 Host 这一层而不是在 Server。这个设计是有意为之的——Server 只负责我能做什么Host 负责允不允许做。我见过有人把权限校验写在 Server 里结果换个 Host 就绕过去了。正确的做法是 Host 做最终裁决Server 只做能力声明。2.2 Client协议连接的维护者Client 是 Host 内部的一个组件一个 Client 对应一个 Server 连接。它负责的事情比较纯粹建立连接、发送请求、接收响应、处理通知、维护会话状态。Client 和 Server 之间是一对一的关系。如果你要连三个不同的 ServerHost 里就会有三个 Client 实例。这种设计的好处是隔离性好——一个 Server 挂了不会影响其他连接每个连接的状态也互不干扰。Client 还要处理协议层面的细节比如能力协商capabilities negotiation。连接建立时双方会交换各自支持的能力比如 Server 支持不支持资源订阅、Client 支持不支持采样请求。这个协商过程决定了后续能用哪些功能。2.3 Server能力的实际提供方Server 是真正干活的那一方。它对外声明自己提供哪些工具tools、哪些资源resources、哪些提示模板prompts然后响应 Client 发来的调用请求。Server 可以是本地的比如跑在同一台机器上的一个进程负责读本地文件也可以是远程的比如一个部署在服务器上的服务负责查数据库。协议本身不限制 Server 的部署位置只规定通信方式。一个 Server 通常聚焦一个领域。比如文件系统 Server 就只管文件读写数据库 Server 就只管 SQL 查询。这种单一职责的设计让 Server 容易复用——同一个文件系统 Server可以被任何支持 MCP 的 Host 使用。下面这张表把三个角色的职责对比一下方便快速定位角色核心职责数量关系安全责任Host用户交互、策略裁决、上下文管理1 个最终授权与权限控制Client协议连接、请求响应、能力协商每个 Server 一个传递授权信息Server能力声明、工具执行、资源提供可多个不负责授权只负责执行理解这三层的关键是记住一句话Host 管策略Client 管连接Server 管能力。三者职责不重叠这是 MCP 架构清晰的根本原因。3. 通信层拆解JSON-RPC 与 transport 是怎么配合的MCP 的通信层是很多人第一次看协议文档时容易懵的地方。它其实分了两层上面是消息格式下面是传输方式。两层分开设计是为了让协议既能跑在本地进程间也能跑在网络之上。3.1 为什么选 JSON-RPC 作为消息格式MCP 的消息格式用的是JSON-RPC 2.0。这个选择我觉得挺务实。JSON-RPC 本身足够简单请求、响应、通知三种消息类型覆盖了绝大多数交互场景而且几乎所有语言都有现成的解析库。一条典型的请求长这样{ jsonrpc: 2.0, id: 1, method: tools/call, params: { name: read_file, arguments: { path: /tmp/demo.txt } } }对应的响应{ jsonrpc: 2.0, id: 1, result: { content: [ { type: text, text: 文件内容... } ] } }选 JSON-RPC 而不是自定义格式好处是生态成熟、调试方便。你甚至可以用 curl 手动发一条请求来测试 Server排查问题时特别有用。坏处是 JSON 本身比较冗长传输大块数据时效率一般不过对于工具调用这种场景数据量通常不大影响可以接受。提示JSON-RPC 的id字段是请求和响应的关联键。异步场景下多个请求可能并发发出靠id才能把响应正确配对。自己实现 Client 时这个字段千万别复用。3.2 transport 层stdio 与 HTTP 的取舍transport 是消息实际走的通道。MCP 目前主流的两种传输方式是stdio和HTTP含 SSE它们适用的场景完全不同。stdio指的是标准输入输出。Server 作为一个子进程被 Host 启动双方通过 stdin 和 stdout 交换 JSON-RPC 消息。这种方式的特点是延迟极低因为就是本地进程间管道通信不需要网络配置天然安全不暴露端口生命周期绑定Host 启动 ServerHost 退出 Server 也跟着结束适合本地工具比如文件操作、本地命令执行HTTP SSE则是为远程场景设计的。Client 通过 HTTP POST 发送请求Server 通过 Server-Sent Events 推送响应和通知。这种方式的特点是可以跨网络Server 能部署在远端需要处理认证、加密、连接保持等问题适合多用户共享的服务型 Server选择哪种 transport本质上是在问这个 Server 是给本机用的还是给网络用的。本地工具优先 stdio远程服务优先 HTTP。我个人的经验是能用 stdio 就别上 HTTP因为少一层网络就少一堆问题。3.3 一次完整的工具调用消息是怎么流动的把上面两层串起来一次工具调用的完整链路是这样的Host 收到用户请求模型决定调用某个工具Host 通过对应的 Client构造 JSON-RPC 请求Client 把请求序列化通过 transportstdio 或 HTTP发给 ServerServer 解析请求执行工具逻辑Server 把结果封装成 JSON-RPC 响应原路返回Client 收到响应交给 HostHost 把结果喂回模型继续对话这个链路里transport 只负责搬运字节不关心内容JSON-RPC 只负责消息结构不关心传输方式。两层解耦所以换 transport 不用改消息格式换消息格式也不用动 transport。这种分层是 MCP 能同时支持本地和远程的根本原因。4. 能力协商与三大原语Server 到底能提供什么MCP 把 Server 能提供的能力抽象成了几个原语primitives。理解这几个原语就理解了 Server 的能力边界。连接建立时的能力协商就是双方互相声明我支持哪些原语。4.1 tools可被模型调用的动作tools是最常用的原语代表一个可以被模型调用的动作。比如读文件发请求查数据库都可以是一个 tool。每个 tool 有名字、描述、参数 schema模型根据这些信息决定要不要调用、怎么传参。tool 的定义通常长这样{ name: query_database, description: 执行 SQL 查询并返回结果, inputSchema: { type: object, properties: { sql: { type: string, description: 要执行的 SQL 语句 } }, required: [sql] } }inputSchema用的是 JSON Schema这样模型能准确知道参数类型和约束。我踩过的一个坑是description 写得太模糊模型经常传错参数。后来把每个参数的用途、格式、示例都写清楚调用准确率明显提升。这个细节看起来小但实际影响很大。4.2 resources可被读取的上下文数据resources代表可以被读取的数据比如文件内容、数据库记录、API 返回的数据。和 tools 的区别在于tools 是执行动作resources 是读取数据。resources 通常由 Host 决定要不要放进上下文而不是模型主动调用。resource 用 URI 标识比如file:///path/to/file或db://users/123。这种设计让资源的定位很直观也方便做权限控制——Host 可以按 URI 前缀来决定允许访问哪些资源。4.3 prompts预定义的提示模板prompts是预定义的提示模板用户可以主动选择使用。比如一个代码审查prompt预置了审查的指令和格式要求用户选中后直接套用。这个原语更多是面向用户的而不是面向模型的。三大原语的对比原语触发方典型用途是否进上下文tools模型执行动作结果进上下文resourcesHost/用户读取数据由 Host 决定prompts用户套用模板模板内容进上下文4.4 能力协商连接建立时的握手连接刚建立时Client 和 Server 会交换各自的能力声明。Server 会告诉 Client我支持 tools、resources但不支持 prompts。Client 也会告诉 Server我支持采样请求。这个协商结果决定了后续哪些方法可以调用。协商的意义在于向前兼容。新版本协议加了新能力老 Server 不声明就不受影响老 Client 也不会去调用不支持的方法。这种设计让协议可以平滑演进不会因为加功能就把老实现全废掉。注意能力协商是单向声明不是强制约束。Server 声明支持某个能力不代表每次调用都会成功。Client 仍然要处理调用失败的情况不能假设声明了就一定能用。5. SDK 的角色为什么不该从零手写协议实现协议规范是一回事实际写代码是另一回事。MCP 提供了多种语言的 SDK覆盖 Python、TypeScript 等主流语言。很多人会问协议又不复杂我直接手写 JSON-RPC 不行吗行但没必要。5.1 SDK 帮你屏蔽了哪些脏活手写协议实现你要自己处理这些JSON-RPC 消息的序列化和反序列化请求 id 的生成和响应配对能力协商的握手流程transport 的建立、维护、断线重连错误码的映射和异常处理并发请求的管理这些活单看都不难但加起来就是几百行胶水代码而且容易出 bug。SDK 把这些都封装好了你只需要关注业务逻辑——定义 tool、实现处理函数、返回结果。用 SDK 写一个 Server核心代码可能就几十行from mcp.server import Server from mcp.server.stdio import stdio_server app Server(demo-server) app.tool() async def read_file(path: str) - str: with open(path, r) as f: return f.read() async def main(): async with stdio_server() as (read, write): await app.run(read, write) if __name__ __main__: import asyncio asyncio.run(main())装饰器一挂tool 就注册好了协议细节 SDK 全包了。这就是 SDK 的价值。5.2 选 SDK 还是手写判断标准我的判断标准很简单做业务 Server用 SDK。省时间少踩坑升级协议也方便。做协议研究或特殊 transport可以考虑手写但要有心理准备。做 Client 集成优先用 SDK因为 Client 要处理的协议细节更多。手写唯一合理的场景是你需要一个 SDK 不支持的 transport或者你要在受限环境里跑比如嵌入式。除此之外用 SDK 都是更优解。5.3 SDK 版本与协议版本的对应关系这里有个容易忽略的点SDK 版本和协议版本不是一回事。SDK 会跟进协议版本但可能有滞后。用 SDK 时要注意看它支持的协议版本别拿一个老 SDK 去对接新协议的 Server。我遇到过 SDK 版本太老、不支持某个新方法的情况排查了半天才发现是版本问题。建议在项目里明确记录 SDK 版本和对应的协议版本升级时一起评估。6. 落地时最容易踩的几个坑架构讲完了说点实际的。下面这些坑有的是我踩过的有的是看别人踩过的都是落地时高频出现的问题。6.1 stdio 模式下日志输出污染协议通道这是 stdio 模式最经典的坑。Server 通过 stdout 发送 JSON-RPC 消息如果你在代码里随手print调试信息这些信息会混进 stdoutClient 解析时就报错。正确做法是所有日志走 stderrstdout 只留给协议消息。大多数 SDK 的日志默认就走 stderr但如果你自己写了 print一定要改掉。import sys # 错误污染协议通道 print(debug info) # 正确走 stderr print(debug info, filesys.stderr)这个坑的隐蔽性在于本地测试时可能没事因为没触发那条 print一上生产就炸。建议在代码审查时专门检查 stdout 的使用。6.2 工具描述写得太随意导致模型调用失败前面提过一次这里再强调。tool 的description和参数的description是给模型看的不是给人看的。写得含糊模型就传错参数或者干脆不调用。我的经验是tool 描述要说清楚这个工具做什么、什么时候用参数描述要说清楚格式、取值范围、示例枚举类型的参数把每个取值都解释一遍这些描述看起来是文档工作实际上直接影响功能可用性。花十分钟把描述写好能省掉后面几小时的调试。6.3 长连接断开后的重连处理HTTP SSE 模式下连接可能因为网络波动断开。如果 Client 没有重连机制断开后就彻底失联了。重连要考虑几件事重连时机立即重连还是退避重连建议指数退避避免雪崩状态恢复重连后之前的会话状态还在不在需不需要重新协商能力请求重放断开时正在进行的请求怎么办是重发还是报错这些问题协议本身不规定需要 Client 实现时自己决定。我的建议是重连后重新走一遍能力协商不要假设状态还在。请求重放要谨慎非幂等的操作重放可能出问题。6.4 权限边界模糊导致的安全隐患MCP 的权限模型是 Host 做最终裁决但实际实现时很多 Host 为了省事直接把 Server 声明的能力全放开了。这就埋下了隐患。比如一个文件系统 Server 声明能读任意路径Host 如果不做限制模型就可能读到敏感文件。正确的做法是 Host 按路径白名单、操作类型等维度做细粒度控制。Server 这边也要有防御意识不要假设 Host 会做所有校验。关键操作在 Server 侧再校验一遍双保险。6.5 大结果集撑爆上下文tool 返回的结果会进上下文如果返回的数据量很大比如查数据库返回几万行上下文直接爆掉模型也没法处理。处理方式有几种Server 侧限制返回条数比如默认返回前 100 行结果分页让模型按需取大结果存成 resource只把摘要或引用放进上下文我倾向于第一种加第三种默认限制条数需要全量时引导模型去读 resource。这样既控制了上下文又保留了获取全量数据的能力。7. 自己动手从零跑通一个最小 MCP Server理论说再多不如跑一遍。下面用一个最小示例把 Server 从定义到跑通的完整流程走一遍。这里用 Python SDK其他语言思路类似。7.1 环境准备与依赖安装先装 SDKpip install mcp确认版本pip show mcp记下版本号后面排查问题时用得上。如果项目里已经有其他依赖建议用虚拟环境隔离避免版本冲突。7.2 定义第一个 tool 并注册写一个最简单的 Server提供一个计算字符串长度的 toolfrom mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent app Server(string-utils) app.list_tools() async def list_tools(): return [ Tool( namestring_length, description计算给定字符串的字符数用于统计文本长度, inputSchema{ type: object, properties: { text: { type: string, description: 要计算长度的字符串例如 hello world } }, required: [text] } ) ] app.call_tool() async def call_tool(name: str, arguments: dict): if name string_length: text arguments[text] return [TextContent(typetext, textf长度: {len(text)})] raise ValueError(f未知工具: {name}) async def main(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ __main__: import asyncio asyncio.run(main())这段代码里list_tools负责声明能力call_tool负责执行。描述字段我特意写详细了就是为了让模型能准确调用。7.3 用 Client 验证调用链路写个简单的 Client 测试一下from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): params StdioServerParameters( commandpython, args[server.py] ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() print(可用工具:, [t.name for t in tools.tools]) result await session.call_tool( string_length, {text: hello mcp} ) print(调用结果:, result.content[0].text) if __name__ __main__: import asyncio asyncio.run(main())跑通后你会看到工具列表和调用结果。这一步验证了从 Client 到 Server 的完整链路包括能力协商、工具调用、结果返回。7.4 调试技巧手动发一条 JSON-RPC 请求SDK 跑通后建议再手动发一条原始请求加深对协议的理解。stdio 模式下可以直接用管道echo {jsonrpc:2.0,id:1,method:tools/list,params:{}} | python server.py你会看到 Server 返回的 JSON-RPC 响应。这个技巧在排查协议层问题时特别有用——能直接看到原始消息不用猜 SDK 在背后做了什么。提示手动测试时Server 可能因为等待更多输入而不退出。可以加超时控制或者用timeout命令包裹。8. 关于 MCP 架构我个人的几点体会聊了这么多架构细节最后说点偏主观的感受。MCP 这套架构最让我欣赏的地方是它的克制。它没有试图解决所有问题只定义了模型和外部能力怎么连接这一件事其他都交给上层。这种克制让协议足够简单也足够稳定。相比之下很多协议恨不得把权限、调度、缓存全塞进来结果就是又重又难用。另一个体会是transport 和消息格式的分层设计非常关键。正因为这两层解耦MCP 才能同时支持本地和远程而且换 transport 不影响上层逻辑。这个设计思路值得借鉴——做任何协议设计时先把传什么和怎么传分开后面扩展会轻松很多。至于实际落地我的建议是先用 SDK 跑通最小闭环再逐步加功能。别一上来就想着做全功能 Server先把一个 tool 跑通理解整个链路再往上堆。我见过太多人一开始就设计复杂的能力体系结果卡在协议细节上最后不了了之。还有一点描述字段的重要性怎么强调都不过分。MCP 是给模型用的协议模型靠描述来理解能力。描述写得好模型调用就准描述写得烂再好的实现也白搭。这一点和传统 API 设计很不一样需要转变思路。最后MCP 还在演进中协议版本、SDK 实现都在更新。保持关注官方规范别照着老教程抄。遇到问题时先看协议原文再看 SDK 源码最后才去搜别人的经验——这个顺序能帮你少走很多弯路。
