资讯动态

手写MCP协议:不依赖SDK构建AI工具接入层

发布时间:2026/10/19 7:21:26 来源:尧图企业网站定制
MCP这三个字母最近在搞AI应用开发的人圈子里出现频率越来越高。它全称是Model Context Protocol说白了就是给大模型和外部数据、工具之间定了一套标准化的对接规则让模型不再只停留在对话框里而是可以像调用函数一样去读文件、查数据库、操作业务系统。团队内部正好要做一套面向业务场景的工具接入层我们没有直接套官方SDK而是从最底层的JSON-RPC消息开始手写了一个完整的MCP服务器和客户端。这篇文章就是这次实操的完整复盘。我不打算泛泛讲概念而是把协议拆开、把代码贴出来、把踩过的坑和排查思路都交代清楚。如果你懂一点编程想真正理解MCP而不只是调用封装好的包这篇文章应该能帮你省下不少摸索时间。1. 为什么我决定不装SDK直接手搓MCP协议现在接入MCP的门槛其实很低官方和社区都提供了各种语言的SDK几步就能连上一个现成的服务器。但恰恰是这种“太方便”让我产生了警惕如果只停留在调用封装好的函数出了问题根本不知道去哪一层排查。这次我们选择从零手写先搞清楚三个问题MCP解决的工程痛点到底是什么套SDK的隐形成本在哪里以及手搓一遍之后能沉淀下什么。1.1 MCP到底是什么它解决了AI工程化里的什么问题先退一步看背景。过去两年做AI应用最常见的一个尴尬局面是模型很聪明但它够不到你的业务数据。它不知道你们系统里有哪些订单、不知道你的内部API怎么调用、更没法直接操作你的代码仓库和数据库。为了解决这个问题大家各自造轮子给模型接函数、写插件、配置各种工具。结果就是A项目里写的工具接入方式搬到B项目完全复用不了因为你得知道对方的调用规范、鉴权方式、参数格式每一家都不一样。MCP干的事情是把“模型和外部世界对话的方式”标准化了。模型端是MCP客户端工具端是MCP服务器两边通过一套统一的协议沟通。你只要把能力封装成MCP服务器任何支持MCP的客户端都能直接发现并调用这些工具。打个比方以前每家餐厅都有自己独特的点菜暗号MCP就是一本统一的菜单和点菜流程服务员和后厨都按同一套规则来协作成本立刻降下来了。这次我们实操的目标很明确实现一个真实的MCP服务器提供两个工具然后写一个客户端程序去发现和调用它们。整个过程不引入MCP相关SDK只用标准库完成通信、编解码和消息循环。1.2 直接套SDK的隐形成本不是说SDK不好恰恰相反生产环境里我建议用官方SDK稳定省事。但“用SDK”和“理解SDK”是两码事。业内几位朋友交流下来大家反馈最多的三个问题第一SDK封装层次多一旦线上出现协议层交互异常日志里全是封装内部的方法调用很难快速定位是消息格式问题还是工具业务逻辑问题第二很多封装限制了你对传输层的自定义比如某些环境要改握手参数、要支持特殊的中转协议框架不一定给你开口子第三团队新人对协议本身没有概念只能照葫芦画瓢写代码换一种语言或换一套运行时之前积累直接失效。这里插一段我们后来在别的环境里遇到的实际案例某个项目用某封装库接入一个MCP服务器发现偶尔出现工具调用超时查了很久最后定位到是客户端和服务端对protocolVersion的协商逻辑不一致某库在收到比自己版本更新的协议声明时选择了静默降级导致后续消息里某个字段没有被正确解析。如果你对协议细节没有概念这种问题基本只能靠猜。所以我一直认为想真正用熟一个东西得先把它的底层机制亲手跑通一遍。手搓MCP不是目的是一次“解黑盒”的过程。1.3 手搓一遍后你会得到什么手写一遍MCP之后你会得到三层收获。第一你真正理解了MCP在网络上发送的报文长什么样以后看任何SDK的调试日志都能迅速定位问题第二你能根据业务需要自由定制传输和消息处理逻辑不再被框架绑死第三你有了一个最小实现作为骨架之后接任何MCP服务器或写任何MCP工具都能快速评估工作量。还有一点容易被忽略动手写代码的过程会逼你把协议规范读一遍。比如我只知道“MCP有工具列表和工具调用”是不够的你还得搞清楚 initialize 的握手时序、id 的匹配规则、通知和请求的区别、错误码的设计。这些细节才是工程化落地的关键。2. 动手前必懂MCP的协议骨架在手写代码之前先把协议层面的几个核心机制说明白。这段内容偏概念但非常重要因为后面所有的代码都是在落实这些规则。2.1 JSON-RPC 2.0MCP的语言基础MCP的通信格式建立在JSON-RPC 2.0之上。这是个很轻量的远程调用协议核心就三样东西请求、响应、通知。请求必须带三个字段jsonrpc: 2.0、method、id。id用来让请求和响应一一对应客户端发一个id为1的请求服务端返回的响应里也要带id为1这样双方在多条消息并发交错时也能准确对上号。响应则有两种格式正常结果放在result字段里出错时放在error字段里error包含code和message。通知和请求的区别在于通知没有id发送方发出去就完了不期望对方回复。这在MCP里很常用比如握手完成后的notifications/initialized就是一个典型通知。你不需要回复它也不需要为它分配请求id。错误码方面有一套约定-32700 是解析错误-32600 是无效请求-32601 是方法不存在-32602 是参数无效-32603 是内部错误。做排查时看到这些码基本就知道是哪一层的问题了。理解了这个基础再去看MCP官方文档你会发现它就是在这个语言基础上定义了一组标准的method。2.2 会话生命周期initialize握手与 notifications/initializedMCP的两个角色是客户端通常是AI应用、IDE插件和服务器提供工具的一方。它们之间的对话不是上来就调工具的有一个固定的握手流程。第一步客户端发一条initialize请求告诉服务器自己支持的协议版本、客户端能力capabilities、客户端标识clientInfo。服务器收到后返回自己支持的协议版本、服务器能力、服务器标识。所谓能力协商就是双方互相交代“我能做什么”比如客户端是否支持采样、服务器是否支持工具、是否支持资源订阅等。第二步客户端收到initialize响应后需要发送一条notifications/initialized通知告诉服务器“握手阶段结束接下来可以正常发业务消息了”。注意这是一个约定很多服务器如果不是很严格你跳过它也能正常用但标准的客户端实现都会发这条通知。第三步进入正常业务阶段双方可以发送tools/list、tools/call、resources/list、prompts/list等请求。这个时序很重要。我在实际操作中见过有人把initialize和notifications/initialized一起打包在子进程启动后的极短时间内发送虽然大多数服务器处理没问题但严格来说应该等待initialize响应后再通知。这里建议做一个简单的状态机或者至少保证顺序。2.3 tools、resources、prompts 三大原语MCP服务器可以向客户端暴露三种能力工具、资源、提示词。很多人刚接触时会把这三个概念搞混这里用最简单的方式区分一下。工具是“动词”是模型可以调用的函数类似函数调用Function Calling常见的有查天气、算加法、提交订单。工具的调用有输入参数参数用JSON Schema描述返回值通常是文本内容。这次实操的核心就是工具。资源是“名词”是可供读取的数据对象比如一个配置文件、一份文档、一张数据库表的内容。资源的组织形式类似文件路径例如file:///logs/app.log客户端可以通过resources/read读取。提示词是一组预设的对话模板或指令客户端可以获取并推荐给用户使用。实际工程里工具最常用资源和提示词则根据业务需要来暴露。设计MCP服务器时要想清楚你这个能力是适合做成工具让模型“调用”还是做成资源供模型“读取”。这个差别会直接影响使用方接入的体验。2.4 两种主流传输方式stdio与Streamable HTTPMCP的传输层目前主要分两类本地 stdio 和远程HTTP。本地 stdio 是最简单的方式也是我这个实操用的方式。客户端启动一个子进程跑MCP服务器代码通过标准输入往子进程写JSON消息子进程通过标准输出把响应写回来。服务端的所有日志、调试信息不允许打到标准输出否则会污染协议通道必须打到标准错误stderr。每条消息必须是完整的一行JSON接收方按行读取解析。HTTP方式则适合远程场景。早期的做法是客户端先向服务端发HTTP请求建立SSE连接服务端通过SSE事件流推送消息客户端通过POST发送请求回包。现在官方演进到 Streamable HTTP支持双向消息兼容普通POST响应和SSE流。这种传输方式的好处是可以跨网络、跨语言、跨进程适合把内部能力开放给多个AI应用复用。工程上怎么选如果工具只是给本机某个AI应用用的用 stdio 最简单启动快、免端口、权限好控制。如果要做成服务给多个客户端共用就要走HTTP。我们公司目前两种都保留本地调试和后端场景分开。2.5 一次tools/call的完整报文是什么样在没有跑代码之前先把一次完整调用的报文在脑子里过一遍后续代码就好理解了。客户端发tools/list服务器返回工具列表每个工具在 JSON 里长这样{ name: sum_numbers, description: 计算两个数字的和, inputSchema: { type: object, properties: { a: { type: number, description: 第一个加数 }, b: { type: number, description: 第二个加数 } }, required: [a, b] } }客户端拿到这个列表后由模型根据用户问题和工具描述决定是否调用某个工具。调用时发tools/call请求体里带上工具名和参数。服务器执行完返回结果结果里可以带多段内容常见是text类型也可以带image。如果工具执行失败返回的result里要加isError: true这样客户端能区分是正常结果还是错误结果。{ jsonrpc: 2.0, id: 3, result: { content: [ { type: text, text: 3 5 8 } ], isError: false } }注意这里的isError和 JSON-RPC 层的error是两回事。JSON-RPC 层的 error 表示协议层面的错误比如方法不存在、参数格式不对isError表示工具业务层面的执行失败比如“文件不存在”“网络请求超时”。两者不要混淆。3. 核心代码实操一个可运行的MCP Server Client理论部分到这就够了直接上代码。这一节会带你写一个完整的、不依赖任何MCP SDK的服务器和客户端全部只使用Python标准库。整个实现大概两百行跑通之后你再看MCP的协议文档会非常通透。3.1 最小工程结构mcp-demo/ ├── mcp_server.py └── mcp_client.py就两个文件。服务器是协议的另一端提供两个工具一个计算加法一个列出白名单目录下的文件。客户端启动服务器子进程完成握手、工具发现、工具调用三个步骤把每一条收发报文都打印出来。先强调一个核心原则发送消息后必须立刻flush()尤其是在textTrue的管道通信模式下如果不 flush接收方会在管道里干等消息一直发不出去。3.2 先写协议层读一行、写一行stdio 传输的本质非常简单客户端往子进程的标准输入写一行JSON服务器从标准输入读一行JSON服务器把响应写到标准输出客户端从标准输出读一行。所以协议层只需要两个函数。# mcp_server.py 开头部分 import json import sys import os from datetime import datetime from pathlib import Path def read_message(): line sys.stdin.readline() if not line: return None return json.loads(line) def write_message(payload): sys.stdout.write(json.dumps(payload) \n) sys.stdout.flush()read_message用readline()按行读取读到空字符串说明标准输入关闭也就是客户端退出了返回None主循环据此退出。write_message把字典序列化成一行JSON写到标准输出立刻刷新缓冲区。这两个函数是后面所有逻辑的地基。3.3 服务器端initialize、tools/list、tools/call全部手写服务器的核心是一个消息分发循环。先定义工具列表和两个业务函数然后在一个while True循环里不断读消息、按method分发处理。# mcp_server.py 继续 TOOLS [ { name: sum_numbers, description: 计算两个数字的和, inputSchema: { type: object, properties: { a: {type: number, description: 第一个加数}, b: {type: number, description: 第二个加数} }, required: [a, b] } }, { name: list_files, description: 列出白名单目录下的文件名, inputSchema: { type: object, properties: { dir: { type: string, enum: [data, logs], description: 目录名称只能是 data 或 logs } }, required: [dir] } } ] BASE_DIR Path.home() / .mcp-demo ALLOWED_DIRS { data: BASE_DIR / data, logs: BASE_DIR / logs }业务函数负责执行工具并构造返回值。这里list_files故意设计成只能访问白名单目录enum限定参数取值从根源上避免路径穿越问题。你在fix_dir的时候不要相信任何来自模型的任意字符串模型生成的参数不受绝对信任安全校验必须做在工具内部。# mcp_server.py 继续 def handle_tool_call(name, arguments): if name sum_numbers: a arguments.get(a, 0) b arguments.get(b, 0) text f{a} {b} {a b} return {content: [{type: text, text: text}], isError: False} if name list_files: key arguments.get(dir, ) target_dir ALLOWED_DIRS.get(key) if target_dir is None: return { content: [{type: text, text: f目录不在白名单: {key}}], isError: True } target_dir.mkdir(parentsTrue, exist_okTrue) files [p.name for p in target_dir.iterdir()] text \n.join(files) if files else (空目录) return {content: [{type: text, text: text}], isError: False} return { content: [{type: text, text: f未知工具: {name}}], isError: True }主循环处理四种消息initialize、notifications/initialized、tools/list、tools/call另外加上ping和兜底的未知方法错误。initialize响应里要带上协议版本、能力和服务器信息。# mcp_server.py 主循环 def main(): while True: msg read_message() if msg is None: break method msg.get(method) msg_id msg.get(id) if method initialize: write_message({ jsonrpc: 2.0, id: msg_id, result: { protocolVersion: 2025-06-18, capabilities: {tools: {}}, serverInfo: {name: demo-mcp-server, version: 1.0.0} } }) elif method notifications/initialized: # 通知没有 id不需要响应只需记录状态 pass elif method tools/list: write_message({ jsonrpc: 2.0, id: msg_id, result: {tools: TOOLS} }) elif method tools/call: params msg.get(params, {}) name params.get(name) arguments params.get(arguments, {}) result handle_tool_call(name, arguments) write_message({ jsonrpc: 2.0, id: msg_id, result: result }) elif method ping: write_message({jsonrpc: 2.0, id: msg_id, result: {}}) else: write_message({ jsonrpc: 2.0, id: msg_id, error: {code: -32601, message: fMethod not found: {method}} }) if __name__ __main__: main()这段代码里有个容易被新手忽略的地方notifications/initialized不需要回复。如果你给一个通知也回了一个带id的响应客户端那边可能没有任何问题但严格来讲已经违背了协议约定。协议规范里通知就是单方向的不要画蛇添足。3.4 客户端起子进程、发JSON-RPC、收响应客户端这边用subprocess.Popen启动服务器子进程把stdin、stdout设置成管道然后封装 send 和 recv 两个函数。这个流程比服务器端更简单核心就是“发什么、收什么”。# mcp_client.py import json import subprocess import sys server_process subprocess.Popen( [sys.executable, -u, mcp_server.py], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue, encodingutf-8 ) def send(payload): server_process.stdin.write(json.dumps(payload) \n) server_process.stdin.flush() def recv(): line server_process.stdout.readline() if not line: return None return json.loads(line)解释几个参数-u表示无缓冲保证子进程的标准输出能立刻被父进程读到textTrue让管道以文本模式读写省得自己编解码字节encodingutf-8主要是为了在Windows上避免默认编码不一致导致的中文乱码问题。stderrsubprocess.PIPE把子进程的调试输出单独接到管道里不混到协议通道。接下来就是标准流程初始化握手、发通知、工具发现、工具调用。为了看得清楚我把每一条收发报文都打印出来。# mcp_client.py 继续 # 1. 初始化握手 send({ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-06-18, capabilities: {}, clientInfo: {name: demo-client, version: 0.1.0} } }) resp recv() print(initialize 响应:, json.dumps(resp, ensure_asciiFalse, indent2)) # 2. 发送 initialized 通知 send({jsonrpc: 2.0, method: notifications/initialized, params: {}}) # 3. 工具发现 send({jsonrpc: 2.0, id: 2, method: tools/list, params: {}}) resp recv() print(tools/list 响应:, json.dumps(resp, ensure_asciiFalse, indent2)) # 4. 调用 sum_numbers send({ jsonrpc: 2.0, id: 3, method: tools/call, params: {name: sum_numbers, arguments: {a: 3, b: 5}} }) resp recv() print(tools/call 响应:, json.dumps(resp, ensure_asciiFalse, indent2)) # 5. 调用 list_files测试白名单目录 send({ jsonrpc: 2.0, id: 4, method: tools/call, params: {name: list_files, arguments: {dir: data}} }) resp recv() print(tools/call 响应:, json.dumps(resp, ensure_asciiFalse, indent2)) server_process.terminate()你可能注意到我故意让tools/list和tools/call的 id 不连续一个1一个2一个3一个4但这不是硬性要求只要保证同一时刻未响应的请求id不重复就行。如果某个请求已经收到响应了之后id可以复用。实际工程里一般用一个递增计数器。3.5 运行结果与报文全记录所有代码就位后直接在终端运行客户端python -u mcp_client.py如果一切正常你会看到类似下面的报文记录。这里我把核心部分贴一下方便没跑过的人有个直观印象。第一条是initialize的响应。protocolVersion回显了客户端声明的版本capabilities.tools表示服务器支持工具能力serverInfo是服务器自我介绍{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2025-06-18, capabilities: { tools: {} }, serverInfo: { name: demo-mcp-server, version: 1.0.0 } } }然后是tools/list响应返回工具列表。这一步返回的inputSchema非常关键模型端会拿这个描述来决定如何调用工具、怎么补参数。这就是为什么工具的description要写清楚、参数要写清楚的原因直接决定模型调用的准确性。再往下是tools/call的响应。第一次调用加法返回了3 5 8。第二次调用列目录因为目录刚创建还是空的返回了(空目录)isError是 false。跑通之后你就完整地看到了一次MCP交互的来龙去脉握手、通知、发现工具、调用工具每一步都在读和写JSON。简单来说MCP并不神秘它就是一套约定了方法名和消息格式的JSON-RPC应用。4. 从Demo到工程化接入、配置与安全边界手写代码跑通之后接下来是把它作为一个真正的组件用起来。MCP从技术验证走向生产环境还有几个绕不开的问题客户端配置、分发部署、安全边界和可观测性。这一节我结合实战经验展开讲。4.1 客户端配置文件与注册表一个MCP服务器写好了怎么让客户端知道它主流做法是通过一个统一格式的配置文件。很多MCP客户端包括几个主流的IDE插件和Agent框架都支持类似下面的注册形式{ mcpServers: { demo-server: { command: python, args: [-u, /path/to/mcp_server.py], env: {} } } }字段含义很直白command是启动命令args是参数env是启动时注入的环境变量。这个配置本质上就是“用这个命令拉起一个子进程然后通过 stdio 跟它通信”。用这个思路写客户端你也完全可以自定义一套配置加载器让你的服务端程序能够被任何MCP客户端接入。如果你的MCP服务器不是本地脚本而是发布到某个包管理平台比如Python的包或Node的包那么command就可以是相应平台的执行命令args里带上服务器入口参数。工程上还有两个细节值得注意一是启动MCP服务器时客户端通常会注入一组环境变量比如MCP_SERVER_ID里面包含会话标识和调试开关服务器代码里可以主动读取这些变量便于在复杂环境里区分不同会话二是配置文件路径和权限管理不要把包含访问凭据的配置写进版本库建议通过环境变量注入敏感信息。4.2 在自有AI应用里以工具层方式接入除了直接用现成的IDE客户端你可能更关心一个问题我自己的AI应用怎么接上这个工具箱思路其实是一样的你的应用就是一个MCP客户端需要实现三个环节加载服务器配置、启动服务器进程、按协议完成握手和工具调用。如果是单机应用直接用subprocess或类似机制启动就行。如果是Web服务而且需要承接多个用户请求就要考虑把MCP服务器进程池化。因为每次工具调用都新起一个子进程的成本太高但一个长驻进程又涉及并发和会话隔离问题。我们的做法是做一个轻量的进程管理器每个MCP服务器实例只服务一个逻辑会话会话结束就回收进程超时未响应就强制清理。这样既保留了stdio的简单性又规避了并发写管道相互干扰的问题。另外要注意当你的应用本身就是MCP客户端时工具调用策略一般不由你写死而是由大模型根据工具列表和用户输入动态决定。这里就引申出一个经验不要给模型太多工具工具越多选择越分散调用准确率越低。比较好的做法是按场景把工具分组每个会话只暴露当前场景需要的工具组。比如“代码审查”会话只挂代码相关工具“数据分析”会话只挂数据工具。4.3 安全边界最小权限、确认机制、路径校验MCP服务器一旦接入AI应用意味着大模型可以按需调用工具。如果工具是“查天气”这种只读能力风险不大但如果涉及文件读取、命令执行、数据修改安全边界必须提前设计好。我总结了三条原则。第一最小权限。整个系统只暴露当前业务必需的工具多余的一律不挂。工具的inputSchema要尽量收紧参数范围能用枚举就不放开自由输入。比如刚才的dir参数限定在data和logs两个值模型就无法传入任意路径。第二多级确认。对于有副作用的工具比如删除文件、提交订单、发送消息客户端侧应该做“二次确认”或者“权限分层”。可以在中间层做拦截比如普通会话调用只读工具直接放行调用写操作则要求用户显式确认后再放行。这个机制可以在MCP客户端实现也可以在工具内部实现。第三路径和命令校验。所有涉及文件路径的工具内部必须做规范化校验防止路径穿越。我通常会这样处理拿到参数路径后用Path.resolve()得到绝对路径然后检查它是否在允许的基目录之内不在就直接返回错误。千万不要直接拿模型生成的路径去操作文件系统要当作不可信输入处理。还有一点容易被忽略MCP服务器本身不应该持有任何长期凭据。如果某个工具需要访问内部API建议在服务器启动时由客户端注入短期令牌而不是在工具代码里写死密钥。这样密钥轮换、权限回收都方便。4.4 可观测性与错误处理设计生产环境里MCP服务器是另一个独立的进程它到底在干什么、调用了什么工具、结果如何你都需要能看到。我建议至少做三件事第一把所有调试日志输出到标准错误stderr这和协议通道隔离是硬性要求。日志内容包括收到的每条请求的 id 和 method、工具执行耗时、错误堆栈。客户端进程则把子进程的 stderr 收集起来按会话关联到日志中心。第二为工具调用增加耗时统计和结果快照。每个工具的调用入口和出口打一个埋点记录工具名、参数摘要、返回的文本长度、耗时、是否有错误。模型调用越复杂这些数据对排查和分析越有价值。你可能会惊讶地发现某个看起来很简单的工具实际耗时最长或者某类参数错误频繁发生。第三设计统一的错误返回规范。工具内部不要抛异常抛到协议层而是捕获后转成isError: true的返回结果附带可读错误信息。只有协议层无法处理的错误才用JSON-RPC的 error 字段返回。这样客户端能区分“工具执行失败但协议正常”和“协议本身就出错”两种场景前者模型可以调整后重试后者需要人工介入。5. 高频问题与排查实录手写MCP、以及后续使用各种MCP工具时我积累了不少问题和排查思路。这里挑几个出现频率最高的整理成速查表再逐个详细说。现象可能原因处理思路进程启动后立刻退出客户端读不到响应代码语法错误、入口文件路径不对、启动命令缺参数先手动在命令行执行启动命令看有没有报错客户端发出消息后一直阻塞发送后忘记 flush、消息不是完整的一行JSON给管道写入后立刻 flush打印实际发送的报文内容initialize 握手失败协议版本不匹配、响应字段缺了核对 protocolVersion 和 result 必需字段观察完整报文工具调用返回 unknown tool工具名大小写不一致、服务器忘记注册检查 tools/list 返回的名称和 tools/call 传的名称参数校验不通过inputSchema 定义和实际传参不一致对照 schema 和请求体尤其注意必填字段和类型工具调用结果很怪MixedContent返回的 content 里有非 text 字段但客户端不兼容简化返回内容尽量只返回 text 文本再逐步加复杂字段stdout 混入调试输出print 没走 stderr调用的工具或服务器内部 print 全部改成 logging 并输出到 stderr5.1 进程启动就退出客户端读不到任何响应这个问题的典型表现是客户端启动子进程后stdout.readline()一直读到空程序没有任何输出。我遇到最多的原因是服务器代码里有语法错误或运行时异常进程秒退。排查路径很简单先在终端里手动运行一次服务器启动命令比如python -u mcp_server.py直接观察有没有报错。如果手动跑没问题再看是不是工作目录不对入口文件用了相对路径找不到依赖。改一下启动命令或配置就可以。还有一个隐蔽点如果代码里用到print()打印调试信息并且这些信息打印到了 stdout那么这些内容会混进协议通道。客户端读到这行“不是JSON”的内容json.loads会直接抛异常表现就是客户端读到一半崩了。解决办法是把所有调试输出改到sys.stderr或者成熟项目里直接用logging并配置 handler 输出到 stderr。5.2 initialize反复失败或服务器不响应如果客户端发出的initialize请求石沉大海先看两件事消息是否真的是“一行JSON”以及发送后有没有 flush。我调试时经常用一个小技巧在客户端发送前把原始字符串打出来在服务器端收到后也打出来两边对比一下立刻就能看出问题在哪。另一个高发点是protocolVersion版本不匹配。服务端返回的版本如果比客户端支持的版本新客户端可能直接报错。建议客户端声明一个已知的稳定版本服务端在响应中返回这个版本即可不要返回比客户端更高或根本不对应的日期版本。还有一点如果你把initialize的响应写错了结构漏了protocolVersion或capabilities客户端一样会判定握手失败。这个需要对照协议规范逐字段检查。排查时最有效的手段就是打印双方完整报文你很快就能定位到是哪个字段的问题。5.3 调工具提示unknown tool或参数不符合schema这种情况在接入第三方MCP服务器时很常见。工具列表来自tools/list的返回但调用绑定的工具名和列表不完全一致。多见于大小写不一致、名称里带空格或特殊符号或者在工具列表里看到的名字和实际调用名之间被加了前缀。参数不符合schema则要拷问inputSchema的定义质量。我见过不少服务器为了偷懒把inputSchema定义成一个宽泛的字符串结果模型调用时填了个对象两边对不上。建议每一个工具都把参数类型、必填项、枚举值写清楚模型才能生成符合要求的参数。如果你发现某类工具经常被模型“错误调用”先别急着怪模型回头看看自己 schema 写得够不够明确。调试技巧在服务器端把收到的tools/call请求里的params原样打日志再和客户端发送前打印的报文对比通常一眼就能看出是哪边的问题。这种问题时协议层大概率是通的问题基本在业务参数构造上。5.4 调用结果中出现奇怪输出如果你的工具返回结果里夹杂着不该有的内容比如把“加载模型耗时”“缓存命中情况”等日志写进了返回值会让模型端误判结果内容。我见过一个案例某个工具内部引入了第三方日志库默认配置在 stdout 输出日志结果这些日志全部混进了工具返回值模型拿到的结果越积越长最终超出上下文限制。这个问题的根源还是通道污染。所有非协议输出都走 stderr这是解决这类问题的唯一办法。另外返回的 content 尽量结构化文本内容干净一些不要塞一堆调试信息。如果你确实需要在返回值里带上元信息可以放到_meta或自定义字段里不要在text里自觉排版。5.5 超时、并发与长任务处理MCP默认没有强制超时机制但工程上你必须防住“工具一直不返回”的情况。方法有三种在客户端进程层做wait(timeout)给工具调用增加业务超时或者给外部API调用加超时。我建议至少做第一和第二种进程层的兜底很重要万一工具内部死锁至少能按超时杀掉重来。并发方面如果你用的是 stdio 模式尤其要注意多个线程同时往同一个子进程的 stdin 写消息会互相踩踏。要么用单一写线程加消息队列要么干脆一个会话一个进程。我在生产环境用的是后者代码干净隔离也好。长任务方面MCP 支持 progress 通知机制服务器可以定期向客户端发送进度更新。但在 stdio 模式下别做得太复杂简单起见可以先只同步返回长任务建议放到HTTP传输模式里做配合SSE 推送体验更好。我自己在实际操作中还有一个感受手写一遍MCP之后再回头去用官方SDK整个思路会通透很多。你会知道某个封装方法内部大概发了什么消息某个参数最终会被放进哪个字段线上出问题的时候排查方向直接锁定到具体环节不用再瞎猜。最后分享一个小技巧。调试MCP服务器的时候不用非得依赖客户端你可以直接在终端里用管道手动喂消息给服务器模拟客户端的行为。比如echo {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-06-18,capabilities:{},clientInfo:{name:manual,version:0.0.1}}} | python -u mcp_server.py然后依次构造tools/list、tools/call的消息。这个手法在判断“是服务器的问题还是客户端的问题”时特别好用成本几乎为零。后续你再去接各种MCP服务器、或者自己开发MCP工具这套排查思路同样适用。

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

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

免费获取报价 →
↑