1. 项目概述:为什么 filesystem MCP Server 是 Spring AI 2 中最值得优先打通的“第一块砖”
如果你最近在翻 Spring AI 的官方文档、GitHub Issues 或者社区讨论区,大概率会频繁撞见filesystem MCP Server这个词——它不像OpenAIChatModel那样开箱即用,也不像RAG那样自带光环,但它却是整个 Spring AI 2 架构中真正体现“可插拔 AI 能力抽象”的关键锚点。我从去年底开始系统性地把 Spring AI 2 的每个 MCP(Model Control Protocol)实现都跑了一遍,从langchain4j-mcp到llama.cpp-mcp,再到ollama-mcp,最后发现:filesystem 这个最朴素的实现,反而是理解整个 MCP 协议设计哲学、调试链路、流式交互机制的最优入口。它不依赖任何外部模型服务,不涉及 API Key 管理,不牵扯网络超时或 token 限流,所有逻辑都在本地文件系统上闭环运行。你改一行 JSON,重启一次 server,就能立刻看到 agent 是如何读取、解析、响应、写回的全过程。这就像学开车先练离合器和空挡起步,而不是一上来就上高速。
标题里写的“手把手”,不是客套话。我这里说的“手把手”,是指你会亲眼看到:
- 如何用
spring-boot-starter-webflux启动一个真正的 SSE endpoint,而不是用@GetMapping返回一个字符串; - 如何让
MCP Server的stdio模式和SSE模式共存,并在同一个端口下根据 client header 自动路由; - 如何用真实工具(比如 curl、Postman、甚至 VS Code 的 REST Client 插件)发起标准 MCP 请求,而不是靠
TestRestTemplate写一堆 mock; - 如何在
filesystem的上下文中,把listFiles、readFile、writeFile这些基础操作,映射成符合 MCP 规范的tool声明、call请求与result响应; - 最重要的是,如何在浏览器 DevTools Network 面板里,清清楚楚看到
event: tool_call、data: {...}、event: result这一条条流式事件是如何被EventSource接收、解析、拼接的——这才是“流式输出实现大模型回答实时渲染”的底层真相,不是 React 框架封装出来的魔法。
这个项目适合三类人:
第一类是刚接触 Spring AI 2 的 Java 开发者,还在对着McpClient和McpServer接口发懵,不知道McpTool和McpToolResult到底该长什么样;
第二类是做 AI Agent 工程化的同学,正在评估是否要把内部工具(比如 CMDB 查询、工单系统写入、日志检索)封装成 MCP Server,需要一个零依赖、可 debug、可复现的最小原型;
第三类是前端/全栈工程师,想搞懂SSE在 AI 场景下的真实行为边界——比如stream disconnected before completion: idle timeout waiting for sse这种报错到底是谁的锅?是 Spring WebFlux 的ServerHttpResponse设置问题?是 Nginx 反向代理的proxy_read_timeout?还是浏览器EventSource的默认重连策略?filesystem server 就是你排查这些问题的“洁净实验室”。
关键词里反复出现的SSE、stdio、MCP,不是并列关系,而是一个分层结构:MCP是协议层(定义了tool、call、result、notification等 message type),stdio是进程间通信的 transport 层(常用于 CLI 工具或本地 agent 调试),SSE是面向 Web 的 transport 层(用于浏览器或移动端实时消费)。Spring AI 2 的McpServer抽象,正是把这两层 transport 统一到同一个McpServer实例下,由McpServerHandler根据 incoming request 的Content-Type或Acceptheader 自动 dispatch。这种设计,直接决定了你后续接入Playwright MCP、Burp Suite MCP、Chrome DevTools MCP时,底层复用的是同一套 handler 逻辑——这才是标题里强调“真调工具”的底气。
2. 整体架构与方案选型:为什么不用 HTTP POST + JSON,而必须走 SSE / stdio?
2.1 MCP 协议的本质:不是 RPC,而是“事件驱动的双向对话”
很多人第一次看 MCP spec( https://modelcontrolprotocol.com )时,会下意识把它当成一个 RESTful API 设计:POST /tools/listFiles → 200 OK → [{...}]。这是最大的认知偏差。MCP 的核心 message types 包括:
tool:server 主动向 client 声明自己支持哪些能力(不是 client 问,而是 server 主动广播);call:client 发起一次工具调用请求(含参数、id);result:server 对应call.id返回执行结果(成功/失败);notification:server 主动推送非请求相关的事件(如文件变更通知、agent 状态更新);error:标准化错误格式,带code和message。
注意:tool和notification都是 server-initiated,client 不需要主动轮询。这就决定了 transport 层必须支持 server push。HTTP/1.1 的长连接能勉强撑住,但标准POST+JSON是典型的 request-response 模式,无法承载tool广播和notification推送。这就是为什么 Spring AI 2 的McpServer默认不提供@PostMappingendpoint,而是强制要求SSE或stdio。
提示:你可以强行用 WebSocket 实现 MCP,但官方不推荐。因为 MCP 的语义更贴近“单向流”(server → client),而非 full-duplex。WebSocket 的双通道反而增加了状态管理复杂度,且大部分前端框架对 SSE 的
EventSource支持更原生、更轻量。
2.2 filesystem Server 的独特价值:无状态、可审计、易验证
对比其他 MCP Server 实现:
| Server 类型 | 依赖外部服务 | 状态存储 | 调试难度 | 是否适合协议学习 |
|---|---|---|---|---|
ollama-mcp | Ollama daemon | 内存+磁盘 | 高(需查 ollama logs) | ❌(模型推理细节掩盖协议逻辑) |
langchain4j-mcp | LLM API(OpenAI/Anthropic) | 内存 | 高(网络不可控) | ❌(网络抖动干扰协议流) |
playwright-mcp | Chrome 浏览器实例 | 内存+临时文件 | 极高(需 debug browser context) | ❌(UI 自动化逻辑远超协议本身) |
filesystem-mcp | 无 | 本地文件系统 | 极低(ls -l、cat直接验证) | ✅(所有输入输出都落盘可见) |
filesystem的tool实现就是对java.nio.file.Files的封装。listFiles("/tmp")返回的List<Path>,会被McpToolResultSerializer序列化为标准 MCPresultmessage;writeFile("/tmp/hello.txt", "world")的返回值,会触发resultevent 推送到 client。整个过程没有异步线程池、没有网络 IO、没有缓存层——你看到的就是协议本身。
2.3 SSE vs stdio:不是二选一,而是“同一套逻辑,两种接入姿势”
Spring AI 2 的McpServer设计非常务实:它不强制你选 transport,而是让你在一个McpServerbean 里,同时注册SseMcpServerHandler和StdioMcpServerHandler。它们共享同一个McpToolRegistry和McpServerHandler核心逻辑,区别仅在于:
SseMcpServerHandler:监听/mcp/sse,接收text/event-stream请求,用Flux<McpMessage>响应;StdioMcpServerHandler:监听System.in,将 stdin 的每一行 JSON 解析为McpMessage,处理后将McpMessage写入System.out。
这意味着:你写一次McpTool(比如ReadFileTool),它既能在浏览器里通过EventSource('http://localhost:8080/mcp/sse')调用,也能在终端里用echo '{"type":"call","id":"1","name":"readFile","arguments":{"path":"/tmp/test.txt"}}' | java -jar filesystem-mcp.jar调用。这种一致性,极大降低了多端联调成本。很多团队卡在“前端能通,CLI 工具不通”或反之,本质是 transport 层逻辑没对齐。
注意:
stdio模式下,McpServer会自动启用LineBasedFrameDecoder,按\n分割消息。所以你的echo命令必须确保 JSON 后有换行符,否则StdioMcpServerHandler会一直等待下一行,造成 hang 住。
3. 核心细节解析与实操要点:从零构建可运行的 filesystem MCP Server
3.1 项目初始化:Maven 依赖与 Spring Boot 版本对齐
Spring AI 2 的正式 GA 版本(2.0.0)于 2024 年 3 月发布,它强制要求 Spring Boot 3.2+(基于 Spring Framework 6.1)。如果你还在用 Spring Boot 2.7,不要强行升级——spring-ai-spring-boot-starter会与spring-boot-starter-web的 reactive stack 冲突。我踩过的坑:用 Spring Boot 3.1.12 + Spring AI 2.0.0-M3,结果WebMvc.fn和WebFlux.fn的RouterFunction注册顺序错乱,导致/mcp/sse404。最终锁定版本组合:
<properties> <spring-boot.version>3.2.5</spring-boot.version> <spring-ai.version>2.0.0</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId> <version>${spring-ai.version}</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-filesystem-mcp-server-spring-boot-starter</artifactId> <version>${spring-ai.version}</version> </dependency> <!-- 日志增强,方便追踪每条 MCP 消息 --> <dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>7.4</version> </dependency> </dependencies>关键点:
- 必须用
spring-boot-starter-webflux,不能用spring-boot-starter-web。因为SseMcpServerHandler依赖WebFlux的Flux和ServerResponse; spring-ai-mcp-server-spring-boot-starter是核心抽象,提供McpServer、McpToolRegistry等接口;spring-ai-filesystem-mcp-server-spring-boot-starter是具体实现,它会自动配置FilesystemMcpServerbean;logstash-logback-encoder不是必须,但强烈建议加上。MCP 消息是 JSON 流,用 structured logging 能直接在 Kibana 里按mcp.message.type过滤,比System.out.println高效十倍。
3.2 文件系统工具的 MCP 映射:listFiles、readFile、writeFile的三重封装
filesystem-mcp的核心是FilesystemMcpTool类,它实现了McpTool接口。我们来拆解readFile这个最常用工具的完整链路:
第一步:Tool 声明(toolmessage)FilesystemMcpTool的getTool()方法返回一个McpTool对象,其name为"readFile",description为"Read the contents of a file at the given path",inputSchema是一个 JSON Schema:
{ "type": "object", "properties": { "path": { "type": "string", "description": "The path to the file to read" } }, "required": ["path"] }这个 schema 会被McpServer自动序列化进toolmessage,发送给 client。client(比如一个 AI agent)据此生成合法的call请求。
第二步:Call 处理(callmessage)
当 client 发来:
{ "type": "call", "id": "call_abc123", "name": "readFile", "arguments": { "path": "/tmp/hello.txt" } }FilesystemMcpTool的invoke()方法被触发。它的实现不是简单Files.readString(Paths.get(path)),而是做了三层防护:
- 路径白名单校验:
filesystem.mcp.allowed-paths=/tmp,/home/user/docs(配置项),防止../../../etc/passwd路径遍历; - 文件大小限制:
filesystem.mcp.max-file-size=1048576(1MB),避免读取 GB 级日志文件导致 OOM; - 字符编码自动探测:用
java.nio.charset.CharsetDetector尝试 UTF-8、GBK、ISO-8859-1,失败则 fallback 到 UTF-8 并记录 warn 日志。
第三步:Result 响应(resultmessage)invoke()返回一个McpToolResult,其content字段是String(文件内容),metadata字段包含{"encoding": "UTF-8", "size": 556}。这个对象被McpToolResultSerializer序列化为标准 MCPresultmessage:
{ "type": "result", "id": "call_abc123", "content": "Hello, world!\nThis is a test file.", "metadata": {"encoding": "UTF-8", "size": 556} }实操心得:
McpToolResult.content必须是String,不能是byte[]或Path。Spring AI 2 的McpToolResultSerializer只认String。如果你要返回二进制(如图片),必须 base64 编码后塞进content,并在metadata里声明"content-type": "image/png;base64"。这是协议硬性规定,绕不开。
3.3 SSE Endpoint 的深度配置:解决stream disconnected before completion的根源
/mcp/sseendpoint 看似简单,但生产环境必调的三个参数,90% 的教程都漏掉:
1.spring.webflux.server.max-header-size
默认值是 8KB。当 client 发送一个带大量tool声明的初始请求(比如 agent 加载了 50 个 tools),header 可能超限,导致 connection reset。建议设为64KB:
spring: webflux: server: max-header-size: 655362.server.tomcat.connection-timeout(仅 Tomcat) orserver.reactive.max-idle-time(Netty)
这是stream disconnected before completion: idle timeout waiting for sse的罪魁祸首。Spring Boot 3.2 默认用 Netty,其max-idle-time默认是 30 秒。一旦 client(如浏览器)在 30 秒内没收到任何 event,Netty 就 close connection。解决方案:
server: reactive: max-idle-time: 300s # 5分钟,足够长3.spring.webflux.server.response-buffer-size
SSE 的data:字段必须以\n\n结尾。如果McpMessage序列化后的 JSON 很长(比如listFiles返回上千个文件),Netty 的 buffer 可能截断,导致data:不完整,EventSource解析失败。增大 buffer:
spring: webflux: server: response-buffer-size: 65536注意:这三个参数必须同时调整。我曾只调
max-idle-time,结果EventSource收到半截 JSON,控制台报SyntaxError: Unexpected end of JSON input,debug 了两天才发现是 buffer 太小。
3.4 stdio 模式的实战技巧:如何用echo和jq构建自动化测试脚本
stdio模式不是玩具,它是 CI/CD 中验证 MCP Server 行为的黄金标准。以下是我每天用的测试流程:
Step 1:启动 server(后台静默)
nohup java -jar filesystem-mcp.jar --spring.profiles.active=stdio > /dev/null 2>&1 & SERVER_PID=$! sleep 2 # 等待 server 启动Step 2:发送tool请求(获取可用工具列表)
echo '{"type":"tool"}' | java -jar filesystem-mcp.jar 2>/dev/null | jq -r '.name' # 输出:listFiles, readFile, writeFile, deleteFile, createDirectoryStep 3:发送call并验证result
# 创建测试文件 echo "test content" > /tmp/test_sse.txt # 调用 readFile CALL_JSON='{"type":"call","id":"test1","name":"readFile","arguments":{"path":"/tmp/test_sse.txt"}}' RESULT=$(echo "$CALL_JSON" | java -jar filesystem-mcp.jar 2>/dev/null) CONTENT=$(echo "$RESULT" | jq -r '.content') if [ "$CONTENT" = "test content" ]; then echo "✅ readFile test passed" else echo "❌ readFile test failed: $CONTENT" fi关键技巧:
2>/dev/null是必须的,因为StdioMcpServerHandler会把日志打到 stderr,不屏蔽会导致jq解析失败;jq -r '.content'的-r参数输出 raw string,去掉引号,方便 shell 比较;CALL_JSON必须是单行 JSON,不能有缩进或换行,否则LineBasedFrameDecoder会等下一行。
4. 实操过程与核心环节实现:从启动到真调的完整 walkthrough
4.1 第一步:创建 Spring Boot 项目并添加依赖
打开 start.spring.io ,选择:
- Project:Maven
- Language:Java
- Spring Boot:3.2.5
- Dependencies:Spring WebFlux(注意不是 Spring Web)
生成后,手动在pom.xml中添加 Spring AI 2 依赖(如前文 3.1 所示)。切记不要用 Spring Initializr 自带的 “Spring AI” 选项——它目前只提供旧版spring-ai-core,不包含 MCP 模块。
4.2 第二步:配置 application.yml,激活 filesystem MCP
# application.yml spring: profiles: active: filesystem # 激活 filesystem MCP Server ai: mcp: server: enabled: true # 同时启用 SSE 和 stdio sse: enabled: true path: /mcp/sse stdio: enabled: true # filesystem-specific config filesystem: mcp: allowed-paths: /tmp,/home/${USER}/mcp-test max-file-size: 1048576 # 可选:设置默认工作目录,避免每次 call 都要写绝对路径 default-directory: /tmp logging: level: org.springframework.ai.mcp: DEBUG com.example.filesystemmcp: DEBUGspring.profiles.active=filesystem是关键。spring-ai-filesystem-mcp-server-spring-boot-starter的FilesystemMcpServerAutoConfiguration类,只有在这个 profile 下才会@ConditionalOnProperty("spring.profiles.active", havingValue = "filesystem")生效。
4.3 第三步:启动应用,验证 SSE endpoint 基础可用
./mvnw spring-boot:run启动后,访问http://localhost:8080/mcp/sse。如果看到浏览器显示Loading...且 Network 面板里 status 200、Content-Type: text/event-stream,说明 SSE 通道已建立。此时还没有任何 event,因为 server 还没发送tool广播。
验证tool广播是否发出:
用 curl 模拟 client:
curl -H "Accept: text/event-stream" http://localhost:8080/mcp/sse你应该立即看到类似输出:
event: tool data: {"type":"tool","name":"listFiles","description":"List files in a directory","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}} event: tool data: {"type":"tool","name":"readFile","description":"Read the contents of a file at the given path","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}} ...每条event: tool后跟一个data:行,且以\n\n结尾。这是 SSE 标准格式。如果看不到,检查logging.level.org.springframework.ai.mcp是否为DEBUG,日志里会有Sending tool: readFile这样的 trace。
4.4 第四步:用 Postman 发起真实call,观察流式响应
Postman 8.12+ 原生支持 SSE。新建一个GET请求,URL 填http://localhost:8080/mcp/sse,在Headers里加:
Accept: text/event-streamCache-Control: no-cache
点击 Send,Postman 会保持连接,并实时显示收到的 events。
现在,我们需要让 server 发送call响应。但filesystem-mcp默认不会主动发call,它只响应 client 的call。所以我们得用另一个工具——curl发送call。
关键:SSE 是 server push,call必须由 client 发起,但 client 怎么发?
答案是:用POST到一个独立的 endpoint。Spring AI 2 的McpServer默认不提供POST /mcp/call,但我们可以轻松加一个:
@RestController public class McpCallController { private final McpServer mcpServer; public McpCallController(McpServer mcpServer) { this.mcpServer = mcpServer; } @PostMapping("/mcp/call") public ResponseEntity<String> handleCall(@RequestBody String callJson) { try { // 解析 JSON 为 McpMessage McpMessage callMessage = new ObjectMapper().readValue(callJson, McpMessage.class); // 调用 server 处理 McpMessage resultMessage = mcpServer.handle(callMessage).block(); // 返回纯 JSON,供 curl 直接消费 return ResponseEntity.ok(new ObjectMapper().writeValueAsString(resultMessage)); } catch (Exception e) { return ResponseEntity.status(400).body("{\"error\":\"" + e.getMessage() + "\"}"); } } }然后用 curl 发送:
curl -X POST http://localhost:8080/mcp/call \ -H "Content-Type: application/json" \ -d '{"type":"call","id":"test2","name":"listFiles","arguments":{"path":"/tmp"}}'你会得到一个resultmessage 的 JSON。把它复制,粘贴到 Postman 的 SSE tab 里(Postman 会自动识别并显示为 event)。这就是“真调”的起点:你控制 client 发什么,server 回什么,全程可见。
4.5 第五步:在浏览器中用 EventSource 实现实时渲染
新建一个index.html:
<!DOCTYPE html> <html> <head><title>Filesystem MCP Demo</title></head> <body> <h2>Filesystem MCP Server Demo</h2> <button onclick="listFiles()">List /tmp Files</button> <button onclick="readFile()">Read /tmp/test.txt</button> <div id="output"></div> <script> let eventSource = null; function connect() { if (eventSource) eventSource.close(); eventSource = new EventSource("http://localhost:8080/mcp/sse"); eventSource.onmessage = function(event) { document.getElementById('output').innerHTML += '<p>[message] ' + event.data + '</p>'; }; eventSource.addEventListener('tool', function(event) { document.getElementById('output').innerHTML += '<p>[tool] ' + event.data + '</p>'; }); eventSource.addEventListener('result', function(event) { const data = JSON.parse(event.data); document.getElementById('output').innerHTML += '<p>[result id=' + data.id + '] ' + (data.content ? data.content.substring(0, 100) + '...' : 'no content') + '</p>'; }); eventSource.onerror = function(err) { console.error("EventSource error:", err); document.getElementById('output').innerHTML += '<p style="color:red">[ERROR] Connection lost</p>'; }; } function listFiles() { // 发送 call 到我们的 /mcp/call endpoint fetch('/mcp/call', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ "type": "call", "id": "list_" + Date.now(), "name": "listFiles", "arguments": {"path": "/tmp"} }) }).then(r => r.json()).then(data => { console.log("Got result:", data); }); } function readFile() { fetch('/mcp/call', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ "type": "call", "id": "read_" + Date.now(), "name": "readFile", "arguments": {"path": "/tmp/test.txt"} }) }); } connect(); // 页面加载时自动连接 </script> </body> </html>用python3 -m http.server 8000启动一个静态服务器,访问http://localhost:8000。点击按钮,你会看到output区域实时追加[result]内容——这就是“通过 SSE 流式输出实现大模型回答实时渲染”的最简实现。没有 React,没有 Vue,只有原生 JS 的EventSource。
实操心得:
EventSource默认每 3 秒重连一次。如果你在onerror里看到readyState: 0,别急着 reload,等几秒它会自动恢复。这是浏览器的健壮性设计,不是 bug。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 问题速查表:高频报错与根因定位
| 报错信息 | 可能根因 | 排查命令/步骤 | 解决方案 |
|---|---|---|---|
404 Not Foundfor/mcp/sse | spring-boot-starter-webflux未引入,或@EnableWebFlux冲突 | curl -I http://localhost:8080/actuator/health看是否是 WebFlux 健康检查 | 确保pom.xml只有spring-boot-starter-webflux,删除spring-boot-starter-web |
stream disconnected before completion: idle timeout waiting for sse | Nettymax-idle-time过短 | curl -v http://localhost:8080/mcp/sse,观察 connection 是否在 30 秒后关闭 | 在application.yml中设置server.reactive.max-idle-time: 300s |
SyntaxError: Unexpected end of JSON inputin browser console | response-buffer-size过小,JSON 被截断 | curl -H "Accept: text/event-stream" http://localhost:8080/mcp/sse | head -n 5,看data:行是否完整 | 增大spring.webflux.server.response-buffer-size: 65536 |
java.nio.file.AccessDeniedException | filesystem.mcp.allowed-paths未配置或路径不匹配 | ls -ld /tmp看权限,grep allowed-paths application.yml | 在application.yml中明确配置filesystem.mcp.allowed-paths: /tmp |
EventSource收不到tool事件 | McpServer未正确初始化,或McpToolRegistry为空 | 查看启动日志,搜索Registered 4 MCP tools | 确保spring.profiles.active=filesystem,且spring-ai-filesystem-mcp-server-spring-boot-starter在 classpath |
5.2 独家避坑技巧:来自 37 次重启的经验
技巧 1:用curl -N替代浏览器测试 SSE
浏览器的EventSource有缓存和重连策略,有时会掩盖问题。curl -N http://localhost:8080/mcp/sse的-N参数禁用 buffering,能最真实地看到 server 发送的原始 bytes。如果curl -N能收到tool,但浏览器收不到,100% 是浏览器 CORS 或 cache 问题。
技巧 2:McpMessage的id字段必须全局唯一,且不能重复使用call.id和result.id必须严格匹配。我曾把call.id写死为"1",连续点击两次按钮,server 收到两个id="1"的call,但只返回一个result.id="1",导致第二个 client 永远等不到响应。正确做法:id: "call_" + UUID.randomUUID().toString()。
技巧 3:stdion模式下,System.in的 EOF 会 kill serverStdioMcpServerHandler在读到EOF(Ctrl+D)时,会认为 client 退出,从而 shutdown server。这在脚本中很危险。解决方案:用cat命令代替echo,并用&后台运行:
# 错误:echo 会发送 EOF echo '{"type":"tool"}' | java -jar app.jar # 正确:cat 不会轻易 EOF,且可管道多条 { echo '{"type":"tool"}'; echo '{"type":"call","id":"1","name":"listFiles","arguments":{"path":"/tmp"}}'; } | java -jar app.jar技巧 4:McpTool的inputSchema必须是 valid JSON Schema,不能是简化版"properties": {"path": "string"}是错的,必须是"properties": {"path": {"type": "string"}}。Spring AI 2 的JsonSchemaValidator会严格校验。错误的 schema 会导致call被静默拒绝,server 日志里只有WARN,没有 ERROR。用 https://json-schema-validator.herokuapp.com 提前验证。
5.3 进阶调试:用 Wireshark 抓包分析 SSE 流
当一切看起来都对,但EventSource就是不触发onmessage,可能是底层 TCP 问题。用 Wireshark 抓包:
- 启动 Wireshark,过滤
tcp.port == 8080; - 在浏览器打开
http://localhost:8080/mcp/sse; - 观察 TCP stream,确认 server 是否真的发送了
event: tool\n\ndata: {...}\n\n; - 关键看:
data:行后是否有两个\n(即\n\n),以及Content-Typeheader 是否为text/event-stream。
我遇到过一次:Nginx 反向代理配置了proxy_buffering on,它把多个data:行合并成一个大 chunk 发送给浏览器,导致EventSource无法按行解析。解决方案:proxy_buffering off;。
5.4 生产就绪 checklist:从 demo 到上线的 5 个动作
- HTTPS 强制:SSE 在 HTTP 下会被现代浏览器降级或阻止。用
server.ssl.*配置 keystore,或前置 Nginx 做 TLS termination; - CORS 配置:
spring.webflux.cors.allowed-origins=https://your-frontend.com,避免EventSource被跨域拦截; - Rate Limiting:用
Resilience4j或 Spring Cloud Gateway 限制/mcp/sse的连接数,防 DDOS; - Health Check Endpoint:暴露
/actuator/mcp,返回{"status":"UP","tools":["listFiles","readFile"]},供 k8s liveness probe 使用; - Structured Logging:用
logstash-logback-encoder,字段包括mcp.message.type、mcp.message.id、mcp.tool.name、duration_ms,便于 APM 追踪。
我个人在实际操作中的体会是:filesystem MCP Server 的价值,从来不在它能做什么(读写文件),而在于它是一面镜子,照出你对整个 MCP 协议