资讯动态

MCP Server从零开发:大模型工具调用统一协议实战

发布时间:2026/10/20 5:09:02 来源:尧图企业网站定制
最近在给公司内部的AI助手接入订单查询能力时我遇到了一个特别典型的场景大模型在聊天窗口里说得头头是道但一让它查真实订单状态它就露馅了——它只能基于训练数据瞎猜根本碰不到我们内部的订单系统。为了让大模型从会聊天变成会办事我第一版方案是在应用里给模型注册了二十多个Python函数覆盖查订单、查库存、算运费、改备注……结果每次升级底层模型函数调用方式一变整层适配代码就得重写一轮维护成本直接失控。后来我转向MCPModel Context Protocol方案把工具调用抽象成统一的协议一个MCP Server写好后任何支持MCP的AI客户端都能直接接入不用再为每个模型单独适配。这篇博文就是我从零开发MCP Server/Tool的完整复盘覆盖协议原理、SDK选型、工具实现、调试排错、生产部署全链路适合正在给大模型接外部工具的AI应用开发者、后端工程师和方案架构师参考。读完你会发现MCP没有想象中那么玄核心就是标准协议 服务进程 工具函数这三件事。1. 大模型接入外部能力的两种做法自造轮子 vs 统一协议1.1 自造Function Calling的隐藏成本很多人第一次给大模型接工具走的都是同一条路在应用代码里写一堆业务函数然后用模型的Function Calling能力把这些函数注册给模型。模型根据用户意图返回一个结构化调用指令应用侧解析后执行对应函数再把结果回填给模型生成最终回复。这条路听起来直接但坑藏在后面。第一每个模型的工具调用格式都不一样有的用JSON Schema描述参数有的用简化格式有的还限制工具描述长度你写完一套适配换模型就得重写。第二工具不只被一个应用使用你有一个聊天机器人、一个文档问答助手、一个客服系统每个应用都要复制同一套工具注册逻辑。第三工具多了之后维护者很难搞清楚每个工具的参数格式是什么文档和代码经常不同步。我后来算了一笔账一个中等规模的工具集40个左右写在业务代码里约三分之一的时间在写工具描述和参数映射真正干活的业务逻辑反而只占一小半。这些代码跟具体模型厂商绑定等于把核心逻辑押注在一个不可迁移的方案上。这个成本在模型快速迭代的当下尤其危险。1.2 MCP的三层角色与三大原语MCP解决的核心问题是把模型调用工具这件事做标准化。你可以把它类比成电脑的USB接口USB定义了设备之间如何通信任何支持USB的键盘、鼠标、U盘插上就能用不需要每换一个外设就改主板电路。MCP就是大模型世界的USB接口。协议里定义了三个角色MCP Host运行大模型的宿主应用比如聊天机器人、IDE插件、智能助手。它是整个调用链路的发起方。MCP ClientHost内部的协议客户端负责建立连接、发送请求、接收响应。一个Host里可以有多个Client对应连接多个Server。MCP Server暴露业务能力的服务进程。它实现了MCP协议规定的接口对外提供工具Tools、资源Resources和提示词模板Prompts三类能力。这里要重点理解的是MCP定义的三大原语原语作用类比Tools可执行的操作模型调用后产生具体动作遥控器上的按键Resources可读取的数据模型不理解也没关系客户端可以展示游戏机的光盘Prompts可复用的提示词模板帮助模型更好地完成任务说明书里的标准话术Tools是MCP里最常用、也是开发时最需要谨慎对待的部分因为一旦模型可以调用工具就意味着模型的行为能影响现实世界——查数据、发请求、改配置。我这次开发的订单查询Server主要就是围绕Tools展开Resources和Prompts在后面章节会专门讲到。1.3 消息格式与一次完整的工具调用链路MCP的通信消息统一走JSON-RPC 2.0格式这一点很多教程会一笔带过但我建议你动手开发前务必理解否则排错时会一头雾水。JSON-RPC 2.0是一种极简的远程调用协议每条请求有id、method、params三个字段响应对应返回result或error。MCP在JSON-RPC之上定义了各种方法名比如initialize是握手、tools/list是枚举工具、tools/call是调用工具。一次完整的工具调用链路是这样的客户端连接Server后先发initialize请求做握手交换协议版本和能力列表。握手完成客户端发notifications/initialized通知表示初始化结束。模型需要工具时客户端向Server发tools/list拿到所有工具的名称、描述、输入Schema。模型根据工具描述决定调用哪个工具并生成参数Json客户端发tools/call给Server。Server执行工具函数返回结构化结果。客户端把结果交给模型模型根据结果生成最终回复。这里面最容易被忽略的是第一步的握手。我见过不少新手直接发tools/list结果收到未初始化的错误提示一脸茫然。MCP的初始化决定了两端能理解的消息格式跳过握手就相当于打电话不说开头语直接聊正事对方根本对不上频道。2. 技术选型与工程准备SDK、Transport、项目骨架2.1 Python还是TypeScriptMCP官方提供了Python和TypeScript两套SDK我两个都试过说下真实体感。Python SDK的特点是FastMCP接口非常省事你用装饰器给函数加一行mcp.tool()这个函数自动变成工具参数类型注解自动生成JSON Schema返回的dict自动序列化成协议要求的格式。如果你主要用Python写业务逻辑这是上手最快的路径。TypeScript SDK则更贴近前端和Node.js生态适合把MCP Server嵌入已有的Node服务里。不过它的工具定义需要显式写参数Schema用zod库描述返回内容要自己包装成content: [{ type: text, text: ... }]这种结构比Python多几步但控制力更强出错时更容易定位到具体字段。我的建议是你团队主语言是什么就选哪个SDKMCP协议是跨语言的没必要为了所谓原生态去引入一个团队不熟悉的技术栈。协议的能力边界是同一个SDK只是减少重复劳动的脚手架。2.2 Transport怎么选stdio还是HTTP服务Transport是指MCP Server和Client之间的数据传输方式目前主流就两类stdio和HTTP。这个选择直接决定了Server跑在哪里、怎么部署最好在写代码之前就定下来。stdioServer作为一个子进程由Client启动双方通过标准输入输出流交换消息。它的优势是本地部署极简单、无网络端口暴露、权限天然隔离适合桌面应用、本地工具链等场景。缺点是无法跨机器Host在哪Server就得在哪。HTTPServer作为一个独立服务通过网络暴露接口。适合多个客户端共享一个Server、Server部署在远端机房的场景。MCP的HTTP传输经历过一次演进早期是HTTP SSE服务器向客户端单向推送事件流后来升级为Streamable HTTP双向通信都走HTTP请求使用体验好了很多如果你要远程暴露MCP能力优先选新的Streamable HTTP。我这次的订单查询Server是给团队内部多个AI应用共用的所以选了HTTP方式部署。开发调试阶段用stdio写完代码再用HTTP方式跑起来两种Transport共用一套工具定义代码这点SDK做得很好切换成本很低。2.3 工程骨架与依赖准备一个最简MCP Server的项目骨架长这样order-server/ ├── server.py # Server入口定义工具和启动逻辑 ├── requirements.txt # 依赖列表 ├── client.py # 调试用客户端脚本可选 └── README.mdPython依赖只需要一个包就可以开始pip install mcp[cli]注意这里我推荐带[cli]后缀因为CLI附带了一些特别好用的开发工具比如后面要讲的调试面板。如果只是当作库用pip install mcp也够但开发期还是建议把CLI一起装上。安装完可以用python -c import mcp; print(mcp.__version__)确认SDK版本。值得一提是MCP的SDK迭代速度非常快不同版本之间API偶有微调你如果看到网上某篇教程的写法跟官方最新文档对不上不要慌多半是版本差异以你安装的版本的官方文档为准。2.4 项目初始化时最容易忽略的版本坑我踩过的第一个坑是同时装了多个Python环境SDK装到了A环境运行脚本时用的却是B环境结果Client启动Server子进程时永远报模块找不到。这个问题在stdio模式下尤其隐蔽因为Server是Client拉起的一个子进程它继承的是Client的环境而不是你终端当前的环境。最后我在启动脚本里显式指定了虚拟环境的Python路径问题才消停。另一个坑是SDK版本对Python版本的要求老版本的Python跑新SDK会报语法错误或依赖冲突。我在公司的一台老服务器上装了Python 3.8拉最新SDK直接装不上。如果你也遇到这种情况要么升级Python要么把SDK版本固定到兼容版本我用的就是最新版在开发环境里最坑人。3. 从零实现一个订单查询ToolSchema、处理器与调用验证3.1 FastMCP和mcp.tool()到底做了什么直接看代码。下面这个完整的Server实现了按订单号查询订单状态的核心工具# server.py from mcp.server.fastmcp import FastMCP mcp FastMCP(order-helper) mcp.tool() def query_order(order_id: str) - dict: 按订单号查询订单当前状态 if not order_id.isdigit(): raise ValueError(order_id必须为纯数字) # 这里是模拟的业务逻辑真正项目中会调用内部服务 return { order_id: order_id, status: shipped, progress: 60, estimated_delivery: 2026-01-20 } if __name__ __main__: mcp.run(transportstdio)这段代码不到二十行但它背后自动完成了三件大事。第一mcp.tool()把query_order函数注册为一个MCP Tool函数名就是工具名实际开发中这样直接用函数名通常会配合显式命名比如mcp.tool(namequery_order)便于规范管理工具名。第二函数的参数类型注解str自动转换成了JSON Schema格式工具描述直接从函数的docstring提取。第三函数返回的dict自动被包装成协议规定的标准结果结构客户端拿到的就是合法响应不需要我们手拼消息。这个设计真的省事但省事的代价是你最好理解背后机制否则出问题时会无从下手。最简单的验证方法是在Server启动后用客户端查一下list_tools你会看到SDK自动生成了一份类似这样的Schema{ name: query_order, description: 按订单号查询订单当前状态, inputSchema: { type: object, properties: { order_id: { type: string } }, required: [order_id] } }模型就是根据这份Schema来决定怎么调你的工具的。所以工具描述写得好不好直接影响模型能不能正确使用它。描述要说明这个工具是做什么的、什么场景下用、有哪些注意事项不要写废话。比如查询订单状态就不如按订单号查询订单当前状态适合用户在询问物流或订单进度时调用来得明确。3.2 输入Schema设计的科学性参数越少越好工具设计的第一原则是参数能少就少参数含义能清晰就清晰。大模型生成参数的准确率跟参数数量、命名是否直观强相关。一个需要七八个参数、每个参数还带嵌套结构的工具模型犯错率会直线上升而且你很难从返回结果里判断是模型没理解还是工具本身报错。举个例子你要做一个查询工具与其搞一个接收复杂过滤条件的通用接口不如拆成query_order_by_id(order_id)和query_orders_by_phone(phone)两个独立工具每个工具参数简单、职责单一。模型只需要判断用户要的是单笔订单还是某手机号的所有订单然后填一个参数就行简单不容易错。参数的类型也要慎用。字符串参数如果格式有要求比如纯数字最好在函数入口做校验就像上面代码里isdigit()那个检查。因为模型可能会脑补出千分位逗号或者把订单122中间的汉字也传进来这些情况都要在函数里拦截并返回明确错误信息而不是带着脏数据往下走。3.3 执行逻辑中的错误处理与超时控制工具函数不可避免会遇到业务异常。MCP协议里有个特殊字段叫isError你返回的响应如果标记了isError: true客户端会把结果视作执行失败模型就能根据错误信息调整后续动作。如果用FastMCP抛出的异常会被框架捕获并包装成错误响应所以一个原则是业务判断中遇到不符合预期的参数直接抛异常不要返回一个假成功的结果。一个很经典的假成功例子是订单不存在时工具返回了一个包含status: not_found的dict客户端以为调用成功了模型也基于这个结果一本正经地回答用户。但实际上这个成功了却没有数据的响应在很多客户端里不会触发重新提问流程。正确做法是订单不存在就抛出明确的异常比如raise ValueError(订单不存在)让模型感知到调用失败转而去问用户是不是单号写错了。超时控制同样关键。如果工具里要调内部HTTP接口一定要给调用设置合理的超时时间最好比SDK的连接超时短一个量级。否则当内部服务卡住时你的MCP Server也会一直吊着连接的客户端不知道该继续等还是放弃用户侧的体感就是AI一直转圈没反应。我用的是200ms内部超时实测下来够用你按自己业务接口的典型延迟来调整。3.4 客户端连接与调用验证Server写好了得写个客户端调一遍验证整个过程通不通。下面这个客户端脚本是我调试时的标配留着以后排查问题非常有用# client.py import asyncio 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]) result await session.call_tool(query_order, {order_id: 20260001}) print(调用结果:, result) asyncio.run(main())跑一下终端里应该能看到工具列表和调用结果。如果这一步能走通说明你的MCP Server最小闭环已经成立接下来才是接入真实AI客户端。这一步常见的错误包括握手时协议版本不一致导致初始化失败、Server端口被占用HTTP模式、命令路径配置错误等。我建议在继续往下写之前一定要先把Server 脚本客户端这一条链路跑通这是整个项目的测试地基地基不稳后面全白搭。3.5 工具返回值的协议要求别被SDK惯坏用FastMCP时你直接return一个dict就行SDK自动搞定包装。但如果你用TypeScript SDK或者哪天需要自定义输出格式就必须知道MCP协议对工具响应的要求。协议规定tools/call的响应必须是一个CallToolResult对象里面有个content数组数组元素的常见类型包括text文本、image图片、resource资源链接。所以不要试图在工具里返回一个裸的字符串或列表然后期望客户端能理解。你要么依赖SDK自动包装要么自己按协议结构拼装。这里我特意强调是因为我看到过有人用TypeScript SDK时直接返回字符串结果客户端收到的结构不对模型完全看不懂。接口的入参和返回值本质上都是你与客户端之间的合同合同格式写错了内容再好也没人认。4. 调试与排错Inspector实战与三个高频翻车点4.1 用官方调试面板做交互式验证MCP SDK装好的CLI里自带一个调试工具运行起来会在本地起一个网页调试面板mcp dev server.py面板打开后你可以看到Server暴露的全部工具手动填写参数发起调用查看详细的协议请求和响应消息。这比脚本客户端直观得多适合快速验证单个工具的行为边界空参数会怎样异常参数会怎样返回结构是什么样面板里看得一清二楚。这个调试面板是我在整个开发过程中用得最多的工具。尤其是在协议消息展示上它可以展开每次tools/call的完整请求和响应一眼就能看出消息格式是否合法。我建议你在开发每个新工具后都手动跑一遍正常入参、异常入参、缺失入参三种情况确保工具行为符合预期再去接复杂的AI客户端。4.2 翻车点一命名不规范导致模型找不到工具我的第一个生产级翻车发生在给工具起名的阶段。当时图省事把函数定义成query_order_by_phone_and_date_range这种超长名字对应的工具名也一长串。结果实测模型在需要查询手机号订单时经常去调query_order这个工具把手机号当成order_id传进去然后收了一堆报错。原因不难理解模型是依据工具名称加描述做选择的工具名之间的语义重叠度太高描述又没说清楚边界它自然容易选错。我的调整方案是工具名做到动词对象能一眼看出用途描述里明确写上该工具适合XX场景不适合YY场景参数名用语义明确的全称不用id这种看着就知道容易歧义的缩写。改完之后调用准确率明显提升。这里补一句工具名一旦上线大概率会被你整个团队引用到各种配置里改名是个牵连甚广的事情。所以命名阶段多琢磨两分钟后面省三个小时。4.3 翻车点二返回结构不符合协议第二次翻车是在引入第三方HTTP接口后。内部接口返回的数据里包含一个嵌套结构我图省事直接在工具函数里返回了那个嵌套对象本身没有做任何转换。结果客户端在处理响应时因为对象结构不符合预期直接把结果标记为解析失败模型拿到的是一堆乱码文本。排查过程比预想的费劲因为问题不在调用链路而在数据格式。最后我是在调试面板里看原始响应消息才发现的SDK虽然能帮你把dict序列化但它不强校验你dict里的内容结构是否符合客户端的期望。客户端期望的要么是一个text类型的字符串要么是结构化字段可我的嵌套对象既不完全是文本也不符合客户端的结构化解析规则。从那以后我每个工具返回前都明确做一步把业务数据统一转成业务状态码 人类可读文本 结构化数据字段三件套。比如订单查询成功时返回{status: success, message: 订单已发货, data: {...}}所有客户端解析起来都不费劲。4.4 翻车点三日志和进程生命周期管理stdio模式下Server是被客户端拉起的子进程如果你的Server代码里有print()调试输出它会打到标准输出——而标准输出正是和Client通信的通道这一下就会让协议消息混进垃圾信息客户端解析直接崩掉。我第一次干这傻事时对着报错找了半小时最后才意识到是那行print(debug)惹的祸。正确做法是所有调试输出走标准错误sys.stderr或logging库标准输出保持纯净只跑协议消息。养成这个习惯后你会少掉很多头发。日志建议使用Python的logging模块把级别调到DEBUG开发时可以看到MCP框架内部打印的每次消息收发排查问题能少走一半弯路。4.5 日志策略既要看得见又不能干扰协议我现在的日志策略是分层管理框架日志打到控制台开发时业务日志写到独立文件运行时工具调用日志记录到结构化存储生产时。工具调用日志尤其重要因为模型会用工具这一机制去执行真实操作每一次调用都该留下可审计的轨迹谁在什么会话里调了哪个工具、入参是什么、结果是什么、耗时多少。开发阶段我还习惯在工具函数开头和结尾各留一行log入参快照一行出参摘要一行。这样排查问题时只翻日志就能还原整个调用过程不用再去猜模型当时到底传了什么参数进来。5. 把Server做得更完整Resources、Prompts、状态与权限5.1 Resources让模型可以读取结构化数据工具解决的是执行操作的需求模型还有一类需求是读取上下文数据。比如订单查询Server里如果模型想了解最新的促销规则这不需要调用什么操作只需要读取一份配置数据这时候就该用Resources。MCP的Resources很像一个只读的文件系统每个资源有一个URI。比如mcp.resource(order://promotion/rules) def get_promotion_rules() - str: return 当前活动满300减30每个用户限参与一次客户端在对话中如果遇到需要促销规则才能回答的问题会通过resources/read读取这个URI对应的内容把它拼接进上下文模型就能基于真实数据作答。Resources最适合放那些知道内容就能帮助理解对话的数据比如业务术语表、产品说明、常用FAQ。我遇到的一个典型用法是给客服对话机器人挂一个售后政策资源模型回答售后问题时不用瞎编政策内容了。不过要注意Resources读取出来的内容会直接进入对话上下文是有token成本的。不要什么数据都往里塞只挂那些模型高频需要、且单独放在提示词里又太占地方的内容。5.2 Prompts复用标准会话模板Prompts原语是用来定义标准提示词模板的。举个例子你的订单查询Server面向不同AI客户端但每条订单都需要统一语气的播报方式。你可以在Server里定义一个prompt模板order_status_report要求模型按固定结构输出订单状态包含订单号、发货状态、预计送达三个模块。然后客户端在会话开始时调用prompts/get获取这个模板。这是一个被很多教程忽略的能力。实际使用中Prompts解决的最大痛点是同一个Server被多个客户端使用时工具使用方式的一致性。比如A客户端配置了详细的工具使用规则B客户端没配两边模型表现差异极大。把这些共同规则沉淀到Server的Prompts里每个客户端都能拉取到模型行为统一了prompt工程成本也降下来了。5.3 多客户端会话状态怎么设计MCP协议本身偏无状态每次tools/call都是一次独立的请求。但实际业务中会话状态几乎不可避免用户上下文里的订单编号、登录用户身份、分页游标都需要跨工具维持。我的做法是在工具参数里显式传递上下文ID。什么意思呢就是Server不做全局状态存储而是在工具调用时要求客户端带上session_id会话标识Server根据这个ID去Redis或数据库里取对应的业务上下文。这样写的好处是保持工具的无状态特性多个客户端连同一个Server也不会互相污染数据扩容时甚至可以把不同请求路由到不同Server实例。这里有个设计教训不要在MCP Server进程内用全局变量或内存dict存会话状态。一旦进程重启所有上下文全部丢失用户就得重新开始多个Server实例时还会出现你连实例A拿到状态A下一次负载均衡连到实例B却查不到状态的情况。会话状态一定要落在外部存储里。5.4 权限边界与操作审计当模型有能力调用工具时安全边界的优先级要排在功能前面。我给企业内部MCP Server定的安全原则是最小暴露、显式授权、全程审计工具只暴露具体而单一的操作不提供万能执行的接口。比如有query_order就不应该再有execute_sql这种让模型直接操作数据库的入口。高危操作必须显式授权。像修改订单备注取消订单这类写操作要么在Server侧配置白名单只允许特定来源的调用要么客户端在模型请求这类工具时弹出人工确认界面让用户点确认后才真正执行。所有工具的调用入参、返回结果、耗时、调用方标识全部记录到日志存储。出事时能精确还原每一次模型造成的操作。我见过一个翻大车的例子有团队把删除用户做成MCP工具暴露给模型模型在对话中因为理解偏差直接执行了删除操作事后追责都不知道哪个会话触发的。这种问题不在于模型坏而在于工具设计时缺少边界意识。危险动作要么别暴露要么在调用链路上加一道人工闸门。6. 生产部署与版本演进从本地脚本到可用的服务6.1 本地进程怎么守护远程服务怎么配置如果你的Server走stdio模式部署时要注意它不是常驻服务而是由客户端动态拉起的。这时候守护主要靠客户端侧的配置正确命令路径、参数、环境变量要一清二楚否则客户端启动Server时容易因环境不一致而失败。我习惯把启动命令写成一个shell脚本把虚拟环境激活和Server入口都包进去客户端直接调脚本而不是让客户端裸调python命令这样可以规避环境PATH不一致的问题。如果是HTTP模式的远程服务部署形态就比较传统用进程管理器守护指定监听端口配置好反向代理和负载均衡。需要注意的是MCP的Streamable HTTP服务要能正确处理POST请求和长连接不要在前面放一个缓存层否则会把协议消息缓存出事故。宁可不要让缓存层碰POST接口。6.2 超时、并发与资源保护MCP Server是给AI模型调用的这意味着流量模式跟传统后端API很不一样请求往往是突发性的模型可能在某个上下文里连续快速调用多个工具也可能长时间空闲。部署时最好给每个工具设置独立的超时时间防止一个慢工具拖垮整个Server进程。并发控制也值得提前规划。我的经验是给工具执行层加一个并发限制比如同一时刻最多处理20个工具调用超过的排队等待而不是无限吞请求。这能防止模型在某次多工具并行场景下把Server打崩。如果业务负载高直接把Server无状态化部署成多副本配合负载均衡不分发同会话的请求到多实例也能保持状态一致。6.3 监控与可观测性MCP Server也要有指标MCP Server本质上是个后端服务监控不可或缺。我维护的最小监控清单有三项请求量每工具调用次数、错误率工具执行异常的比例、时延分布工具调用的耗时分位数。把这些指标暴露出去接到监控面板里工具出问题不用等用户投诉看图表就能发现。在日志层面除了前面说的工具调用日志还要记录每次握手的协议版本和客户端标识。这能帮你在升级协议版本时快速判断环境中还跑着哪些老客户端避免升级后一堆老客户端断连找不到原因。6.4 协议版本升级别让Server和客户端各说各话MCP目前还处在快速演进阶段协议版本会持续更新。在部署时有个容易踩的坑客户端和服务端的SDK版本差太多握手时协议版本对不上连不上。处理办法是Server侧在握手时做好版本协商如果发现客户端版本过旧返回明确的错误信息让客户端能理解是版本问题而不是服务挂了。我实践下来比较稳妥的做法是Server的SDK版本保持与服务端操作系统兼容的最近稳定版客户端SDK尽量跟随官方更新不要长期停在老版本上不升不动。大版本升级前先在一个测试环境里同时起旧的客户端和新的Server跑一遍核心链路确认兼容再上生产。最后一个建议MCP Server不是写一次就完事的静态代码它是AI应用运行时的毛细血管。我把工具列表变更记录到一个专门的变更日志里每次加工具、改参数、调描述都同步更新文档。这份文档我一开始还觉得多余后来团队里另一个人接手维护时多亏了它才能快速定位到一个工具行为变化导致模型行为漂移的case。工具是给模型用的接口模型的体验和人用API的体验一样文档与代码一致才是工程化该有的样子。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑