ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MCP协议实战:从零实现LLM工具接入的标准化方案

MCP协议实战:从零实现LLM工具接入的标准化方案 1. 从一个反复出现的集成噩梦说起如果你最近半年在折腾大模型应用大概率会遇到这样一个场景你写了一个很顺手的对话助手接入了文件读取、数据库查询、网页抓取三个工具代码跑得挺好。结果产品经理过来说能不能把公司内部的工单系统也接进来你打开代码一看工具调用的逻辑和主流程耦合在一起光是参数校验和错误处理就写了三百行。更麻烦的是隔壁团队用另一套框架也做了个助手他们想复用你的工单查询能力你发现根本抽不出来。这个问题的本质不是代码写得不好而是工具接入这件事缺少一个统一的协议层。每个模型厂商有自己的函数调用格式每个框架有自己的工具注册方式每个应用有自己的上下文拼装逻辑。你每接一个新工具就要重新写一遍适配代码你每换一个模型就要重新调一遍参数结构。这种重复劳动消耗了大量精力而且极易出错。MCPModel Context Protocol就是为了解决这个问题而出现的。它做的事情说起来很简单把模型需要的外部能力和模型本身之间的通信方式标准化。你可以把它理解成 LLM 应用领域的 USB-C 接口——不管你是鼠标、键盘还是显示器只要插上这个标准接口主机就能识别并使用。MCP 定义了一套基于 JSON-RPC 的通信规范让工具提供方Server和能力消费方Client之间可以用统一的方式交换上下文信息。这篇文章适合三类人看第一类是想给自己的 LLM 应用接入外部工具但被各种适配层折磨的开发者第二类是想把自己内部系统的能力暴露给 AI 助手使用的平台工程师第三类是想理解 MCP 到底解决了什么问题、值不值得投入时间学习的技术决策者。我会从协议设计的底层逻辑讲起拆解核心通信机制然后给出可落地的 Server 和 Client 实现思路最后分享一些实际接入中容易踩的坑。2. MCP 到底标准化了哪些东西2.1 不是又一个函数调用格式而是上下文接入的完整抽象很多人第一次听到 MCP会以为它只是另一个函数调用规范类似于 OpenAI 的 Function Calling 或者 Anthropic 的 Tool Use。这个理解只对了一小部分。函数调用规范解决的是模型如何表达它想调用某个工具这个问题而 MCP 解决的是整个上下文接入链路的标准化。具体来说MCP 标准化了以下几个层面的事情。第一是能力的描述方式一个 Server 能提供哪些工具、每个工具接受什么参数、返回什么结构这些都用统一的 schema 来描述。第二是通信的传输方式Client 和 Server 之间怎么建立连接、怎么发送请求、怎么接收响应协议规定了标准的传输层选项。第三是上下文的组织方式除了工具调用MCP 还定义了资源Resources和提示模板Prompts的概念让模型可以获取结构化的上下文信息而不仅仅是调用一个函数拿返回值。这三层抽象加在一起才构成了 MCP 的完整价值。你可以这样理解函数调用规范是动词层面的标准化告诉模型怎么做一件事MCP 是名词动词语法层面的标准化告诉模型有哪些东西可以用、怎么用、用完怎么组织结果。2.2 JSON-RPC 2.0 作为通信基座的选择逻辑MCP 选择 JSON-RPC 2.0 作为消息格式这个决定值得展开说说。JSON-RPC 是一个很老的协议规范它的核心结构非常简单请求包含jsonrpc、method、params、id四个字段响应包含jsonrpc、result或error、id。就这么简单。为什么不用 REST因为 REST 是面向资源的而 MCP 的交互模式更接近远程过程调用——Client 要执行一个具体的方法比如tools/list列出所有工具、tools/call调用某个工具。用 RPC 风格更自然。为什么不用 gRPC因为 gRPC 依赖 HTTP/2 和 Protobuf对很多轻量级场景来说太重了而且调试不如 JSON 直观。JSON-RPC 的好处是人类可读、实现简单、传输层无关——你可以用 stdio 传也可以用 HTTP 传甚至可以用 WebSocket 传。这里有一个容易被忽略的细节JSON-RPC 的id字段是支持异步和批量请求的关键。Client 可以同时发出多个请求每个请求带不同的idServer 处理完后按id返回结果顺序可以打乱。这个设计在工具调用场景下很实用因为有些工具执行快、有些执行慢如果串行等待会浪费大量时间。2.3 三种核心原语Tools、Resources、Prompts 的分工MCP 定义了三种核心原语它们各自解决不同的问题很多人一开始会混淆。Tools是最常用的它代表模型可以执行的动作。比如查询数据库、发送邮件、调用外部 API。Tool 的调用是有副作用的模型需要明确知道自己在做什么。每个 Tool 用 JSON Schema 描述输入参数Server 负责执行并返回结果。Resources代表模型可以读取的数据。它和 Tool 的区别在于Resource 是只读的、被动的模型不需要执行什么只需要获取内容。比如一个文件的内容、一个数据库表的 schema、一段配置信息。Resource 用 URI 来标识比如file:///path/to/doc.md或者db://users/schema。Prompts代表预定义的提示模板。它允许 Server 向 Client 暴露一些可复用的提示结构Client 可以选择使用这些模板来构造请求。这个原语在实际应用中使用频率相对较低但在需要标准化交互模式的场景下很有价值。理解这三者的分工是设计 MCP Server 的基础。我见过不少开发者把所有东西都塞进 Tools 里结果 Resource 能做的事也写成了 Tool导致模型需要调用一个函数才能读取一段静态文本既浪费 token 又增加了出错概率。3. 传输层选型stdio 与 Streamable HTTP 的适用边界3.1 stdio 模式本地进程间通信的简洁方案stdio 是 MCP 最早支持的传输方式也是实现起来最简单的一种。它的工作原理是Client 启动 Server 作为一个子进程然后通过标准输入stdin和标准输出stdout交换 JSON-RPC 消息。每条消息占一行用换行符分隔。这种方式的优势非常明显。第一是零网络配置不需要考虑端口、防火墙、证书这些东西进程启动就能通信。第二是生命周期天然绑定Client 启动 ServerClient 退出时 Server 也跟着结束不会留下孤儿进程。第三是安全性好通信完全在本机进程间进行不暴露任何网络端口。但 stdio 的局限性也很明显。它只能用于本地场景无法跨机器通信。而且 Server 必须是一个可以独立启动的进程如果你的能力是嵌在一个大系统里的抽出来做成独立进程会增加部署复杂度。另外stdio 模式下 Server 的日志输出要特别小心——如果你往 stdout 打印了非 JSON-RPC 格式的内容Client 解析就会出错。正确的做法是把日志写到 stderr或者写到文件里。实操提示在 stdio 模式下调试时千万不要用print或console.log往标准输出打日志。我见过至少三个项目因为这个原因导致 Client 端报invalid JSON错误排查了半天才发现是日志污染了通信通道。3.2 Streamable HTTP远程接入的主流选择当你的 MCP Server 需要部署在远程服务器上或者需要被多个 Client 共享时Streamable HTTP 就是更合适的选择。它是 MCP 规范中定义的 HTTP 传输方式支持两种模式普通的请求-响应模式以及基于 Server-Sent Events 的流式模式。普通模式下Client 向 Server 的/mcp端点发送 POST 请求请求体是 JSON-RPC 消息Server 返回 JSON-RPC 响应。这个模式实现简单适合大多数工具调用场景。流式模式下Client 先发一个 GET 请求建立 SSE 连接Server 可以通过这个连接主动推送消息给 Client适合需要长时间运行或需要 Server 主动通知的场景。Streamable HTTP 的一个关键设计是会话管理。Server 在初始化响应中返回一个Mcp-Session-IdClient 后续的请求都要带上这个 header。这样 Server 就能区分不同 Client 的会话状态。如果你的 Server 是无状态的也可以不实现会话管理但那样就无法支持需要保持状态的交互。这里有一个实际部署时经常遇到的问题反向代理的超时设置。如果你的 Server 后面挂了 Nginx 之类的代理默认的 60 秒超时可能会导致长耗时工具调用被中断。你需要把proxy_read_timeout调大或者让工具调用走异步模式——先返回一个任务 IDClient 再轮询结果。3.3 选型决策表什么场景用什么传输场景特征推荐传输方式理由本地开发调试stdio零配置启动快日志直观单机桌面应用stdio生命周期绑定无网络暴露团队共享的工具服务Streamable HTTP多 Client 接入集中管理需要 Server 主动推送Streamable HTTP SSE支持服务端发起消息工具执行时间超过 30 秒Streamable HTTP 异步任务避免连接超时对安全性要求极高的内网stdio不暴露网络端口这个表不是绝对的实际选型还要考虑你的部署环境、团队技术栈和运维能力。但核心原则是能用 stdio 就用 stdio需要远程共享时才上 HTTP。很多团队一上来就搞 HTTP 部署结果增加了不必要的运维负担。4. 从零实现一个 MCP Server 的关键步骤4.1 能力声明让 Client 知道你能做什么MCP Server 启动后Client 会先发送initialize请求Server 需要在响应中声明自己支持的能力。这个声明包括支持哪些协议版本、是否支持 Tools、是否支持 Resources、是否支持 Prompts、是否支持日志等。{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2024-11-05, capabilities: { tools: { listChanged: true }, resources: { subscribe: false, listChanged: true }, prompts: { listChanged: false } }, serverInfo: { name: my-tool-server, version: 1.0.0 } } }这里的listChanged表示当工具列表发生变化时Server 是否会主动通知 Client。如果你的工具是动态注册的这个字段要设为true否则 Client 可能一直用缓存的旧列表。初始化完成后Client 会发送notifications/initialized通知表示握手完成。之后就可以正常调用tools/list、tools/call等方法了。4.2 工具注册JSON Schema 描述的艺术每个 Tool 的定义包含name、description和inputSchema三个核心字段。name是工具的唯一标识description是给模型看的自然语言说明inputSchema是 JSON Schema 格式的参数定义。这里有一个很多人忽略的点description 的质量直接决定模型能否正确使用这个工具。我见过太多项目把 description 写成查询数据这种模糊描述结果模型根本不知道什么时候该调用它。好的 description 应该包含这个工具做什么、什么时候用、参数的含义、返回值的结构。{ name: query_user_orders, description: 根据用户ID查询该用户的订单列表。当用户询问我的订单、购买记录等问题时使用此工具。返回订单号、金额、状态和创建时间。, inputSchema: { type: object, properties: { user_id: { type: string, description: 用户的唯一标识符通常是UUID格式 }, status: { type: string, enum: [pending, paid, shipped, completed], description: 可选按订单状态过滤 }, limit: { type: integer, minimum: 1, maximum: 100, default: 20, description: 返回的最大订单数量 } }, required: [user_id] } }注意enum和default的使用。enum限制了参数的取值范围让模型不会传入无效值default告诉模型这个参数可以省略。这些细节看起来小但能显著降低模型调用出错的概率。4.3 请求处理从 tools/call 到结果返回当 Client 调用tools/call时请求体包含工具名称和参数{ jsonrpc: 2.0, id: 2, method: tools/call, params: { name: query_user_orders, arguments: { user_id: u-12345, status: paid, limit: 10 } } }Server 收到请求后需要做几件事验证参数是否符合 schema、执行实际逻辑、把结果包装成 MCP 规定的格式返回。返回格式中content是一个数组每个元素可以是文本、图片或资源引用。{ jsonrpc: 2.0, id: 2, result: { content: [ { type: text, text: 找到 3 个已支付订单\n1. 订单号 ORD-001金额 299 元创建于 2024-01-15\n2. 订单号 ORD-002金额 158 元创建于 2024-01-18\n3. 订单号 ORD-003金额 89 元创建于 2024-01-20 } ], isError: false } }如果执行出错isError设为truecontent里放错误描述。这里有一个设计决策错误信息是给模型看的不是给开发者看的。所以错误描述要写得让模型能理解并可能自我纠正而不是抛一个堆栈信息。比如参数格式错误时应该返回user_id 必须是字符串格式你传入的是数字而不是TypeError: expected str, got int。4.4 日志与可观测性别让调试变成盲人摸象MCP 定义了notifications/message方法Server 可以通过它向 Client 发送日志消息。日志有级别之分debug、info、warning、error。Client 可以选择把这些日志展示给用户或者写入自己的日志系统。实际开发中我建议把日志分成两类协议日志和业务日志。协议日志记录 JSON-RPC 消息的收发用于排查通信问题业务日志记录工具执行的过程和结果用于排查逻辑问题。协议日志可以通过 MCP 的日志通知发送业务日志写到本地文件或发送到你的日志收集系统。注意不要在工具执行过程中同步写大量日志到 stdout这会阻塞通信。如果必须写用异步方式或者写到 stderr。5. Client 端的接入策略与上下文管理5.1 连接建立初始化握手的完整流程Client 端的接入从建立连接开始。如果是 stdio 模式Client 需要启动 Server 进程并获取 stdin/stdout 句柄如果是 HTTP 模式Client 需要向 Server 的端点发送请求。初始化握手分三步。第一步Client 发送initialize请求带上自己支持的协议版本和客户端信息。第二步Server 返回自己的能力声明。第三步Client 发送notifications/initialized通知握手完成。这个流程看起来简单但有一个容易出错的点协议版本协商。如果 Client 和 Server 支持的版本不一致需要有一方做出妥协。规范建议 Client 在initialize请求中带上自己支持的版本Server 如果支持就返回相同版本如果不支持就返回自己支持的版本Client 再决定是否继续。5.2 工具发现与缓存什么时候该刷新列表Client 在握手完成后通常会调用tools/list获取所有可用工具。这个列表可以缓存起来避免每次调用都重新获取。但缓存有一个问题如果 Server 的工具列表发生了变化Client 怎么知道答案在capabilities.tools.listChanged字段。如果 Server 声明支持列表变更通知那么当工具列表变化时Server 会发送notifications/tools/list_changed通知Client 收到后重新调用tools/list刷新缓存。如果 Server 不支持这个通知Client 就需要定期轮询或者在每次会话开始时重新获取。实际项目中我建议在每次会话开始时重新获取工具列表而不是长期缓存。因为工具列表通常不会太大重新获取的成本很低但用错工具列表的代价可能很高。5.3 把工具描述注入模型上下文的技巧Client 获取到工具列表后需要把这些工具的描述转换成模型能理解的格式注入到模型的上下文中。不同的模型有不同的工具调用格式但核心信息是一样的工具名称、功能描述、参数 schema。这里有一个 token 消耗的优化点。工具描述会占用模型的上下文窗口如果工具很多描述很长可能会挤占实际对话的空间。优化策略包括只注入当前场景相关的工具、压缩 description 的长度、把详细的参数说明放到 Resource 里按需加载。另一个技巧是工具分组。如果你的 Server 提供了几十个工具可以按功能分组在系统提示中先告诉模型有哪些组模型需要时再展开具体工具。这样能显著减少初始上下文的 token 消耗。5.4 处理工具调用结果文本、图片与结构化数据工具调用的返回结果可能是多种类型的。最常见的是文本直接拼接到对话历史里就行。图片需要特殊处理——有些模型支持直接理解图片有些不支持Client 需要根据模型能力做转换。结构化数据比如 JSON可以选择直接返回给模型也可以先格式化成可读文本。这里有一个实际经验返回给模型的结果要尽量简洁。我见过一个工具返回了完整的数据库查询结果几千行 JSON直接把模型的上下文撑爆了。正确的做法是在 Server 端做聚合和摘要只返回模型需要的关键信息。比如查询订单返回共 3 个订单总金额 546 元最近一笔是 1 月 20 日就够了不需要把每条记录的每个字段都返回。6. 实际接入中那些文档没写的坑6.1 参数校验失败时的错误信息设计参数校验失败是最高频的错误场景。很多 Server 实现直接返回 JSON Schema 的校验错误比如user_id does not match pattern ^[0-9a-f]{8}-。这种错误信息对开发者有用但对模型来说太晦涩了模型看不懂就无法自我纠正。好的错误信息应该包含三要素哪里错了、期望什么、实际收到什么。比如参数 user_id 格式不正确。期望是 UUID 格式如 550e8400-e29b-41d4-a716-446655440000你传入的是 12345。请检查后重新调用。这样的错误信息模型能理解下一轮调用时就会修正。实测下来这种错误信息能把参数错误的自我纠正率从不到 30% 提升到 80% 以上。6.2 长耗时工具的超时与异步处理有些工具执行时间很长比如调用外部 API 做数据分析、生成报表、批量处理文件。如果同步等待很容易触发 Client 或中间代理的超时。处理方案有两种。第一种是异步任务模式工具调用立即返回一个任务 ID模型告诉用户任务已提交请稍后查询然后模型再调用另一个工具查询任务状态。第二种是流式返回Server 通过 SSE 逐步返回进度Client 实时展示给用户。第一种方案实现简单适合大多数场景。第二种方案体验更好但实现复杂度高。选择哪种取决于你的具体需求和团队能力。6.3 多 Server 场景下的工具命名冲突当 Client 同时连接多个 MCP Server 时不同 Server 可能提供同名工具。比如两个 Server 都有search工具一个搜网页一个搜内部文档。这时候 Client 需要做命名空间隔离比如给工具名加上 Server 前缀web_search和doc_search。这个处理应该在 Client 端做而不是要求 Server 改工具名。因为 Server 是独立开发的不应该知道其他 Server 的存在。Client 在注入工具描述时把server_name.tool_name作为完整标识调用时再拆分出实际的 Server 和工具名。6.4 安全边界哪些能力不该暴露给模型MCP 让模型可以调用外部能力这带来了便利也带来了风险。不是所有能力都适合暴露给模型。我建议遵循以下原则写操作要谨慎删除数据、发送消息、修改配置这类有副作用的操作要么不暴露要么加上确认机制。敏感数据要过滤工具返回的结果中如果包含密码、密钥、个人隐私信息要在 Server 端过滤掉。权限要最小化Server 执行工具时使用的账号权限应该是最小的只够完成工具功能即可。调用要可审计每次工具调用都要记录日志包括谁调的、调了什么、参数是什么、结果是什么。实操提示在开发阶段可以给工具加上dry run模式只返回将要执行的操作而不实际执行。这样既能测试工具逻辑又不会产生副作用。7. 我对 MCP 落地节奏的一点判断MCP 这个协议本身并不复杂JSON-RPC 加几个方法定义一两天就能把基本流程跑通。真正花时间的是工具的设计和错误处理——怎么把业务能力拆成合适的工具粒度、怎么写让模型能理解的描述、怎么处理各种边界情况。这些工作没有标准答案需要在实践中不断调整。我自己的经验是先从一个最简单的工具开始跑通整个链路然后再逐步增加工具数量和复杂度。不要一上来就设计一个大而全的 Server那样很容易在细节里迷失。另外多观察模型实际调用工具的行为你会发现很多设计时没想到的问题——比如模型可能会用你没想到的参数组合、可能会连续调用同一个工具、可能会忽略你精心设计的错误提示。根据这些观察来迭代你的工具设计比闭门造车有效得多。这个协议还在快速演进中规范版本从最初的草案到现在已经更新了好几轮。建议在实现时把协议版本作为可配置项方便后续升级。同时关注社区的实现和讨论很多坑已经有人踩过了没必要自己再踩一遍。
返回列表