1. 项目概述为什么 filesystem MCP Server 是 Spring AI 2 中最值得优先打通的“第一块砖”如果你最近在翻 Spring AI 的官方文档、GitHub Issues 或者社区讨论区大概率会频繁撞见filesystem MCP Server这个词——它不像OpenAIChatModel那样开箱即用也不像RAG那样自带光环但它却是整个 Spring AI 2 架构中真正体现“可插拔 AI 能力抽象”的关键锚点。我从去年底开始系统性地把 Spring AI 2 的每个 MCPModel 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 typestdio是进程间通信的 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 / stdio2.1 MCP 协议的本质不是 RPC而是“事件驱动的双向对话”很多人第一次看 MCP spec https://modelcontrolprotocol.com 时会下意识把它当成一个 RESTful API 设计POST /tools/listFiles → 200 OK → [{...}]。这是最大的认知偏差。MCP 的核心 message types 包括toolserver 主动向 client 声明自己支持哪些能力不是 client 问而是 server 主动广播callclient 发起一次工具调用请求含参数、idresultserver 对应call.id返回执行结果成功/失败notificationserver 主动推送非请求相关的事件如文件变更通知、agent 状态更新error标准化错误格式带code和message。注意tool和notification都是 server-initiatedclient 不需要主动轮询。这就决定了 transport 层必须支持 server push。HTTP/1.1 的长连接能勉强撑住但标准POSTJSON是典型的 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-mcpOllama daemon内存磁盘高需查 ollama logs❌模型推理细节掩盖协议逻辑langchain4j-mcpLLM APIOpenAI/Anthropic内存高网络不可控❌网络抖动干扰协议流playwright-mcpChrome 浏览器实例内存临时文件极高需 debug browser context❌UI 自动化逻辑远超协议本身filesystem-mcp无本地文件系统极低ls -l、cat直接验证✅所有输入输出都落盘可见filesystem的tool实现就是对java.nio.file.Files的封装。listFiles(/tmp)返回的ListPath会被McpToolResultSerializer序列化为标准 MCPresultmessagewriteFile(/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请求用FluxMcpMessage响应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 Server3.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.version3.2.5/spring-boot.version spring-ai.version2.0.0/spring-ai.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-mcp-server-spring-boot-starter/artifactId version${spring-ai.version}/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-filesystem-mcp-server-spring-boot-starter/artifactId version${spring-ai.version}/version /dependency !-- 日志增强方便追踪每条 MCP 消息 -- dependency groupIdnet.logstash.logback/groupId artifactIdlogstash-logback-encoder/artifactId version7.4/version /dependency /dependencies关键点必须用spring-boot-starter-webflux不能用spring-boot-starter-web。因为SseMcpServerHandler依赖WebFlux的Flux和ServerResponsespring-ai-mcp-server-spring-boot-starter是核心抽象提供McpServer、McpToolRegistry等接口spring-ai-filesystem-mcp-server-spring-boot-starter是具体实现它会自动配置FilesystemMcpServerbeanlogstash-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 声明toolmessageFilesystemMcpTool的getTool()方法返回一个McpTool对象其name为readFiledescription为Read the contents of a file at the given pathinputSchema是一个 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-size10485761MB避免读取 GB 级日志文件导致 OOM字符编码自动探测用java.nio.charset.CharsetDetector尝试 UTF-8、GBK、ISO-8859-1失败则 fallback 到 UTF-8 并记录 warn 日志。第三步Result 响应resultmessageinvoke()返回一个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 个 toolsheader 可能超限导致 connection reset。建议设为64KBspring: webflux: server: max-header-size: 655362.server.tomcat.connection-timeout仅 Tomcat orserver.reactive.max-idle-timeNetty这是stream disconnected before completion: idle timeout waiting for sse的罪魁祸首。Spring Boot 3.2 默认用 Netty其max-idle-time默认是 30 秒。一旦 client如浏览器在 30 秒内没收到任何 eventNetty 就 close connection。解决方案server: reactive: max-idle-time: 300s # 5分钟足够长3.spring.webflux.server.response-buffer-sizeSSE 的data:字段必须以\n\n结尾。如果McpMessage序列化后的 JSON 很长比如listFiles返回上千个文件Netty 的 buffer 可能截断导致data:不完整EventSource解析失败。增大 bufferspring: webflux: server: response-buffer-size: 65536注意这三个参数必须同时调整。我曾只调max-idle-time结果EventSource收到半截 JSON控制台报SyntaxError: Unexpected end of JSON inputdebug 了两天才发现是 buffer 太小。3.4 stdio 模式的实战技巧如何用echo和jq构建自动化测试脚本stdio模式不是玩具它是 CI/CD 中验证 MCP Server 行为的黄金标准。以下是我每天用的测试流程Step 1启动 server后台静默nohup java -jar filesystem-mcp.jar --spring.profiles.activestdio /dev/null 21 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. 实操过程与核心环节实现从启动到真调的完整 walkthrough4.1 第一步创建 Spring Boot 项目并添加依赖打开 start.spring.io 选择ProjectMavenLanguageJavaSpring Boot3.2.5DependenciesSpring 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.activefilesystem是关键。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 模拟 clientcurl -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点击 SendPostman 会保持连接并实时显示收到的 events。现在我们需要让 server 发送call响应。但filesystem-mcp默认不会主动发call它只响应 client 的call。所以我们得用另一个工具——curl发送call。关键SSE 是 server pushcall必须由 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 ResponseEntityString 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 headtitleFilesystem MCP Demo/title/head body h2Filesystem MCP Server Demo/h2 button onclicklistFiles()List /tmp Files/button button onclickreadFile()Read /tmp/test.txt/button div idoutput/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 stylecolor: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/ssespring-boot-starter-webflux未引入或EnableWebFlux冲突curl -I http://localhost:8080/actuator/health看是否是 WebFlux 健康检查确保pom.xml只有spring-boot-starter-webflux删除spring-boot-starter-webstream disconnected before completion: idle timeout waiting for sseNettymax-idle-time过短curl -v http://localhost:8080/mcp/sse观察 connection 是否在 30 秒后关闭在application.yml中设置server.reactive.max-idle-time: 300sSyntaxError: Unexpected end of JSON inputin browser consoleresponse-buffer-size过小JSON 被截断curl -H Accept: text/event-stream http://localhost:8080/mcp/sse | head -n 5看data:行是否完整增大spring.webflux.server.response-buffer-size: 65536java.nio.file.AccessDeniedExceptionfilesystem.mcp.allowed-paths未配置或路径不匹配ls -ld /tmp看权限grep allowed-paths application.yml在application.yml中明确配置filesystem.mcp.allowed-paths: /tmpEventSource收不到tool事件McpServer未正确初始化或McpToolRegistry为空查看启动日志搜索Registered 4 MCP tools确保spring.profiles.activefilesystem且spring-ai-filesystem-mcp-server-spring-boot-starter在 classpath5.2 独家避坑技巧来自 37 次重启的经验技巧 1用curl -N替代浏览器测试 SSE浏览器的EventSource有缓存和重连策略有时会掩盖问题。curl -N http://localhost:8080/mcp/sse的-N参数禁用 buffering能最真实地看到 server 发送的原始 bytes。如果curl -N能收到tool但浏览器收不到100% 是浏览器 CORS 或 cache 问题。技巧 2McpMessage的id字段必须全局唯一且不能重复使用call.id和result.id必须严格匹配。我曾把call.id写死为1连续点击两次按钮server 收到两个id1的call但只返回一个result.id1导致第二个 client 永远等不到响应。正确做法id: call_ UUID.randomUUID().toString()。技巧 3stdion模式下System.in的 EOF 会 kill serverStdioMcpServerHandler在读到EOFCtrlD时会认为 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技巧 4McpTool的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 terminationCORS 配置spring.webflux.cors.allowed-originshttps://your-frontend.com避免EventSource被跨域拦截Rate Limiting用Resilience4j或 Spring Cloud Gateway 限制/mcp/sse的连接数防 DDOSHealth 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 协议