一个中文参数引发的问题记一次 MCP Server 间歇性卡死的排查之旅一个新增的 MCP Tool 接口间歇性卡死而其他接口一切正常。从怀疑服务端业务逻辑到怀疑反射调用再到怀疑 SSE/HTTP 双通道竞态——三次排查、三度碰壁。最终突破口来自一个被忽视的细节只有这个接口的参数包含中文。一、问题浮现我们的 IntelliJ 插件内置了一个 MCP Server通过 HTTP SSE 协议对外暴露工具调用接口并通过反射调用另一个插件的接口。代码片段如下// MCP Server Tool 片段StringprojectDirarguments.get(PROJECT_DIR).getAsString();StringprojectNamearguments.get(PROJECT_NAME).getAsString();StringlogicJsonarguments.get(logicJson).getAsString();PluginIdpluginIdPluginId.getId(PLUGIN_ID);ClassLoaderloaderObjects.requireNonNull(PluginManagerCore.getPlugin(pluginId)).getPluginClassLoader();Class?clazzloader.loadClass(SERVICE_CLASS);Objectinstanceclazz.getDeclaredConstructor().newInstance();Methodmethodclazz.getMethod(createLogicGraph,String.class,String.class,String.class);StringprojectPathprojectDir/projectName;booleanresult(boolean)method.invoke(instance,projectPath,projectName,logicJson);// if (result) {// return 编译成功;// } else {// return 错误编译失败;// }return上传成功;某天一个新上线的createLogicGraph接口出现了诡异的行为表现极不稳定三次调用一次返回SocketException: Software caused connection abort: socket write error两次直接卡死无响应。其他 Tool 完全正常createDeployGraph、createProject、compileProject等接口使用相同的代码模板和调用路径却从未出现问题。直觉告诉我们问题一定出在createLogicGraph的“特殊性”上。但特殊性在哪里二、第一轮排查怀疑服务端业务逻辑从日志的时间线入手是最自然的起点。14:25:03,888 → MCP 收到 tools/call 请求 14:25:03,900 → 标记method.invoke 之前 14:25:03,911 → AIService 开始执行业务逻辑 14:25:04,073 → AIService 最后一条日志 cccccccccccccccc 14:25:04,073 → ###### 标记method.invoke 已返回 14:25:04,076 → SocketException: socket write error第一次调用的日志显示AIService.createLogicGraph()仅仅执行了约 160ms 就完成了并非业务逻辑慢导致的超时。异常发生在“写 HTTP 响应”阶段——此时客户端已经关闭了连接。但随后的第二次、第三次调用更糟糕——日志中和######标记都没有出现说明method.invoke()根本没返回线程卡在了反射调用内部。第一轮结论问题不在 MCP Server 的调度层而在被反射调用的AIService.createLogicGraph()方法内部。该方法在打印完业务日志后“神秘消失”间歇性地不返回。于是我们给反射调用加了 30 秒超时保护ExecutorServiceexecutorExecutors.newSingleThreadExecutor();FutureBooleanfutureexecutor.submit(()-(boolean)method.invoke(instance,projectPath,projectName,logicJson));try{booleanresultfuture.get(30,TimeUnit.SECONDS);// ...}catch(TimeoutExceptione){future.cancel(true);return错误上传逻辑流图超时;}这是一个“治标”的防御性措施——即使底层卡死MCP worker 线程也不会被永久占用。但根本原因仍然未知。三、第二轮排查怀疑 SSE/HTTP 双通道竞态带着“为什么只有这个 Tool 有问题”的疑问我们仔细对比了出错日志和正常日志的差异发现了一个关键模式handlePostMessage() 的处理流程 1. registry.handleJsonRpc() → 同步执行工具返回结果 2. conn.sendEvent(message, …) → 通过 SSE 通道推送结果 3. sendJsonResponse(out, 200, …) → 通过 HTTP 通道返回结果同一个结果通过两条通道发送了两次。而且SSE 是长连接HTTP 是短连接——Kilo 客户端在收到 SSE 事件后可能会立即关闭 POST 连接导致步骤 3 写 Socket 时连接已被终止。这解释了一部分现象但有一个致命漏洞如果是竞态问题为什么其他 Tool 从不触发它们的处理路径完全相同。代码 diff 也证实CreateLogicGraphTool和CreateDeployGraphTool的结构几乎一模一样唯一的区别是被调用的业务方法名不同。这个方向虽然推动了我们对 HTTP 响应策略的改进SSE 已发则 HTTP 只回 202但没法解释“为什么只有它”这个核心问题。四、突破从参数差异找到真正线索连续两个方向碰壁后我们退后一步重新审视一个基本事实其他 Tool 全部正常只有createLogicGraph有问题。它们的代码结构完全一致唯一的区别是什么答案藏在参数里。对比一下两个接口的典型调用参数createDeployGraph正常{projectName:untitled8,projectDir:D:/Workspace/Work/Projects/,deployJson:{\nodes\: [...], \edges\: [...]}}deployJson的内容全部是 ASCII 字符——英文 key、数字 ID、符号。createLogicGraph异常{projectName:untitled8,projectDir:D:/Workspace/Work/Projects/,logicJson:{\moduleNodes\: [{\moduleName\: \毫米波视频前端\, ...}, {\moduleName\: \接收机及ADC\, ...}]}}logicJson中包含了大量中文模块名称——“毫米波视频前端”、“接收机及ADC”、“脉冲压缩”……这个差异意味着什么中文在 UTF-8 编码下每个字符占 3 个字节而 ASCII 字符只占 1 个字节。如果 HTTP 请求体的读取逻辑混淆了“字节”和“字符”就可能在遇到中文时出错。五、定位根本原因Content-Length 的字节/字符陷阱带着这个猜想我们重新审视了McpHttpServer中读取 HTTP 请求体的代码// 修复前的代码 // handleConnection() 中使用 BufferedReader 读取BufferedReaderreadernewBufferedReader(newInputStreamReader(client.getInputStream(),StandardCharsets.UTF_8));// readRequestBody() 用字符数组接收privatestaticStringreadRequestBody(BufferedReaderreader,intcontentLength)throwsIOException{if(contentLength0)return;char[]bufnewchar[contentLength];// ← 问题在这里inttotal0;while(totalcontentLength){// ← 循环读取 contentLength 个字符intnreader.read(buf,total,contentLength-total);if(n-1)break;totaln;}returnnewString(buf,0,total);}Bug 的本质HTTP 协议中的Content-Length头表示请求体的字节数。但readRequestBody方法用一个字符数组去接收并且循环条件是以contentLength为目标的字符计数。当请求体全部是 ASCII 字符时1 字符 1 字节contentLength字节恰好等于contentLength个字符——循环完美结束一切正常。当请求体包含中文时毫米波视频前端 → UTF-8 编码后占 18 字节但只有 6 个字符假设整个logicJson参数的 UTF-8 字节数是 5000Content-Length 5000但实际只有约 4500 个字符。BufferedReader.read()读完 4500 个字符后流中已经没有更多数据了——但循环还在等待凑满 5000 个“字符”。于是reader.read()永久阻塞直到客户端超时关闭连接。这完美解释了所有现象场景发生了什么其他 Tool纯 ASCII 参数字节数 字符数循环正确结束一切正常createLogicGraph含中文参数字节数 字符数reader.read()读完实际字符后永久阻塞等待不存在的字符第一次调用“碰巧”成功某些边界条件下如网络分片、TCP 缓冲行为reader 提前返回 -1 从而跳出循环之后调用卡死更典型的情况——缓冲区一次性交付完所有字节reader 阻塞在后续 read 上六、修复从字符流切换到字节流根因清楚了修复方向也明确了以字节为单位读取请求体读完后再按 UTF-8 解码为字符串。// 修复后的代码 // handleConnection() 中改用 BufferedInputStreamInputStreaminnewBufferedInputStream(client.getInputStream());// readRequestBody() 以字节为单位读取privatestaticStringreadRequestBody(InputStreamin,intcontentLength)throwsIOException{if(contentLength0)return;byte[]bufnewbyte[contentLength];// ← 字节数组inttotal0;while(totalcontentLength){// ← 读取 contentLength 个字节intnin.read(buf,total,contentLength-total);if(n-1)break;totaln;}returnnewString(buf,0,total,StandardCharsets.UTF_8);// ← 读完再解码}同时由于不再使用BufferedReaderHTTP 请求行的解析也需要改为基于字节流的实现privatestaticStringreadLine(InputStreamin)throwsIOException{ByteArrayOutputStreambaosnewByteArrayOutputStream();intprev-1;intb;while((bin.read())!-1){if(prev\rb\n){byte[]bytesbaos.toByteArray();if(bytes.length0){returnnewString(bytes,0,bytes.length-1,StandardCharsets.UTF_8);}return;}baos.write(b);prevb;}returnbaos.size()0?newString(baos.toByteArray(),StandardCharsets.UTF_8):null;}这个实现逐字节读取直到遇到\r\nHTTP 头部本身是 ASCII逐字节处理没有性能问题。七、总结与反思技术层面这次问题的根因是一个经典的字节/字符不匹配陷阱HTTP Content-Length → 字节数协议层定义 BufferedReader.read → 字符数Java 抽象层 UTF-8 多字节字符 → 1 字符 ≠ 1 字节编码层三层之间的语义错位在纯 ASCII 场景下被完美掩盖一旦中文参数介入就暴露无遗。这也是为什么其他 Tool参数不含中文完全正常。排查方法论回顾整个排查过程有几个值得记录的经验“为什么只有它”是最强线索。当某个组件“唯独”表现异常时寻找它和其他组件的差异点往往比深入分析异常组件本身更快定位问题。这次的关键差异不是代码逻辑而是数据特征——参数中是否包含中文。不要过早排除“看起来不可能”的方向。我们最初把注意力集中在服务端业务逻辑、反射调用机制、SSE/HTTP 竞态这些“重”问题上因为这些问题更符合“卡死”的直觉。但实际上问题出在最基础的 HTTP 请求体读取上——一个我们默认“不可能出错”的基础设施层。跳出当前层次思考。当在 MCP Server 层和反射调用层反复排查无果后把视野扩展到客户端构造的参数内容才打开了突破口。问题不在“怎么调”而在“调的时候传了什么”。这个视角切换是本次排查最重要的转折点。日志要区分“执行完了”和“执行成功了”。第一次调用中业务日志正常打印但仍报错——这说明业务逻辑执行完了但后续的 HTTP 响应写入失败了。如果只关注“有没有报错”很容易误判问题位置。代码层面的教训永远不要用字符数去匹配字节数。处理 HTTP 协议时Content-Length 是字节语义读取逻辑必须遵循字节语义。BufferedReader不适合读取 HTTP 请求体。HTTP 头部是 ASCII 文本用 Reader 没问题但请求体可能包含任意二进制数据应该用InputStream 显式解码。编写 HTTP 服务时优先考虑包含多字节字符的测试用例。中英文混合的请求体能有效暴露字节/字符混淆类 bug应当作为基础测试场景。最后这个问题从发现到根因定位经历了三次方向调整、多次日志对比和代码 diff 分析。最终的修复只有几十行代码变更但背后的排查过程折射出一个朴素的道理最隐蔽的 bug 往往不在复杂的业务逻辑里而在那些“理所当然应该正确”的基础设施层。愿你我都能在各自的领域里不断成长勇敢追求梦想同时也保持对世界的好奇与善意!
一个中文参数引发的问题:记一次 MCP Server 间歇性卡死的排查之旅
一个中文参数引发的问题记一次 MCP Server 间歇性卡死的排查之旅一个新增的 MCP Tool 接口间歇性卡死而其他接口一切正常。从怀疑服务端业务逻辑到怀疑反射调用再到怀疑 SSE/HTTP 双通道竞态——三次排查、三度碰壁。最终突破口来自一个被忽视的细节只有这个接口的参数包含中文。一、问题浮现我们的 IntelliJ 插件内置了一个 MCP Server通过 HTTP SSE 协议对外暴露工具调用接口并通过反射调用另一个插件的接口。代码片段如下// MCP Server Tool 片段StringprojectDirarguments.get(PROJECT_DIR).getAsString();StringprojectNamearguments.get(PROJECT_NAME).getAsString();StringlogicJsonarguments.get(logicJson).getAsString();PluginIdpluginIdPluginId.getId(PLUGIN_ID);ClassLoaderloaderObjects.requireNonNull(PluginManagerCore.getPlugin(pluginId)).getPluginClassLoader();Class?clazzloader.loadClass(SERVICE_CLASS);Objectinstanceclazz.getDeclaredConstructor().newInstance();Methodmethodclazz.getMethod(createLogicGraph,String.class,String.class,String.class);StringprojectPathprojectDir/projectName;booleanresult(boolean)method.invoke(instance,projectPath,projectName,logicJson);// if (result) {// return 编译成功;// } else {// return 错误编译失败;// }return上传成功;某天一个新上线的createLogicGraph接口出现了诡异的行为表现极不稳定三次调用一次返回SocketException: Software caused connection abort: socket write error两次直接卡死无响应。其他 Tool 完全正常createDeployGraph、createProject、compileProject等接口使用相同的代码模板和调用路径却从未出现问题。直觉告诉我们问题一定出在createLogicGraph的“特殊性”上。但特殊性在哪里二、第一轮排查怀疑服务端业务逻辑从日志的时间线入手是最自然的起点。14:25:03,888 → MCP 收到 tools/call 请求 14:25:03,900 → 标记method.invoke 之前 14:25:03,911 → AIService 开始执行业务逻辑 14:25:04,073 → AIService 最后一条日志 cccccccccccccccc 14:25:04,073 → ###### 标记method.invoke 已返回 14:25:04,076 → SocketException: socket write error第一次调用的日志显示AIService.createLogicGraph()仅仅执行了约 160ms 就完成了并非业务逻辑慢导致的超时。异常发生在“写 HTTP 响应”阶段——此时客户端已经关闭了连接。但随后的第二次、第三次调用更糟糕——日志中和######标记都没有出现说明method.invoke()根本没返回线程卡在了反射调用内部。第一轮结论问题不在 MCP Server 的调度层而在被反射调用的AIService.createLogicGraph()方法内部。该方法在打印完业务日志后“神秘消失”间歇性地不返回。于是我们给反射调用加了 30 秒超时保护ExecutorServiceexecutorExecutors.newSingleThreadExecutor();FutureBooleanfutureexecutor.submit(()-(boolean)method.invoke(instance,projectPath,projectName,logicJson));try{booleanresultfuture.get(30,TimeUnit.SECONDS);// ...}catch(TimeoutExceptione){future.cancel(true);return错误上传逻辑流图超时;}这是一个“治标”的防御性措施——即使底层卡死MCP worker 线程也不会被永久占用。但根本原因仍然未知。三、第二轮排查怀疑 SSE/HTTP 双通道竞态带着“为什么只有这个 Tool 有问题”的疑问我们仔细对比了出错日志和正常日志的差异发现了一个关键模式handlePostMessage() 的处理流程 1. registry.handleJsonRpc() → 同步执行工具返回结果 2. conn.sendEvent(message, …) → 通过 SSE 通道推送结果 3. sendJsonResponse(out, 200, …) → 通过 HTTP 通道返回结果同一个结果通过两条通道发送了两次。而且SSE 是长连接HTTP 是短连接——Kilo 客户端在收到 SSE 事件后可能会立即关闭 POST 连接导致步骤 3 写 Socket 时连接已被终止。这解释了一部分现象但有一个致命漏洞如果是竞态问题为什么其他 Tool 从不触发它们的处理路径完全相同。代码 diff 也证实CreateLogicGraphTool和CreateDeployGraphTool的结构几乎一模一样唯一的区别是被调用的业务方法名不同。这个方向虽然推动了我们对 HTTP 响应策略的改进SSE 已发则 HTTP 只回 202但没法解释“为什么只有它”这个核心问题。四、突破从参数差异找到真正线索连续两个方向碰壁后我们退后一步重新审视一个基本事实其他 Tool 全部正常只有createLogicGraph有问题。它们的代码结构完全一致唯一的区别是什么答案藏在参数里。对比一下两个接口的典型调用参数createDeployGraph正常{projectName:untitled8,projectDir:D:/Workspace/Work/Projects/,deployJson:{\nodes\: [...], \edges\: [...]}}deployJson的内容全部是 ASCII 字符——英文 key、数字 ID、符号。createLogicGraph异常{projectName:untitled8,projectDir:D:/Workspace/Work/Projects/,logicJson:{\moduleNodes\: [{\moduleName\: \毫米波视频前端\, ...}, {\moduleName\: \接收机及ADC\, ...}]}}logicJson中包含了大量中文模块名称——“毫米波视频前端”、“接收机及ADC”、“脉冲压缩”……这个差异意味着什么中文在 UTF-8 编码下每个字符占 3 个字节而 ASCII 字符只占 1 个字节。如果 HTTP 请求体的读取逻辑混淆了“字节”和“字符”就可能在遇到中文时出错。五、定位根本原因Content-Length 的字节/字符陷阱带着这个猜想我们重新审视了McpHttpServer中读取 HTTP 请求体的代码// 修复前的代码 // handleConnection() 中使用 BufferedReader 读取BufferedReaderreadernewBufferedReader(newInputStreamReader(client.getInputStream(),StandardCharsets.UTF_8));// readRequestBody() 用字符数组接收privatestaticStringreadRequestBody(BufferedReaderreader,intcontentLength)throwsIOException{if(contentLength0)return;char[]bufnewchar[contentLength];// ← 问题在这里inttotal0;while(totalcontentLength){// ← 循环读取 contentLength 个字符intnreader.read(buf,total,contentLength-total);if(n-1)break;totaln;}returnnewString(buf,0,total);}Bug 的本质HTTP 协议中的Content-Length头表示请求体的字节数。但readRequestBody方法用一个字符数组去接收并且循环条件是以contentLength为目标的字符计数。当请求体全部是 ASCII 字符时1 字符 1 字节contentLength字节恰好等于contentLength个字符——循环完美结束一切正常。当请求体包含中文时毫米波视频前端 → UTF-8 编码后占 18 字节但只有 6 个字符假设整个logicJson参数的 UTF-8 字节数是 5000Content-Length 5000但实际只有约 4500 个字符。BufferedReader.read()读完 4500 个字符后流中已经没有更多数据了——但循环还在等待凑满 5000 个“字符”。于是reader.read()永久阻塞直到客户端超时关闭连接。这完美解释了所有现象场景发生了什么其他 Tool纯 ASCII 参数字节数 字符数循环正确结束一切正常createLogicGraph含中文参数字节数 字符数reader.read()读完实际字符后永久阻塞等待不存在的字符第一次调用“碰巧”成功某些边界条件下如网络分片、TCP 缓冲行为reader 提前返回 -1 从而跳出循环之后调用卡死更典型的情况——缓冲区一次性交付完所有字节reader 阻塞在后续 read 上六、修复从字符流切换到字节流根因清楚了修复方向也明确了以字节为单位读取请求体读完后再按 UTF-8 解码为字符串。// 修复后的代码 // handleConnection() 中改用 BufferedInputStreamInputStreaminnewBufferedInputStream(client.getInputStream());// readRequestBody() 以字节为单位读取privatestaticStringreadRequestBody(InputStreamin,intcontentLength)throwsIOException{if(contentLength0)return;byte[]bufnewbyte[contentLength];// ← 字节数组inttotal0;while(totalcontentLength){// ← 读取 contentLength 个字节intnin.read(buf,total,contentLength-total);if(n-1)break;totaln;}returnnewString(buf,0,total,StandardCharsets.UTF_8);// ← 读完再解码}同时由于不再使用BufferedReaderHTTP 请求行的解析也需要改为基于字节流的实现privatestaticStringreadLine(InputStreamin)throwsIOException{ByteArrayOutputStreambaosnewByteArrayOutputStream();intprev-1;intb;while((bin.read())!-1){if(prev\rb\n){byte[]bytesbaos.toByteArray();if(bytes.length0){returnnewString(bytes,0,bytes.length-1,StandardCharsets.UTF_8);}return;}baos.write(b);prevb;}returnbaos.size()0?newString(baos.toByteArray(),StandardCharsets.UTF_8):null;}这个实现逐字节读取直到遇到\r\nHTTP 头部本身是 ASCII逐字节处理没有性能问题。七、总结与反思技术层面这次问题的根因是一个经典的字节/字符不匹配陷阱HTTP Content-Length → 字节数协议层定义 BufferedReader.read → 字符数Java 抽象层 UTF-8 多字节字符 → 1 字符 ≠ 1 字节编码层三层之间的语义错位在纯 ASCII 场景下被完美掩盖一旦中文参数介入就暴露无遗。这也是为什么其他 Tool参数不含中文完全正常。排查方法论回顾整个排查过程有几个值得记录的经验“为什么只有它”是最强线索。当某个组件“唯独”表现异常时寻找它和其他组件的差异点往往比深入分析异常组件本身更快定位问题。这次的关键差异不是代码逻辑而是数据特征——参数中是否包含中文。不要过早排除“看起来不可能”的方向。我们最初把注意力集中在服务端业务逻辑、反射调用机制、SSE/HTTP 竞态这些“重”问题上因为这些问题更符合“卡死”的直觉。但实际上问题出在最基础的 HTTP 请求体读取上——一个我们默认“不可能出错”的基础设施层。跳出当前层次思考。当在 MCP Server 层和反射调用层反复排查无果后把视野扩展到客户端构造的参数内容才打开了突破口。问题不在“怎么调”而在“调的时候传了什么”。这个视角切换是本次排查最重要的转折点。日志要区分“执行完了”和“执行成功了”。第一次调用中业务日志正常打印但仍报错——这说明业务逻辑执行完了但后续的 HTTP 响应写入失败了。如果只关注“有没有报错”很容易误判问题位置。代码层面的教训永远不要用字符数去匹配字节数。处理 HTTP 协议时Content-Length 是字节语义读取逻辑必须遵循字节语义。BufferedReader不适合读取 HTTP 请求体。HTTP 头部是 ASCII 文本用 Reader 没问题但请求体可能包含任意二进制数据应该用InputStream 显式解码。编写 HTTP 服务时优先考虑包含多字节字符的测试用例。中英文混合的请求体能有效暴露字节/字符混淆类 bug应当作为基础测试场景。最后这个问题从发现到根因定位经历了三次方向调整、多次日志对比和代码 diff 分析。最终的修复只有几十行代码变更但背后的排查过程折射出一个朴素的道理最隐蔽的 bug 往往不在复杂的业务逻辑里而在那些“理所当然应该正确”的基础设施层。愿你我都能在各自的领域里不断成长勇敢追求梦想同时也保持对世界的好奇与善意!