news 2026/10/19 7:21:26

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写MCP协议:不依赖SDK构建AI工具接入层

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.0:MCP的语言基础

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/initialized

MCP的两个角色是客户端(通常是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 HTTP

MCP的传输层目前主要分两类:本地 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(),尤其是在text=True的管道通信模式下,如果不 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(parents=True, exist_ok=True) 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": f"Method 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"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-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表示无缓冲,保证子进程的标准输出能立刻被父进程读到;text=True让管道以文本模式读写,省得自己编解码字节;encoding="utf-8"主要是为了在Windows上避免默认编码不一致导致的中文乱码问题。stderr=subprocess.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_ascii=False, indent=2)) # 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_ascii=False, indent=2)) # 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_ascii=False, indent=2)) # 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_ascii=False, indent=2)) 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 并输出到 stderr

5.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工具,这套排查思路同样适用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 7:19:00

【AI大模型】模型预热:服务启动预热的最佳实践

【AI大模型】模型预热:服务启动预热的最佳实践 大模型服务刚启动时,第一批请求往往延迟飙升、超时频发,而运行一段时间后延迟逐渐回落。这不是负载问题,而是冷启动:权重未驻留、算子未编译、缓存未填充。模型预热在服务对外服务前完成这些工作,把冷启动延迟挡在真实流量…

作者头像 李华
网站建设 2026/10/19 7:18:39

第212篇 Dex 与 Multidex:方法数的来龙去脉

先把结论放在前面:单个 dex 的方法数上限是 65536,这个数字来自 dex 文件里 method_ids 表用 16 位索引(最多 65536 项)。超过上限就要拆成多个 dex(multidex)。现代 Android(minSdk 21+)原生支持多 dex,不再需要 MultiDex.install;真正需要关注的不是"能不能装…

作者头像 李华
网站建设 2026/10/19 7:17:24

在ADT中打造自定义IDE Action:Eclipse插件开发到团队部署

很多做 ABAP 开发的朋友,基本每天都在 ABAP Development Tools(ADT)里写代码。ADT 是基于 Eclipse 构建的,所以它骨子里是一个插件化平台,只不过平时大家只把它当编辑器用。所谓 IDE Action,就是你右键一个…

作者头像 李华
网站建设 2026/10/19 7:16:12

中枢炎症体外新模型|猫脑星形胶质细胞(BA)功能与科研应用解析

星形胶质细胞是中枢神经系统数量最多的胶质细胞,并非单纯的脑组织填充细胞,在脑发育、神经递质稳态维持、血脑屏障调控中承担关键作用。静息状态下,它维持离子平衡、清除突触间隙谷氨酸、分泌神经营养因子;当遭遇外伤、缺血、Aβ …

作者头像 李华
网站建设 2026/10/19 7:13:19

Flink双流Join源码解析:从入口API到IntervalJoin

做 Flink 实时开发的这几年,双流操作是绕不过去的一个坎。不管是订单流和支付流做实时对账,还是用户点击流和曝光流做归因分析,本质上都是把两条独立的数据流按照某个 key 和时间范围关联起来。我在带团队时也发现,很多人会把 Dat…

作者头像 李华
网站建设 2026/10/19 7:13:14

Flink双流Join源码剖析:三种机制的执行原理与选型

拿到一个Flink双流作业,我最先警惕的不是业务逻辑本身,而是 join 背后的状态模型。单流处理是一条河,数据按顺序流过去就行;双流操作等于两条河在某处合流,合流那一刻怎么等、怎么配、怎么清,全由源码里的数…

作者头像 李华