Spring AI 应用开发实践课程教导站 · Spring AI 2.0 课程首页 速查
课程首页 / 第 8 章

CH 08MCP:模型上下文协议

第 7 章我们把工具写在了自己工程里;本章用 MCP 把工具变成跨进程、跨语言、可治理的标准服务——就像 HTTP 统一了网页、JDBC 统一了数据库访问。本章操作按「自建工程」口径逐步带你做:Server 单独建一个 mcp-order-server 工程,Client 代码加在 lab 的 ch08 包里。每一行命令都能直接复制,每一步都能独立验收;文中所有「看到什么算通过」都是在 Spring AI 2.0.1 + Boot 4.1.1 + JDK 17 + 中文 Windows 上真跑出来的原文,不是示意。

教学目标
掌握 MCP Server / Client 开发,理解三大原语与传输方式
教学重点
@McpTool 写 Server、Client 连接配置、工具显式注册到 ChatClient
教学难点
stdio 协议流污染、MCP 工具不自动注册的认知
建议学时
2 学时(1 学时讲 + 1 学时实操)

教学设计说明(教师参考):本章建议用「对比第 7 章」的方式切入——先让学生明白 MCP 不是新概念,而是把「进程内 @Tool」升级成「进程外标准服务」。实操上先走 stdio(零网络问题,10 分钟跑通),再讲远程 Streamable HTTP。教师务必反复强调两个 2.0 关键点:① 服务端注解是 @McpTool/@McpToolParam(包 org.springframework.ai.mcp.annotation),不是 @Tool;② MCP 工具不会自动注册到 ChatClient,必须显式传给 .defaultToolCallbacks(...),这是学生最容易栽的两个地方。

本章易掉队点(课前先点名,课上重点盯):

  • 注解认错包:用的是 org.springframework.ai.mcp.annotation 下的 @McpTool/@McpToolParam,不是第 7 章的 @Tool/@ToolParam。把本地工具和 MCP 工具搞混,工具永远不会被协议暴露。
  • 漏了显式注册:客户端即使连上了 Server,工具也只是「备好」而非「给模型」。必须调用 .defaultToolCallbacks(mcpTools)。而且漏写后的表现很不稳定——模型可能拒答,也可能一本正经地编一个答案(本章实测:同一个问题一次编「已发货」、一次诚实拒答)。所以「跑一次没出问题」不能当作通过,见 8.7 的反例警告。
  • stdio 协议流污染(本章第一大坑):很多人以为「关掉 Web 容器 + 禁掉 System.out」就干净了——远远不够。Spring Boot 自己的启动日志仍然往 stdout 写十几行,stdout(通信通道)照样被污染。正解是把 logback 的 ConsoleAppender 整体改到 System.err(8.4 节 ②′)。而且症状不是「报个解析错就算了」,而是 Client 握手 20 秒超时 → 主应用整个启动失败,那时你连 /tools 接口都没有,只能看启动栈。
  • ★ 拿 npx 当 Server 启动器(两层坑):① Windows 上 npx 是 npx.cmd,Java 的 ProcessBuilder 不经过 shell,直接报 CreateProcess error=2;② 就算用 cmd /c 绕过去,npx 的启动开销实测 96 秒,远超 MCP 的 20 秒初始化窗口 → 应用起不来。正解是 npm install 到本地后用 node 直跑入口文件(8.5 节差异一)。
  • 默认传输变了:2.0 默认传输是 Streamable HTTP,本地 stdio 必须显式 spring.ai.mcp.server.stdio=true,否则 Server 不走标准输入输出。
  • 客户端配置键漂移:2.0 用 yml 里的 stdio.connections.<名字>.command/args,不再以外部 JSON + servers-configuration 为主。抄旧教程的 JSON 写法会连不上。
  • 顺带提醒(仅作废弃语境,不展开):1.x 的 internalToolExecutionEnabled 在 2.0 已不存在,工具循环改由 ToolCallingAdvisor 接管;记忆相关的 PromptChatMemoryAdvisor 在 2.0 也已删除,对话记忆键一律用 ChatMemory.CONVERSATION_ID。本章不涉及记忆,遇到旧代码别照抄。

8.1MCP 解决什么问题:工具的「USB 接口标准」

先看没有 MCP 时的困境:每个 AI 应用都要把各系统的工具代码抄一遍,升级不同步、权限难管控、还受语言限制。

❌ 没有 MCP 的世界 AI 应用 A ─┬─ 需要查订单 → 抄一份 OrderService 的代码 ├─ 需要查库存 → 抄一份 InventoryService 的代码 └─ 需要发邮件 → 抄一份 MailService 的代码 AI 应用 B ─┴─ 上面三份代码……再抄一遍 问题:代码重复、升级不同步、权限难管控、语言不通(Python 写的工具 Java 用不了) ✅ 有 MCP 的世界 订单系统 ──▶ MCP Server(订单)─┐ 库存系统 ──▶ MCP Server(库存)─┼─▶ AI 应用 A / B / C …… 通过标准协议发现并调用 邮件系统 ──▶ MCP Server(邮件)─┘ 每个系统只暴露一次,所有 AI 应用复用;语言无关(Java/Python/Node 都能实现)

MCP 是什么:一层一层拆开讲

维度说明
是什么MCP(Model Context Protocol,模型上下文协议)是一套标准通信协议,规定「AI 应用」和「外部能力(工具/数据/提示词)」之间怎么握手、怎么发现能力、怎么传参数和结果。它和语言、框架无关,谁实现都行。
为什么没有标准,每接一个系统就要写一套私有对接代码;有标准,一个 Server 写一次,所有支持 MCP 的客户端复用。本质是把「工具对接」从「点对点硬编码」升级为「按规范插拔」。
没有会怎样工具代码在多个 AI 应用里被反复复制(见上方 ❌ 图),升级一处要改 N 处、权限散落在各处、Python 写的工具 Java 直接调不了。
类比就像 USB 接口:设备(工具)只要按 USB 规范做插头,任何带 USB 口的电脑(AI 应用)都能用;也像 HTTP 统一了网页访问、JDBC 统一了数据库访问。MCP 统一的是「AI 调用工具」这件事。

MCP 与第 7 章 @Tool 的关系

对比第 7 章 @Tool(进程内)MCP(进程外)
部署形态与 AI 应用同一个 JVM独立进程 / 独立服务
复用性只能本应用用✅ 所有支持 MCP 的应用都能用
语言Java✅ 任意(Java/Python/Node/Go…)
权限与审计混在应用代码里✅ 可以独立做鉴权、限流、审计
调用开销本地方法调用,纳秒级进程间/网络通信,毫秒级
适用项目内部、轻度使用✅ 企业级、多系统共享、需要治理
一句话选型建议:自己项目内部的简单工具,用 @Tool 就够; 一旦出现「多个 AI 应用要用同一批工具」或「工具由别的团队维护」,就该上 MCP。 两者不冲突,可以混用——MCP 提供的工具在 Spring AI 里最终也会变成 ToolCallback, 和内嵌工具用同一套机制。

MCP 协议长什么样:一次调用的真实报文

理解「stdio 模式 stdout 被污染就会解析失败」,前提是知道 MCP 在传什么。MCP 基于 JSON-RPC 2.0,一次「用户问订单 → 模型调工具」背后的报文大致是这样三步:

步骤方向报文(简化)
① 初始化握手Client → Server{"method":"initialize","params":{"protocolVersion":"2025-11-25",...}}
② 列工具Client → Server{"method":"tools/list"} → 返回工具名 / 描述 / 参数 schema
③ 调工具Client → Server{"method":"tools/call","params":{"name":"queryOrderStatus","arguments":{"orderNo":"2024001"}}}
协议版本号别背,看日志就够了。MCP 协议在快速演进,版本号随 SDK 升级而变。本课程实测(MCP Java SDK 2.0.0)双方协商结果是 2025-11-25;老教程里常见的 2025-06-18 已经被覆盖。协议版本不匹配不会导致连不上——SDK 会在 initialize 时双向协商,选双方都支持的最高版本。所以看到版本号和你预期不一致,不要当成故障去改配置,先看 8.5 节「看到什么算通过」里那条 Server response with Protocol: 日志。

关键点:这些报文全是纯文本 JSON,通过 stdout / stdin 一行一行传。一旦 Server 往 stdout 多吐了一行启动日志、或一个 System.out.println,Client 读到的就不是合法 JSON,先报 Unexpected character ('-'),随后握手超时、连接彻底建不起来。这就是为什么 8.4 节不仅要关 Web 容器、禁 System.out,还必须把 Boot 自己的启动日志改道到 stderr——不是为了好看,是为了不让通信电线里灌杂音。

厘清一个常见混淆:MCP ≠ 函数调用(Function Calling)。函数调用是「模型输出一段『我要调哪个函数、参数是什么』的结构化结果」的能力,由模型厂商定义;MCP 是「怎么把工具安全、标准地交给模型、并在进程外执行」的传输与治理协议。两者处于不同层:函数调用是「模型侧的约定」,MCP 是「应用 ↔ 工具侧的管道」。Spring AI 里,MCP Server 暴露的工具最终也是通过函数调用机制交给模型的——MCP 解决的是「管道和治理」,而不是「模型怎么决策调工具」。
一句话记忆(8.1):MCP 是「工具的 USB 接口」——把进程内的 @Tool 变成进程外、跨语言、可治理的标准服务;只在「多应用共享 / 需要独立权限治理」时才上 MCP。

8.2架构与三大原语

三个角色:Host / Client / Server

角色职责本课程的对应实现
Host承载 AI 应用与用户界面的一方(你的 Spring Boot 应用 / Claude Desktop / IDE)我们的 springai-lab 主应用
ClientHost 内部的连接器,负责与某个 Server 建立 1:1 连接、协议握手、收发消息McpSyncClient / McpAsyncClient
Server暴露能力的一方,声明「我提供哪些工具 / 资源 / 提示词」我们写的 MCP Server 应用

把三个角色的关系再讲一遍「为什么这样分」:

维度说明
是什么一个 Host(你的 AI 应用)内部会有 N 个 Client,每个 Client 一对一连一个 Server;Server 独立部署、独立暴露能力。
为什么把「连接管理」和「能力提供」解耦:Server 只管把能力说清楚,Client 只管跟某一个 Server 打交道,Host 把两者拼起来喂给模型。
没有会怎样如果 Host 直接硬编码连每个系统,系统一多就变成一团乱麻,换一个 Server 就要改 Host 代码。
类比Host 是你家(AI 应用),Client 是各个房间的「专用插座」,Server 是外面的「发电厂」。插座只负责跟某一个厂对接,你(模型)插上就能用电,不用关心电厂怎么发电。

Server 端 vs Client 端职责对照

职责Server 端(提供方)Client 端(使用方)
核心任务把工具/资源/提示词声明出去,接收调用并返回结果连上 Server,发现能力,把工具包装好交给模型
关键注解 / 类@McpTool / @McpToolParamMcpSyncClient / SyncMcpToolCallbackProvider
谁来启动独立进程(stdio 由 Host 拉起,或独立 Web 服务)由 Host 的 Spring 容器按配置自动建立连接
最易踩的坑stdio 模式污染协议流、忘了 @Component忘了显式注册工具到 ChatClient、配置键写错

Server 能暴露的三种能力(三大原语)

原语含义典型例子谁发起
Tools
最常用
可执行的「动作」,有参数、有返回值 查询订单、发送邮件、创建日程 模型决定
Resources 可读取的「数据」,像文件一样按 URI 访问 file:///docs/a.md、db://orders/2024001 应用决定
Prompts 预定义的「提示词模板」,可带参数 「代码审查模板」「周报生成模板」 用户/应用选择
初学阶段只关注 Tools。Resources 和 Prompts 在 Spring AI 里的封装相对简单, 生产项目里 95% 的场景都是用 Tools。本章的动手部分也只做 Tools,把机制吃透比贪多更有价值。
三大原语选择速记:90% 的场景只用 Tools(让模型「做动作」)。Resources 是「读数据」(按 URI 像读文件),Prompts 是「提示词模板」(用户 / 应用主动选)。初学阶段遇到「要调接口、查库、发消息」就写 Tool,别纠结另外两个。
Sync 还是 Async?McpSyncClient vs McpAsyncClient。同步客户端的方法直接返回结果(listTools()、callTool(...)),写起来像普通 Java 方法,教学和最常用;异步客户端返回 Mono / Flux,适合高并发响应式应用。二者一一对应:Server 端 type: SYNC 配同步,客户端注入 SyncMcpToolCallbackProvider;选了异步就注入 AsyncMcpToolCallbackProvider。初学一律用 Sync,别给自己加难度。
一句话记忆(8.2):Host 是 AI 应用,Client 一对一连 Server,Server 暴露 Tools/Resources/Prompts 三大原语;初学只盯 Tools,其余两个先放一边。

8.3三种传输方式:怎么连

传输方式决定了「Host 和 Server 之间用什么通道通信」。先给一张总表,再逐个拆开讲。

传输方式原理优点缺点适用
stdio Host 以子进程方式启动 Server,通过标准输入输出通信 零网络配置、最简单、延迟极低 只能本机、Server 必须能被 Host 启动 本地工具
桌面应用
SSE
旧
HTTP 长连接,服务端通过 Server-Sent Events 推送 可跨网络、可被反向代理 需要维护长连接、部分代理不友好 早期实现
Streamable HTTP
推荐
标准 HTTP 请求 + 流式响应,无状态可扩展 ✅ 最容易部署到 K8s、可水平扩展、易做网关鉴权 需要独立部署一个服务 企业级
远程 MCP

stdio:本地联调的首选,但必须理解「stdout 就是电线」

维度说明
是什么Host 像启动子进程一样拉起 Server,双方通过标准输入 stdin / 标准输出 stdout 收发 JSON-RPC 消息。没有端口、没有 HTTP。
为什么零网络配置,本机一条命令就能跑通,最适合「先跑通再谈部署」的教学与本地开发。
没有会怎样没有 stdio 你就得先搞定网络、端口、CORS、反向代理,而那不是本章重点。直接上 HTTP 会让你在「连不上」上浪费大量时间。
类比stdout 就是 Server 和 Host 之间的「电线」。任何往 stdout 多写一点的东西(启动日志、System.out.println)都等于往电线里灌杂音,对端收到的就不再是合法报文。

SSE 与 Streamable HTTP:远程形态

维度说明
SSE 是什么HTTP 长连接,服务端用 Server-Sent Events 持续往客户端推消息。曾是远程 MCP 的早期方案。
Streamable HTTP 是什么标准 HTTP 请求 + 流式响应,无状态、可水平扩展,是 2.0 的默认传输。
为什么推荐后者无状态意味着前面可以放普通网关/负载均衡做鉴权和扩缩容;SSE 的长连接对反向代理不友好。
没有会怎样(选错)选 SSE:部分代理会断流、扩缩容麻烦;把 stdio 当成远程方案:跨机器根本连不上。
课程建议路线:先用 stdio 把「Server 写出来 + Client 连上」跑通(10 分钟搞定,零网络问题), 再改成 Streamable HTTP 部署成独立服务。 直接上 HTTP 会让你在「连不上」的问题上浪费大量时间,而那不是本章要学的重点。

怎么选传输方式:一张决策表

你的场景选哪个
本地先把流程跑通、单机工具、桌面应用stdio(零网络,10 分钟跑通)
工具要被多个机器 / 多个服务共享、要上云、要网关鉴权Streamable HTTP(2.0 默认,推荐)
已有基于 SSE 的旧服务、短期不想改SSE(但已 deprecated,新项目别选)
想验证「跨语言复用」(如用官方 Node 版 filesystem Server)stdio 或 Streamable HTTP 都行,看部署位置

课程实操顺序:先 stdio 跑通整条链路,再(选做)把 Server 改成 Streamable HTTP 独立部署。不要一上来就远程,否则你会花大量时间在「端口 / 代理 / 跨域」上,而不是学 MCP 本身。

stdio 子进程的生命周期:谁启动、谁关闭?stdio 模式下,不是你手动启动 Server——是 Host(主应用)在连接时自动把 Server 当作子进程拉起的(看你 yml 里配置的 command / args)。所以:① 你只需保证 jar 路径正确、能被 java -jar 拉起;② 主应用正常退出时,框架会一并结束这些子进程;③ 不要在外面单独再 java -jar 启一个 Server——那样会变成两个实例,客户端连的是它自己拉起的那一个。理解这点,你就明白为什么「Server 必须常驻、不能自己退出」。
★ 实测补充(这条坑很贵):子进程回收只在「正常退出」成立,启动失败时它不会被回收。 如果 Client 因为 stdout 污染 / 路径写错等原因启动失败,它拉起的 Server 子进程会残留在后台,继续锁着 target/*.jar。下一次你跑 mvn clean package 就报:
[ERROR] Failed to execute goal ... : Cannot delete ...\target\mcp-order-server-1.0.0.jar
        (Windows 上是「另一个程序正在使用此文件,进程无法访问」)
这不是 maven 的问题,是残留子进程的问题。清理办法(PowerShell,按命令行关键字找):
# 列出所有命令行里带 mcp-order-server 的 java 进程
Get-CimInstance Win32_Process -Filter "Name='java.exe'" |
  Where-Object { $_.CommandLine -like '*mcp-order-server*' } |
  Select-Object ProcessId, CommandLine

# 确认无误后按 Id 逐个结束(把 12345 换成上面查到的 ProcessId)
Stop-Process -Id 12345 -Force
同理,调试期间反复启停主应用是残留的高发场景:每次 Ctrl+C 前先想一下这次是不是「正常退出」。
Streamable HTTP 为什么叫「无状态」?每次请求都自带完整上下文,Server 不在内存里维护「这个客户端上次调了啥」,因此可以随便放在负载均衡后面、随便扩缩容——多起几个 Server 实例,流量分摊即可。代价是它失去了 MCP 原生的「服务端主动推消息给客户端」的双向能力(那需要长连接)。教学里用 stdio 或单实例 Streamable HTTP 都无所谓,理解「无状态 = 好扩展」就够了。
一句话记忆(8.3):stdio 走「标准输入输出」、零网络最适合本地联调且 stdout 就是通信电线;远程用 Streamable HTTP(2.0 默认),SSE 已过时别选。

8.4写一个 MCP Server

下面从零建一个独立工程 mcp-order-server。本章无配套 lab 代码,所以全程「自建」:所有路径、类名、片段都按下面逐步操作,不要照抄别的教程的目录。

① 新建工程并加依赖(建议独立工程)

MCP Server 通常做成独立应用,不要和主应用混在一起——这样它才能被多个 AI 应用共享,也便于独立部署与权限治理。

  1. 打开 start.spring.io,按下表选择:

    选项选择
    Project / LanguageMaven / Java
    Spring Boot4.1.x(不要选 3.x)
    Group / Artifactcom.example / mcp-order-server
    Package namecom.example.mcporderserver
    Packaging / JavaJar / 17
    Dependencies先不加任何依赖(MCP starter 不在初始选项里,我们手动加)
  2. Generate 下载 zip,解压到 D:/springai-lab/mcp-order-server,用 IDEA 打开(File → Open → 选中含 pom.xml 的那层)。

  3. 打开 pom.xml,把下面片段完整粘贴进去(替换默认的依赖段,并补上 spring-ai-bom 的 dependencyManagement)。注意版本基准:Spring AI 2.0.1 + Boot 4.1.x + Java 17。

    <!-- 新建工程 mcp-order-server,pom 片段(stdio 形态) -->
    <parent>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-parent</artifactId>
      <version>4.1.1</version>
    </parent>
    
    <dependencies>
      <!-- 就这一个依赖,够了 -->
      <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server</artifactId>
      </dependency>
    
      <!-- ★ 不要加 spring-boot-starter-webmvc! -->
      <!--   stdio 形态没有 HTTP 入口,加了只会启动一个用不上的 Tomcat, -->
      <!--   多一条往 stdout 打日志的路径(见 ②′ 的 logback 一节)。 -->
      <!--   要改远程 Streamable HTTP 形态时再换坐标,见 8.8 末尾。 -->
    </dependencies>
    
    <dependencyManagement>
      <dependencies>
        <dependency>
          <groupId>org.springframework.ai</groupId>
          <artifactId>spring-ai-bom</artifactId>
          <version>2.0.1</version>
          <type>pom</type>
          <scope>import</scope>
        </dependency>
      </dependencies>
    </dependencyManagement>
  4. 保存,等右下角 Maven 同步完成(首次约 1~3 分钟)。

为什么明明「能跑」也要把 webmvc 去掉?很多教程(包括本章的早期版本)在 stdio Server 里顺手加了 spring-boot-starter-webmvc,再靠 spring.main.web-application-type: none 把它关掉——这写法确实能跑通,但等于给自己埋了两颗雷: ① 依赖树上多一个 Web 容器,将来有人误删那行 web-application-type,Tomcat 启动横幅立刻灌进 stdout; ② 排查协议流污染时,你永远要先排除「是不是 Web 容器打的日志」。 stdio 形态的 Server 根本不需要 Web 能力,不引就是最干净的隔离。

② stdio 模式:一个配置文件 + 一个工具类

  1. 在 src/main/resources/ 下右键 → New → File,文件名精确填 application.yml(若已存在就直接打开)。把下面内容整段粘贴覆盖:

    # src/main/resources/application.yml(本地联调用 stdio)
    spring:
      main:
        # ★ 关掉 Web 容器。注意:这只是第一步,不是全部 —— 见 ②′
        web-application-type: none
      ai:
        mcp:
          server:
            name: order-mcp-server        # 服务名,客户端会看到(会出现在 serverInfo.name 里)
            version: 1.0.0
            # ★ 2.0 变更:显式打开 stdio。Streamable HTTP 已成为默认传输,
            #    所以本地用 stdio 时必须显式声明,否则 server 不会走标准输入输出。
            stdio: true
            # 执行类型:SYNC(同步)/ ASYNC(异步,返回 Mono/Flux)
            type: SYNC
            # 是否把工具变更通知给客户端
            tool-change-notification: true
★ 只写上面这段 yml,Server 的 stdout 仍然是脏的(本章最贵的一课)。 实测结论:web-application-type: none 只是让 Tomcat 不再启动,Spring Boot 自己的启动日志一共还有 13 行照旧写进 stdout—— 包括 Starting McpOrderServerApplication ...、No active profile set、几条 BeanPostProcessorChecker 的 WARN、 Started McpOrderServerApplication in 2.435 seconds。 这些行对人是正常日志,对 MCP 协议全是垃圾报文。

②′ 关键一步:把 Boot 的启动日志整体改道到 stderr(不做这步后面全废)

printf 时代的经验在这里失效——你以为「不打印」就行了,实际上 Boot 默认的日志输出目标就是 stdout,而 stdout 就是 MCP 的电线。唯一干净的做法:加一个 logback-spring.xml,把 ConsoleAppender 的 target 从 System.out 改成 System.err。这样人的日志照旧能看到(stderr 在终端里一样显示),协议流只走 stdout,互不干扰。

  1. 在 src/main/resources/ 下右键 → New → File,文件名精确填 logback-spring.xml(不要叫 logback.xml,那样会绕过 Boot 的 profile 支持)。
  2. 粘贴下面内容:
<?xml version="1.0" encoding="UTF-8"?>
<!-- src/main/resources/logback-spring.xml -->
<!-- ★★ 这个文件的唯一使命:把控制台日志从 stdout 挪到 stderr。 -->
<!--    stdio 模式下 stdout 是 MCP 的通信通道,任何人类可读日志写进去都会破坏协议。 -->
<configuration>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <target>System.err</target>   <!-- ★ 关键:默认是 System.out,必须改成 System.err -->
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>

</configuration>
为什么不用 spring.main.banner-mode: off 之类的小开关凑合?banner 只是那一行 ASCII 图案,关掉它 stdout 里还有 12 行日志。 也不用「把 root level 调到 ERROR」——那是把日志全扔掉,出了问题你连 Server 起没起来都看不到。 target=System.err 是唯一「日志照旧、协议干净」的方案,而且 Host 端还能看到这些 stderr:SDK 会把它转发成 STDERR Message received: ... 打进主应用日志——这正好成了「Server 真的被拉起来了」的证据(见 8.5 节)。
反例实验(务必做一次,5 分钟,胜过读十遍):把 logback-spring.xml 临时改名(比如加个 .bak), 重新 mvn clean package 打包,然后启动主应用。你会看到应用在约 20 秒后直接启动失败。 这次失败有三个可确认的特征(按关键字搜,不要逐字对照——异常文本随 SDK 小版本会变):
# 特征 1:stdout 里混进了人类日志,客户端逐行解析时先在这里报错
Unexpected character ('-') ...            <!-- 因为日志行以时间戳/短横线开头 -->

# 特征 2:等待握手的时长恰好是 20 秒(SDK 默认 initializationTimeout)
搜关键字:timed out        <!-- 通常伴随 "after 20000ms" 之类的字样 -->

# 特征 3:最终死在 MCP 客户端自动配置上,应用没起来
搜关键字:MCP client       <!-- 表现为 Bean 创建失败 / Application run failed -->
搜关键字:APPLICATION FAILED TO START
看完记得把文件名改回来、重新打包。这个反例解释了本章最重要的一个认知:协议流污染的后果不是「工具调不通」,而是「应用根本起不来」——连 /tools 都没得调。
2.0 把默认传输换成了 Streamable HTTP。官方说明是「Streamable HTTP is now the default, replacing the deprecated SSE transport」,STDIO 保留给本地进程式集成。
所以在 2.0 里:
  • 本地联调:显式设 spring.ai.mcp.server.stdio=true(如上)。
  • 远程部署(推荐形态):换成 spring-ai-starter-mcp-server-webmvc, 用 spring.ai.mcp.server.protocol=STREAMABLE;也有 STATELESS 变体, 适合放在反向代理后面做水平扩展(代价是失去 MCP 的双向通信能力)。
  • SSE:已被标记为 deprecated,新项目不要再选它。

③ 启动类

  1. 在包 com.example.mcporderserver 上右键 → New → Java Class,名称精确填 McpOrderServerApplication,把下面内容粘贴进去:
// McpOrderServerApplication.java
@SpringBootApplication
public class McpOrderServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(McpOrderServerApplication.class, args);
    }
}

④ 写工具类:用 @McpTool,不是 @Tool

  1. 在包 com.example.mcporderserver 上右键 → New → Java Class,名称精确填 OrderMcpTools。
  2. 把下面完整内容粘贴进 OrderMcpTools.java 并保存。重点看两处:注解是 @McpTool / @McpToolParam(包 org.springframework.ai.mcp.annotation),类上有 @Component 让它成为 Spring bean。
// OrderMcpTools.java —— 注意:用 @McpTool,不是第 7 章的 @Tool!
@Component
public class OrderMcpTools {

    /**
     * ★ 2.0 变更:服务端暴露 MCP 能力要用 @McpTool / @McpToolParam,
     *   而不是给本地工具用的 @Tool / @ToolParam。两者语义不同:
     *   @Tool     = 让本应用的模型调用(第 7 章)
     *   @McpTool  = 通过 MCP 协议暴露给外部客户端
     *   包路径(2.0 起 MCP 注解已并入 Spring AI 本体):
     *     org.springframework.ai.mcp.annotation.McpTool
     *     org.springframework.ai.mcp.annotation.McpToolParam
     */
    @McpTool(description = """
            查询指定订单号的当前状态与预计发货时间。
            当用户询问发货情况、物流状态时调用。
            不要用于查询退款进度或账户余额。
            """)
    public OrderStatus queryOrderStatus(
            @McpToolParam(description = "订单号,纯数字字符串,例如 2024001")
            String orderNo) {
        // 这里可以是真实数据库查询。教学演示用模拟数据。
        return switch (orderNo) {
            case "2024001" -> new OrderStatus(orderNo, "未发货", "2024-03-08");
            case "2024002" -> new OrderStatus(orderNo, "已发货", "-");
            default -> new OrderStatus(orderNo, "NOT_FOUND", "-");
        };
    }

    @McpTool(description = "列出某个客户最近 5 笔订单的订单号与状态")
    public List<OrderStatus> listRecentOrders(
            @McpToolParam(description = "客户姓名") String customerName) {
        return List.of(
                new OrderStatus("2024001", "未发货", "2024-03-08"),
                new OrderStatus("2023129", "已完成", "-"));
    }

    public record OrderStatus(String orderNo, String status, String promisedDate) {}
}

⑤ 关键一步:让 Server 发现你的工具

2.0 起这一步变得很简单:不用写任何配置类。 只要工具类是个 Spring bean 且方法上标了 @McpTool, MCP Server 的自动配置会通过注解扫描把它自动注册出去:

@Component          // ★ 必须是 Spring bean,否则扫描不到
public class OrderMcpTools {

    @McpTool(description = "查询指定订单号的当前状态与预计发货时间")
    public OrderStatus queryOrderStatus(
            @McpToolParam(description = "订单号,例如 2024001") String orderNo) {
        // …… 业务逻辑
    }
}
// 就这样,不需要 @Configuration,不需要 ToolCallbackProvider。

注解扫描的包范围可以配置(默认扫应用主类所在包及其子包)。如果你确实需要程序化注册 (例如工具来自第三方对象、或要按环境动态决定暴露哪些),再退回到 Provider 写法:

@Configuration
public class McpServerConfig {

    @Bean
    public ToolCallbackProvider orderToolsProvider(OrderMcpTools orderMcpTools) {
        return MethodToolCallbackProvider.builder()
                .toolObjects(orderMcpTools)
                .build();
    }
}
★ 陷阱:上面的 MethodToolCallbackProvider 只认 @Tool,不认 @McpTool。 它收集的是「第 7 章那种本地工具注解」。你要是把它指向本章这个只有 @McpTool 方法的 OrderMcpTools,结果是收集到 0 个工具、且不报错——Server 起来了,但 tools/list 是空的。
所以本章的 mcp-order-server 不需要、也不应该创建这个 McpServerConfig: 注解扫描已经把两个 @McpTool 方法注册好了(启动日志 Registered tools: 2 / 探针 tools/list 返回 2 个即可确认)。 这节课记住一句:不是「配一个 Provider 更保险」,而是「多配一个 Provider 可能把工具清零」。
2.0 还给了「控制暴露面」的开关。MCP Server 支持过滤哪些工具对外可见—— 用一个 McpToolFilter bean 按服务标识、工具名或描述筛选。 这在多租户或分级授权的场景里很关键:不要把内部管理类工具无意中暴露给所有客户端。 另外还有 spring.ai.mcp.server.expose-mcp-client-tools 控制是否把本应用作为客户端拿到的工具再对外暴露。
说明:本章(只暴露两个订单工具)不需要这两个开关,所以课程里没有实测它们的效果。真要用,先按官方文档核对当前版本的 bean 签名,别照抄网上的旧示例。

运行验证 ①:打包并确认 Server 能起

  1. 打开 IDEA 底部 Terminal(或系统终端,先 cd 到工程目录),执行:

    # 进入工程目录(按你的实际路径)
    cd D:/springai-lab/mcp-order-server
    
    # 打包,跳过测试加快速度
    mvn clean package -DskipTests

    看到什么算通过:末尾出现 BUILD SUCCESS,且 target/ 下生成 mcp-order-server-1.0.0.jar(约 22 MB,是带依赖的 fat jar)。

    [INFO] BUILD SUCCESS
    [INFO] ------------------------------------------------------------------------
    [INFO] Total time:  12.345 s          <!-- 时间因机器而异,只看 BUILD SUCCESS -->
    [INFO] mcp-order-server-1.0.0.jar 已生成于 target/
  2. 先验一条确定型判据:stdout 必须是 0 字节。这一步比 Inspector 更重要,因为它能在你接客户端之前就把污染问题挡掉。把工程的 stdout / stderr 分别重定向到两个文件,跑 8 秒后强制结束:

    # Git Bash(IDEA 默认终端是 PowerShell,等价写法在下面)
    cd D:/springai-lab/mcp-order-server
    timeout 8 java -jar target/mcp-order-server-1.0.0.jar > out.txt 2> err.txt
    wc -c < out.txt     # ★ 必须是 0
    wc -l < err.txt     # 应该是 6 行左右(启动日志全在这里)
    
    # PowerShell 等价写法
    # $p = Start-Process java -ArgumentList '-jar','target\mcp-order-server-1.0.0.jar' `
    #        -RedirectStandardOutput out.txt -RedirectStandardError err.txt -PassThru
    # Start-Sleep 8; Stop-Process -Id $p.Id -Force
    # (Get-Item out.txt).Length      # 必须 0
    # (Get-Content err.txt).Count    # 约 6 行

    看到什么算通过(本章实测原文):out.txt 0 字节 0 行,err.txt 6 行。这两条同时成立,才说明「协议电线」是干净的、人与机器各走各的通道。若 out.txt 非空,把它打印出来看看是什么——90% 是 Boot 日志,说明 ②′ 的 logback-spring.xml 没生效(文件名写成了 logback.xml?没放在 src/main/resources/?忘了重新 mvn package?)。

  3. 用官方 Inspector(网页调试工具)连上它,确认工具能被发现并调用:

    # 在 mcp-order-server 工程目录下执行
    npx @modelcontextprotocol/inspector java -jar target/mcp-order-server-1.0.0.jar

    看到什么算通过:终端提示打开了网页(通常 http://localhost:6274),在页面里能看到 order-mcp-server 声明的两个工具 queryOrderStatus / listRecentOrders,填参数 2024001 调用能返回 未发货。这代表 Server 本身完全正常。

    注:这里 npx 是你在终端里手工敲的,Windows 下完全正常(终端会经过 shell 解析 npx.cmd)。但 8.5 节要把它写进 yml 让 Java 去拉起时,不能用 npx——除了拉不起来,它 96 秒的启动耗时也过不了 20 秒初始化窗口,详见 8.5 的差异一。

stdio 模式的四个致命坑(验收前先自查):
  1. ★ 以为「关掉 Web 容器就干净了」:这是本章最大的认知偏差。web-application-type: none 只挡住 Tomcat, Spring Boot 自己的 13 行启动日志仍然写进 stdout,协议照样被污染。
    正解:必须加 ②′ 的 logback-spring.xml,把 ConsoleAppender 的 target 设为 System.err。
    判据:上面「out.txt 必须 0 字节」,不是「看起来没报错」。
  2. 在工具方法里 System.out.println:同样是污染协议流,而且更隐蔽——只有调用该工具时才炸, 表现为「工具调一次成功、第二次超时」这类诡异现象。 调试请一律用 log(配合 ②′ 已改道到 stderr)。
    排查:全局搜索 System.out,替换为日志框架。
  3. 启动太快退出:stdio 模式下 Server 必须保持运行等待输入, 如果 main 里没有任何阻塞机制,进程会直接结束。用 Spring Boot 的常驻模式即可(不要手动 System.exit)。
  4. ★ 搞不清楚「污染」到底长什么样:它不是「客户端提示连不上」,而是 Unexpected character ('-') → 握手 20 秒超时 → McpClientAutoConfiguration 创建 Bean 失败 → 主应用根本起不来。 这时你没有 /tools 可调,只能读栈。记住这个顺序,能省一小时。

若 mvn 报插件要求更高 Maven 版本:确认本地 Maven ≥ 3.6.3(见第 0 章 0.5 节),用 mvn -v 核对;若机器有多个 Maven,用 where mvn(Windows)定位实际解析到哪个,优先用项目 mvnw 或删除旧版本后重开终端。

「stdout 干净」这件事,肉眼靠不住,要靠字节数。很多同学在终端里看到「屏幕上好像没什么输出」就以为干净了——但 IDE 的运行窗口把 stdout 和 stderr 混在一起显示,你根本分不清。 只有把两个流重定向到不同文件、再看 stdout 的字节数,才是可断言、可复现的判据。这也是本章反复强调的「确定型预期输出」:不是「看起来对」,而是「等于 0」。
为什么要「独立工程」而不是把 Server 写进主应用?因为 MCP 的价值正是「一个 Server 被多个 AI 应用共享、独立治理」。如果 Server 和主应用揉在一个工程里,它就成了主应用的一部分,既没法被别的应用复用,也享受不到「独立部署 / 独立权限 / 独立扩缩容」。所以 8.4 让你新建 mcp-order-server 独立工程——哪怕教学里只有主应用连它,这个「独立」的姿势也是生产级的正确姿势。
一句话记忆(8.4):Server 端用 @McpTool/@McpToolParam(包 org.springframework.ai.mcp.annotation)+ @Component,2.0 自动扫描注册;stdio 模式不引 web starter、并必须用 logback-spring.xml 把日志改道 stderr——判据是 stdout 0 字节。

8.5写一个 MCP Client(连上 Server)

客户端跑在主应用 springai-lab 里(它既是 Host 也是 Client)。下面逐步加依赖、写配置、写调试服务。

① 主应用加依赖

  1. 打开主应用 D:/springai-lab 的 pom.xml。
  2. 在 <dependencies> 里粘贴下面这段(starter 坐标以官方为准,不要编造版本,交给 spring-ai-bom 管理):
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>

② 声明要连接的 Server 列表(2.0 推荐 yml 写法)

  1. 打开主应用的 src/main/resources/application.yml,在 spring.ai.mcp.client 下粘贴下面的 stdio 连接配置。

先给你看一份备选的外部 JSON 写法(了解即可,本章以 yml 为准):

{
  "mcpServers": {
    // ① 我们自己的订单 MCP Server(stdio:本地启动一个 Java 进程)
    "order-server": {
      "command": "java",
      "args": [
        "-jar",
        "D:/springai-lab/mcp-order-server/target/mcp-order-server-1.0.0.jar"
      ]
    },

    // ② 官方文件系统 Server(Node 实现,演示跨语言复用)
    "filesystem": {
      "command": "npx",      // ★ 别这么写:npx 当子进程启动器会拉不起 / 超时,改用 node 直跑(见 8.5 差异一)
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "D:/springai-lab/data"          // ★ 授予访问的目录白名单
      ]
    },

    // ③ 远程 Streamable HTTP Server(生产形态)
    "remote-weather": {
      "url": "http://localhost:8081/mcp",
      "type": "STREAMABLE",
      "headers": {
        "Authorization": "Bearer ${MCP_TOKEN}"
      }
    }
  }
}

2.0 推荐写法:直接在 yml 里声明每个 stdio 连接,不再需要外部 JSON 文件,粘贴到主应用 application.yml。

★ 动手之前先做一件事:把官方的 Node Server 装到工程内的固定目录。 不要在 yml 里写 npx —— 它会让你在第 8.5 节的验证里撞两次墙(Windows 拉不起 + 启动 96 秒超时,详见下方 warn)。 正确姿势是先离线装好包,再让 Java 用 node 直接跑它的入口文件:
# 建一个固定的「MCP Servers 安装区」,只做一次
mkdir D:/springai-lab/mcp-servers
cd D:/springai-lab/mcp-servers
npm init -y
npm install @modelcontextprotocol/server-filesystem

# 装完确认入口文件存在(路径就是下面 yml 里要填的那个)
dir node_modules\@modelcontextprotocol\server-filesystem\dist\index.js
这条命令会联网下载一次(几十秒,可接受);装完之后每次启动 Server 只需 0.5 秒,而且不依赖 npx 缓存、不受网络影响。
为什么值得多这一步:MCP 的初始化窗口是硬编码的 20 秒,npx 的启动开销(包解析 + registry 元数据请求)本身就超过 20 秒——这不是「网络好就没事」,而是「网络再好也不够快」。实测数据见下方 warn。
# ★ 2.0 推荐写法:直接在 yml 里声明每个 stdio 连接,不再需要外部 JSON 文件
spring:
  ai:
    mcp:
      client:
        enabled: true              # 默认即 true(写出来是给人看的)
        name: springai-lab
        version: 1.0.0
        # 默认即 true:启动时把所有 Server 一次性连上
        # 只有显式写 false 才会变成「首次使用时才连」的惰性模式
        initialized: true
        # 默认即 true:把 MCP 工具包装成 ToolCallback
        # ★ 注意「包装」≠「交给模型」,后者必须手写,见 8.6 节
        toolcallback:
          enabled: true
        # ★ 单次工具调用超时。注意它在 client 层,不在 stdio 层!
        #   写进 stdio: 下面不会被识别(Spring 对未知键默认静默忽略)
        request-timeout: 60s
        stdio:
          connections:
            # ---- 我们自己的订单 MCP Server ----
            order-server:
              command: java
              args:
                - -jar
                - D:/springai-lab/mcp-order-server/target/mcp-order-server-1.0.0.jar
            # ---- 官方文件系统 Server(Node 实现) ----
            # ★★ 不要写 npx!用 node 直接跑本地装好的入口文件(原因见下方 warn)
            #    路径里的 mcp-servers 就是上面 npm install 的那个目录
            filesystem:
              command: node
              args:
                - D:/springai-lab/mcp-servers/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js
                - D:/springai-lab/data          # ★ 授权目录白名单
★★ 差异一:npx 不能当「被程序拉起的子进程」启动器——两个坑,都会让应用直接起不来。
  1. 坑 1(Windows 专属):根本拉不起来。 npx 在 Windows 上实际是 npx.cmd。你在终端里敲 npx 能用,是因为 cmd.exe / PowerShell 帮你做了扩展名解析; 但 MCP Client 是用 Java 的 ProcessBuilder 直接 CreateProcess,不经过 shell,于是报:
    java.io.IOException: Cannot run program "npx": CreateProcess error=2, 系统找不到指定的文件。
    这时有人会改成 command: cmd + args: [/c, npx, ...] 把它绕过去——但绕过去只是进了第二个坑。
  2. 坑 2(全平台):就算拉起来了,它也太慢。 MCP SDK 的初始化握手有硬编码 20 秒上限,而 npx 每次启动都要做包解析 / registry 元数据请求。实测(2026-10-22,同一台机、npm 缓存已热):
    启动方式到「ready」耗时结果
    npx -y @modelcontextprotocol/server-filesystem96 秒❌ 必然超时
    cmd /c npx --offline -y ...仍 > 20 秒❌ 依然不够
    node <包>/dist/index.js0.47 秒✅ 稳定
    超时的表现不是「工具为空」,而是整个应用启动失败:
    Caused by: java.util.concurrent.TimeoutException:
        Did not observe any item or terminal signal within 20000ms in 'map'
            at io.modelcontextprotocol.client.LifecycleInitializer.lambda$withInitialization$2(...)
正解(也就是上面 yml 的写法):把包 npm install 到工程内固定目录,command 写 node,args 指向 dist/index.js。 这不是「性能优化」,而是「能否启动」的问题。
小提示:node 是真正的 .exe,ProcessBuilder 可以直接拉起(不像 npx.cmd 需要 shell); 万一报 Cannot run program "node",说明 PATH 里没有它,把那行改成 command: C:/Program Files/nodejs/node.exe 这样的绝对路径即可(用 where node 查)。
macOS / Linux 用户注意:坑 1 不存在,但坑 2 一样存在——别以为换个系统就能继续用 npx。
★★ 差异二:initialized 和 toolcallback.enabled 的默认值都是 true。 很多教程把它们写成「必须打开」,暗示默认关闭——实测元数据默认就是 true,写不写行为一致。 真正会把它们变成 false 的,只有你自己显式写 false。
所以当有人遇到「工具为空」时,不要第一反应去加这两个开关(加了也没变化,反而排除错了方向)—— 先看 8.5 运行验证里的工具数,再看 8.6 的显式注册。
★ 差异三:request-timeout 的位置,以及它管不到启动超时。
  • 位置:正确键是 spring.ai.mcp.client.request-timeout(在 client 层,与 stdio 同级)。 写在 stdio: 下面属于未知键,Spring 默认静默忽略——不报错,也不生效,最容易造成「我明明调大了还是超时」。
  • 作用域:它只管单次 tools/call 等请求。初始化握手超时是另一回事: 底层 MCP Java SDK 的 initializationTimeout 默认 20 秒,Spring AI 2.0.1 没有把这个值暴露到 yml。 也就是说——Server 启动慢导致的 20 秒超时,改任何 yml 都救不了。
  • 那怎么办?改 yml 没用,只能让 Server 自己快起来。 而「慢」的根源几乎总是启动器:npx 每次启动都要重新做包解析 / registry 元数据请求, 实测 96 秒——注意:npm 缓存已经是热的也一样慢,所以「预热缓存」救不了。
    唯一稳的解:把包装到本地、用 node 直接跑入口文件(实测 0.47 秒,见差异一)。 这也是为什么 8.5 节让你先执行一次 npm install 再写 yml。
2.0 的配置写法变了两处,抄旧代码会不生效:
  • 从「外部 JSON 文件 + servers-configuration」改为 yml 里的 stdio.connections.<名字>.command/args。 上面的 mcp-servers.json 仍可作为备选方案保留,但以 connections 为准。
  • 远程 Server 用 url + type(STREAMABLE / STATELESS)声明, 通过 headers 带鉴权信息。
排查口诀:连不上就先看配置键是否被识别——用 IDEA 展开 spring.ai.mcp.client 看当前版本支持哪些键,比翻文档快。
配置键名在各版本间有微调。如果启动时报 mcp-servers.json 找不到或连接没建立, 优先用 IDEA 的 yml 自动补全把 spring.ai.mcp.client 展开看一遍当前版本支持的键, 再对照官方文档。这类「配置漂移」在新兴协议里很常见,属于正常现象。

③ 用代码直接操作 MCP 客户端(了解机制)

  1. 在主应用包 com.example.springai.ch08 上右键 → New → Java Class,名称填 McpInspectService。
  2. 粘贴下面内容。它注入 List<McpSyncClient> 拿到全部连接,提供「列出工具」和「手工调工具」两个方法——这是 MCP 调试的黄金顺序。

容器里每个已配置的 Server 都会有一个 McpSyncClient bean。注入 List<McpSyncClient> 就能拿到全部连接,先手工列工具、再手工调工具——这是 MCP 调试的黄金顺序。

下面这份代码比「只往日志里打」多做了一件事:把结果结构化返回。 日志适合看,JSON 适合断言。/tools 直接返回 clientCount / toolCount, 你就能用一句话判断「连了几个、有没有工具」,而不是在一屏日志里数行数——这是本章所有验收都能"通过/不通过"可判的基础。
@Service
public class McpInspectService {

    private static final Logger log = LoggerFactory.getLogger(McpInspectService.class);

    /**
     * 容器里每个已配置的 Server 都会有一个 McpSyncClient bean。
     * 用 List 注入可以拿到全部。
     */
    private final List<McpSyncClient> clients;

    public McpInspectService(List<McpSyncClient> clients) {
        this.clients = clients;
    }

    /** 连接数:yml 里配了几个 Server 就该有几个;0 说明一个都没建起来 */
    public int clientCount() {
        return clients.size();
    }

    /** 列出所有 Server 及其工具,返回结构化结果便于断言 */
    public List<Map<String, Object>> listAllTools() {
        List<Map<String, Object>> servers = new ArrayList<>();
        for (McpSyncClient client : clients) {
            // 服务端自报的名字/版本(不是你在 yml 里起的连接别名!)
            McpSchema.Implementation info = client.getServerInfo();
            List<McpSchema.Tool> tools = client.listTools().tools();

            log.info("=== MCP Server: {} v{} === 工具数={}",
                    info.name(), info.version(), tools.size());

            List<Map<String, Object>> toolList = new ArrayList<>();
            for (McpSchema.Tool t : tools) {
                log.info("  工具: {}  描述: {}", t.name(), t.description());
                toolList.add(Map.of("name", t.name(),
                        "description", String.valueOf(t.description())));
            }

            Map<String, Object> one = new LinkedHashMap<>();
            one.put("server", info.name());
            one.put("version", info.version());
            one.put("initialized", client.isInitialized());   // 握手完成了吗
            one.put("tools", toolList);
            servers.add(one);
        }
        return servers;
    }

    /** 手工调用一个工具(不经过模型)——用于验证 Server 端逻辑是否正确 */
    public String callToolManually(String toolName, Map<String, Object> args) {
        for (McpSyncClient client : clients) {
            boolean has = client.listTools().tools().stream()
                    .anyMatch(t -> t.name().equals(toolName));
            if (has) {
                McpSchema.CallToolResult result =
                        client.callTool(new McpSchema.CallToolRequest(toolName, args));
                log.info("[manual-call] {}({}) -> isError={}", toolName, args, result.isError());
                return result.content().toString();
            }
        }
        throw new IllegalArgumentException("所有已连接的 Server 都没有工具:" + toolName);
    }
}

运行验证 ②:主应用启动,确认连上 Server

  1. 确认 mcp-order-server 已打包(target/*.jar 存在),且 Node 已安装、filesystem 包已 npm install 到本地目录(8.5 ② 开头那一步)。然后启动主应用:

    # 在主应用目录下
    cd D:/springai-lab
    mvn spring-boot:run

    看到什么算通过(★ 注意:别去搜「Connected to MCP server」——那行字符串实测出现 0 次):旧教程(含本章早期版本)让你盯 Connected to MCP server 'xxx',而 2.0.1 的日志里根本没有这句话。照着搜会误判成「没连上」,白折腾。真正的判据是下面四条同时成立:

    # ① 子进程被拉起(每个连接一行,这里应该出现 2 次)
    ... i.m.c.transport.StdioClientTransport : MCP server started
    
    # ② Server 自己的日志被转发到 Host(证明 jar 真的跑起来了,不是假连上)
    ... i.m.c.transport.StdioClientTransport : STDERR Message received: ... Starting McpOrderServerApplication v1.0.0 using Java 17.0.12 ...
    ... i.m.c.transport.StdioClientTransport : STDERR Message received: ... Started McpOrderServerApplication in 2.435 seconds ...
    ... i.m.c.transport.StdioClientTransport : STDERR Message received: Secure MCP Filesystem Server running on stdio
    
    # ③ 协议握手成功:版本 + 对端服务名(★ 这条信息量最大)
    ... i.m.client.LifecycleInitializer : Server response with Protocol: 2025-11-25,
        Capabilities: ... Info: Implementation[name=order-mcp-server, title=null, version=1.0.0, ...]
    
    # ④ 工具真的被取出来了(8.6 节那行自打的日志)
    ... c.e.springai.ch08.McpChatClientConfig : 已从 MCP Server 加载 16 个工具

    其中 ② 那一堆 STDERR Message received: ... 正是 8.4 节 ②′ 把日志改道 stderr 带来的附加收益:协议走 stdout 不互相干扰,而人的日志通过 stderr 被 Host 转发进主应用日志——跨进程调试第一次变得可读。

  2. 另开一个终端,调一个运维接口把工具清单打印到日志(接口实现见 8.7 节,这里先验证连接本身):

    # 触发工具清单打印(若此接口尚未实现,可暂时在 main 里调用 listAllTools())
    curl http://localhost:8080/api/v1/mcp-assistant/tools

    看到什么算通过(本章实测原文,工具数要精确对上):

    ... c.e.springai.ch08.McpInspectService : === MCP Server: order-mcp-server v1.0.0 === 工具数=2
    ... c.e.springai.ch08.McpInspectService :   工具: listRecentOrders  描述: 列出某个客户最近 5 笔订单的订单号与状态
    ... c.e.springai.ch08.McpInspectService :   工具: queryOrderStatus  描述: 查询指定订单号的当前状态与预计发货时间。
    ... c.e.springai.ch08.McpInspectService : === MCP Server: secure-filesystem-server v1.0.0 === 工具数=14

    ★ 两处易错点:① 第二个 Server 的名字是 secure-filesystem-server,不是 filesystem(filesystem 只是你在 yml 里给这个连接起的别名);② filesystem Server 当前版本带 14 个工具(read_file 已标 DEPRECATED,还有 read_text_file / list_directory_with_sizes / search_files 等),总数是 16,不是旧教程说的 6~8 个。工具数会随 npm 包升级而变,别把数字写死在作业里——要写就写「2 + N」。

「先手工调通,再交给模型」是 MCP 调试的黄金顺序。 listAllTools() 确认工具被发现了,callToolManually() 确认 Server 端逻辑正确, 最后才接入模型。这样出问题时你能立刻判断是「连接问题」「Server 问题」还是「模型选择问题」。
客户端连不上 / 工具为空的头号原因(按真实踩坑频率排序):
  1. ★★ 「20 秒超时」有两个完全不同的原因,先分清再动手。两者报错一样(都是 did not observe any item ... within 20000ms),但修法相反:
    原因怎么认出来怎么修
    Server 的 stdout 被日志污染超时之前能看到该连接的 STDERR Message received: 有内容,且 stdout 里混着人类日志加 logback-spring.xml(8.4 ②′),判据 stdout 0 字节
    Server 启动就超过 20 秒MCP server starting 之后什么都没有——连 stderr 都是空的(进程还没起来,自然没日志)换启动器:npx → node <包入口>(见差异一)
  2. ★ 用了 npx 当启动器:Windows 上先报 CreateProcess error=2;改成 cmd /c npx 后仍会因 96 秒启动而超时。 不要修它,直接换成 node 直跑(见差异一)。
  3. jar 路径错:order-server 的 args 里 jar 路径不存在。先用 java -jar D:/springai-lab/mcp-order-server/target/mcp-order-server-1.0.0.jar 手工跑一遍确认能起。
  4. 配置键没被识别:2.0 把连接配置放在 stdio.connections.* 下,且 request-timeout 在 client 层。用 IDEA 把 spring.ai.mcp.client 展开核对,别照抄旧版 JSON 为主的写法。

关于 initialized:默认就是 true,别被旧文档误导。网上(包括本章早期版本)有「initialized: false 是惰性连接,要设 true 才不会首次调用卡顿」的说法——方向对,前提错:实测元数据默认值就是 true,所以你从没遇到过「首次请求卡几秒」,正是因为默认已经是一次性建连。真正的坑在反方向:如果有人为了「加快启动」把它改成 false,Server 路径写错时要等到第一次调用才暴露,排查难度立刻上一个台阶。结论:保持默认(true),并且不需要在 yml 里写这一行;写出来只是为了让人读懂。

关于配置里的「远程 Server」示例:上面 yml 的 remote-weather(url + type: STREAMABLE)只是展示「远程怎么声明」,本章实操不要求它跑通——因为它需要一个真实存在的远程 MCP 服务在 http://localhost:8081/mcp 监听。如果你想验证远程形态,先按 8.8 末尾把 order-server 改成 Streamable HTTP 独立部署,再把 order-server 的 stdio 配置换成对应的 url / type 即可,其余代码零改动。
一句话记忆(8.5):Client 在主应用里,靠 yml 的 stdio.connections.<名字>.command/args 声明要连的 Server——启动器别用 npx(Windows 拉不起 + 启动 96 秒 > 20 秒窗口),Node 写的 Server 一律 npm install 到本地再用 node 直跑入口;每个 Server 对应一个 McpSyncClient bean,先 listTools 再 callTool 是调试黄金顺序;启动日志里找 Server response with Protocol:,不是找 Connected to MCP server。

8.6把 MCP 工具交给模型:和第 7 章无缝衔接

工具发现流程:从 Server 到模型的四步

步发生了什么对应代码
1Server 启动,自动扫描 @McpTool bean,声明自己有哪些工具OrderMcpTools 上的注解
2Client 按 yml 配置连上 Server,listTools() 拿到工具清单McpSyncClient
3框架把每个 MCP 工具包装成 ToolCallback,汇总进 SyncMcpToolCallbackProvidertoolcallback.enabled(默认 true)
4你必须显式把 Provider 里的工具传给 .defaultToolCallbacks(...),模型才用得了McpChatClientConfig

spring.ai.mcp.client.toolcallback.enabled(默认就是 true,不用手写)打开时, Spring AI 会把所有 MCP Server 提供的工具自动包装成 ToolCallback, 然后你就能像使用本地 @Tool 一样把它们交给模型。

★ 把「包装」和「注册」彻底分开,这是本章最核心的认知:
  • 包装(自动):toolcallback.enabled=true(默认)→ 框架把 MCP 工具变成 ToolCallback[],放进 Provider。到这一步,工具只是「备好了」,模型还看不见。
  • 注册(必须手写):.defaultToolCallbacks(mcpTools) → 工具真正进入某一次对话的工具清单。不写这行,模型手里一个工具都没有。
顺带纠正一个常见误解:toolcallback.enabled 默认就是 true,所以「工具为空」时去加这个开关不会解决任何问题——它本来就没关。真正的缺口永远在第二步。
  1. 在主应用包 com.example.springai.ch08 上右键 → New → Java Class,名称填 McpChatClientConfig。
  2. 粘贴下面内容。注意注入的是 SyncMcpToolCallbackProvider(异步客户端则注入 AsyncMcpToolCallbackProvider),并显式调用 .defaultToolCallbacks(mcpTools)。
@Configuration
public class McpChatClientConfig {

    /**
     * ★ 关键 bean:Spring AI 会把所有 MCP Server 的工具汇总成这个 Provider,
     *   它是"MCP 世界"和"Spring AI 工具世界"之间的桥梁。
     *
     * ★★ 2.0 最容易踩的坑:MCP 工具【不会】自动注册到 ChatClient 上。
     *    框架刻意这么设计——避免一个应用无意间把上百个外部工具全塞给模型。
     *    你必须显式地把它传给 .defaultTools(...) 或 .tools(...),就像下面这样。
     */
    @Bean("mcpAssistantClient")
    public ChatClient mcpAssistantClient(
            ChatModel chatModel,
            // ★ 2.0 的具体类型是 SyncMcpToolCallbackProvider
            //   (异步客户端则注入 AsyncMcpToolCallbackProvider)
            SyncMcpToolCallbackProvider mcpToolCallbackProvider) {   // 自动装配

        // 取出全部 MCP 工具(本质是 ToolCallback[],与第 7 章完全同类型)
        ToolCallback[] mcpTools = mcpToolCallbackProvider.getToolCallbacks();

        // 打印出来确认连上了几个工具——启动日志里能看到
        org.slf4j.LoggerFactory.getLogger(McpChatClientConfig.class)
                .info("已从 MCP Server 加载 {} 个工具", mcpTools.length);

        return ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是"智汇"智能助手,可以访问文件系统和订单系统。

                        规则:
                        1. 涉及真实数据(文件、订单)时必须调用工具。
                        2. 需要读取或写入文件时,先确认路径在允许范围内。
                        3. 工具报错时,向用户说明原因并给出建议。
                        """)
                .defaultToolCallbacks(mcpTools)     // ★ 显式注册 MCP 工具(不写这行模型就用不了)
                .build();
    }
}

运行验证 ③:启动日志确认工具已加载

  1. 重启主应用(若已在运行先停掉),观察启动日志:

    # 主应用目录
    cd D:/springai-lab
    mvn spring-boot:run

    看到什么算通过:日志中出现 已从 MCP Server 加载 N 个工具,其中 N = 订单 Server 的 2 个 + filesystem Server 的 14 个 = 16(实测值;filesystem 的工具数随 npm 包版本变化,别把 16 当常量背下来)。这条日志来自上面 McpChatClientConfig 里的 info(...),它证明「MCP 工具已被取出并准备好了」。

    ... c.e.springai.ch08.McpChatClientConfig : 已从 MCP Server 加载 16 个工具
    ... c.e.springai.ch08.McpChatClientConfig :   - listRecentOrders  (列出某个客户最近 5 笔订单的订单号与状态)
    ... c.e.springai.ch08.McpChatClientConfig :   - queryOrderStatus  (查询指定订单号的当前状态与预计发货时间。...)
    ... c.e.springai.ch08.McpChatClientConfig :   - read_file  (Read the complete contents of a file as text. DEPRECATED: ...)
    ... <!-- 后面还有 13 行,一行一个工具 -->
    
    # ★ 建议像上面这样顺手打印工具名:出问题时一眼能看出「少了哪个 Server 的工具」
    #   只打数字的话,14 和 16 你分不清是谁挂了一个
这就是 MCP 最漂亮的地方:对模型和业务代码来说,MCP 工具和本地工具没有任何区别。 模型看到的都是「工具名 + 描述 + 参数 schema」,调用链路都是 ToolCallback。 你甚至可以混用:.defaultTools(localTools) 再 .defaultToolCallbacks(mcpTools), 模型根本分不出哪个是本地的、哪个是远程的。这就是协议标准化的力量。
「MCP 工具没生效」的排查顺序(★ 别再从 toolcallback.enabled 查起):
  1. 先看数字:启动日志里「已从 MCP Server 加载 N 个工具」。N=0 → 连接/包装环节断了(回 8.5);N>0 但模型不用 → 注册或描述环节(第 2、3 条)。
  2. 确认 chatClient 上真的调用了 .defaultToolCallbacks(mcpTools)(或 .tools(...)),且你问的那个 client bean 就是它——工程里有多个 ChatClient 时,很容易请求打到了没挂工具的裸 client。
  3. 确认工具描述写得具体(何时用、做什么),而不是「处理订单相关」这种含糊话。
  4. 若 N 一直是 0,再检查 SyncMcpToolCallbackProvider 是否被正确注入(异步客户端要注入 AsyncMcpToolCallbackProvider,类型不匹配会注入失败)。toolcallback.enabled 不用查——默认就是 true。

顺带纠正一个过时认知(仅废弃语境):1.x 里的 internalToolExecutionEnabled 在 2.0 已不存在,工具循环改由 ToolCallingAdvisor 接管——不要在 2.0 的配置里找这个开关,也别在代码里设置它。同样地,记忆相关的 PromptChatMemoryAdvisor 在 2.0 也已删除,对话记忆键统一用 ChatMemory.CONVERSATION_ID(本章不涉及记忆,遇到旧代码别照抄)。

本地 @Tool 与 MCP 工具:注册方式对照

环节第 7 章本地 @Tool本章 MCP 工具
工具来源本工程里的 @Tool 方法外部 Server 经协议暴露
交给模型的方式.defaultTools(...)取出 ToolCallback[] 后 .defaultToolCallbacks(...)
是否自动注册由 @Tool 扫描自动进 ChatClient否,必须显式传(2.0 铁律)
模型看到的工具名 + 描述 + schema完全相同,无差别

记住:本地 @Tool 和 MCP 工具最终都变成 ToolCallback,所以你完全可以把两者混着交给模型——本地一个、远程一个,模型分不清。唯一区别就是「MCP 那个需要你多写一行显式注册」。

注入 SyncMcpToolCallbackProvider 失败 / 为 null 的两类原因:
  1. 类型不匹配:同步客户端就注入 SyncMcpToolCallbackProvider;若工程里混入了异步模式或你写成了 AsyncMcpToolCallbackProvider,Spring 找不到对应类型会直接启动报错。确认 yml 没开异步、注入类型与客户端一致。
  2. 工具根本没被发现:若所有 Server 都没连上(路径错 / Node 没装),Provider 可能是空集合——此时 getToolCallbacks() 返回长度 0 的数组,不报错但模型用不了工具。先用 8.5 的 /tools 确认连接和工具数。
工具描述写不好,模型就不调它——这是「模型不用工具」的头号原因。把 @McpTool(description=...) 当成「给模型看的说明书」来写:① 说清它做什么(查订单状态);② 说清何时用(用户问发货 / 物流时);③ 说清何时不用(不要用于退款 / 余额)。像 8.4 的 queryOrderStatus 那样写,模型命中率会明显更高。模糊的「处理订单相关」描述,模型往往选择忽略。
一句话记忆(8.6):MCP 工具不会自动进 ChatClient——框架把工具备成 ToolCallback 后,你必须显式 .defaultToolCallbacks(...),这是 2.0 头号铁律。

8.7完整示例:MCP 文件助手

做一个小助手:既能用我们自己写的订单 MCP Server,也能用官方文件系统 MCP Server(跨语言复用,Server 是 Node 写的)。重点是验证「MCP 工具要显式注册、且权限边界在 Server 端」。

springai-lab/ # 主应用(MCP Host + Client) ├── src/main/java/com/example/springai/ch08/ │ ├── McpChatClientConfig.java # 装配带 MCP 工具的 ChatClient │ ├── McpInspectService.java # 工具清单 / 手工调用(8.5 节) │ ├── FileAssistantService.java # 业务服务 │ └── McpAssistantController.java # HTTP 接口 └── src/main/resources/ └── application.yml # MCP Client 配置(8.5 节) mcp-order-server/ # 独立工程(MCP Server) ├── src/main/java/.../McpOrderServerApplication.java ├── src/main/java/.../OrderMcpTools.java └── src/main/resources/ ├── application.yml # stdio: true / type: SYNC(8.4 ②) └── logback-spring.xml # ★★ 日志改道 stderr(8.4 ②′,缺它必挂)

端到端数据流:一次问答怎么走到 Server

把 8.7 的完整链路在脑子里过一遍,出问题才知道断在哪一层:

你发 curl /ask → FileAssistantService.ask() → ChatClient(带 MCP 工具)把问题 + 工具清单发给模型 → 模型决定调用 queryOrderStatus(或 list_directory) → ToolCallingAdvisor 拦截,本地执行该 ToolCallback → SyncMcpToolCallbackProvider 把调用转给对应 McpSyncClient → 经 stdio / HTTP 发给 order-server / filesystem Server → Server 执行业务逻辑,返回结果 → 结果回填给模型,模型组织成自然语言 → 返回给你的 curl

动手 checkpoint:把 order-server 的 jar 路径(D:/springai-lab/mcp-order-server/target/...)和 filesystem 的白名单目录(D:/springai-lab/data)改成你机器上的真实路径,否则 8.7 第 ②③④ 步会连不上或越权判定失效。路径错了不是 MCP 的问题,是配置问题。

① 先构建并验证 Server(回顾)

8.4 节已经做过这一步,这里再给一次完整命令,确保 jar 就绪:

# 打包 MCP Server
cd D:/springai-lab/mcp-order-server
mvn clean package -DskipTests

# 预期输出:BUILD SUCCESS,并在 target/ 下生成 mcp-order-server-1.0.0.jar

# ★ 先用 8.4 运行验证 ① 的两个动作确认它「干净且能用」:
#   1) 分流运行,stdout 必须 0 字节(否则主应用一定起不来)
#   2) 用 Inspector 连一次,确认两个工具能列出、能调用
# 这两步过了再往下,能省掉后面一半的排查时间。

② 业务服务(含安全边界)

  1. 在包 com.example.springai.ch08 上右键 → New → Java Class,名称填 FileAssistantService。
  2. 粘贴下面内容。它注入名为 mcpAssistantClient 的 ChatClient(即 8.6 节配的那个),把用户问题交给模型。
@Slf4j
@Service
public class FileAssistantService {

    private final ChatClient mcpAssistantClient;
    private final ChatModel chatModel;          // 给下面的反例方法用

    public FileAssistantService(
            @Qualifier("mcpAssistantClient") ChatClient client,
            ChatModel chatModel) {
        this.mcpAssistantClient = client;
        this.chatModel = chatModel;
    }

    public String ask(String question) {
        long start = System.currentTimeMillis();
        String answer = mcpAssistantClient.prompt()
                .user(question)
                .call()
                .content();
        log.info("[mcp-ask] {}ms question={} answerLen={}",
                System.currentTimeMillis() - start, question,
                answer == null ? 0 : answer.length());
        return answer;
    }

    /**
     * ★ 反例实验专用:同一个模型、但【不挂任何工具】。
     *   拿来和 ask() 的结果对比,验证 8.6 的铁律(见 8.7 ④ 与「反例不稳定」)。
     */
    public String askWithoutTools(String question) {
        return ChatClient.builder(chatModel)
                .defaultSystem("你是智能助手,可以查询订单状态与文件系统。")  // ← 故意的,见下方 warn
                .build()
                .prompt()
                .user(question)
                .call()
                .content();
    }
}
★★ 差异:教材早期版本在这里写 SecurityContextHolder.currentUserId(),而这段代码根本编译不过,更严重的是思路本身就错了。 两层问题:
  1. 没有这个类。SecurityContextHolder.currentUserId() / currentTenantId() 是本章虚构的便利方法(第 7 章也踩过同样的坑)。要用 Spring Security 就得自己包一层 SecurityContextHolder.getContext(), 而本章工程压根没引 Spring Security。
  2. ★ 就算你把它换成能编译的 .toolContext(Map.of(...)),对 MCP Server 也毫无意义。 因为 ToolContext 只活在本进程的内存里:它的读取者是「进程内的 ToolCallback 执行器」。 而 MCP 的工具是在另一个进程里执行的,tools/call 报文里没有 toolContext 这个字段—— Server 端读不到、也没法读。第 7 章那套「用 ToolContext 传身份」的经验,跨进程直接失效。
那 MCP 里身份怎么传?只能走传输层:
  • Streamable HTTP:在 headers 里带 Authorization: Bearer <token>,Server 端从 HTTP 请求头取——这是唯一「真正的鉴权通道」。
  • stdio:没有鉴权通道。子进程由 Host 亲手拉起,权限等于「宿主进程权限」。要让 stdio Server 区分租户, 只能退化到「把身份拼进参数」(queryOrderStatus(orderNo, tenantId))——但那是模型能看见、能篡改的参数,不是安全边界,只适合教学演示。
这也解释了 8.7 第 ④ 步为什么那么重要:MCP 的安全边界只能建在 Server 侧(目录白名单、HTTP 头鉴权),而不是「让模型自觉带上正确身份」。

③ 接口(含一个运维接口,用于排查工具注册情况)

  1. 在包 com.example.springai.ch08 上右键 → New → Java Class,名称填 McpAssistantController。
  2. 粘贴下面内容。四个端点各有分工:/tools 看「连了几个、有多少工具」、/call 绕过模型直接调工具、/ask 走模型、/ask-no-tools 是反例对照组。
@RestController
@RequestMapping("/api/v1/mcp-assistant")
public class McpAssistantController {

    private final FileAssistantService assistant;
    private final McpInspectService inspect;

    public McpAssistantController(FileAssistantService assistant,
                                  McpInspectService inspect) {
        this.assistant = assistant;
        this.inspect = inspect;
    }

    /** 端点总表:排错时先看这一张,别急着怀疑工具描述 */
    @GetMapping("/endpoints")
    public Map<String, Object> endpoints() {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("GET /tools", "已连接 Server 及其工具(结构化,可断言)");
        m.put("GET /call?tool=&arg=", "手工调工具,不经过模型");
        m.put("POST /ask", "走模型(ChatClient 已挂 MCP 工具)");
        m.put("POST /ask-no-tools", "反例:裸 ChatClient,验证「不显式注册就用不了」");
        return m;
    }

    /**
     * ★ 排查「工具没注册上」的最快手段:直接返回结构化结果,不靠读日志。
     *   clientCount 应为 yml 里配的 Server 数;toolCount 应为各 Server 工具数之和。
     *   生产环境记得加管理员权限。
     */
    @GetMapping("/tools")
    public Map<String, Object> tools() {
        List<Map<String, Object>> servers = inspect.listAllTools();
        int total = servers.stream()
                .mapToInt(s -> ((List<?>) s.get("tools")).size())
                .sum();
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("clientCount", inspect.clientCount());
        m.put("toolCount", total);
        m.put("servers", servers);
        return m;
    }

    /** 手工调用:把「连接问题」与「模型不选工具」彻底分开 */
    @GetMapping("/call")
    public Map<String, Object> call(@RequestParam String tool,
                                    @RequestParam(defaultValue = "2024001") String arg) {
        Map<String, Object> args = tool.startsWith("queryOrderStatus")
                ? Map.of("orderNo", arg)
                : Map.of("customerName", arg);
        return Map.of("tool", tool, "args", args,
                "result", inspect.callToolManually(tool, args));
    }

    @PostMapping("/ask")
    public Map<String, String> ask(@RequestBody Map<String, String> body) {
        return Map.of("answer", assistant.ask(body.get("question")));
    }

    @PostMapping("/ask-no-tools")
    public Map<String, String> askNoTools(@RequestBody Map<String, String> body) {
        return Map.of("answer", assistant.askWithoutTools(body.get("question")));
    }
}

④ 端到端验证(四个用例)

下面四个用例覆盖「注册成功 / 远程调用 / 跨语言 / 越权拦截」。第 ④ 步是本章最有价值的验证。

  1. 先确认工具真的注册上了(调 8.5 已配好的 /tools):

    curl http://localhost:8080/api/v1/mcp-assistant/tools

    看到什么算通过(确定型判据):JSON 里 "clientCount": 2、"toolCount": 16,且 servers 数组里两个元素的 server 分别是 order-mcp-server 与 secure-filesystem-server、initialized 都是 true。

    {
      "clientCount": 2,
      "toolCount": 16,
      "servers": [
        { "server": "order-mcp-server", "version": "1.0.0", "initialized": true,
          "tools": [ { "name": "listRecentOrders", ... }, { "name": "queryOrderStatus", ... } ] },
        { "server": "secure-filesystem-server", "version": "1.0.0", "initialized": true,
          "tools": [ { "name": "read_file", ... }, /* …共 14 个 */ ] }
      ]
    }

    三处常见误判:① 把 secure-filesystem-server 当成「配错了名字」——它是 Server 自报的名字,filesystem 只是你在 yml 里起连接别名;② 把 toolCount=16 背成常量——它随 npm 包版本变化,断言请用「2 + N」;③ 看到 initialized=false 就以为要改配置——那说明握手没完成,去看 8.5 的启动日志。

  2. 用远程 MCP 工具查订单(模型不知道这是远程的):

    curl -X POST http://localhost:8080/api/v1/mcp-assistant/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"订单 2024001 发货了吗?"}'

    看到什么算通过(实测原文):答案包含「未发货」与「2026-10-28」,说明模型调用了 queryOrderStatus 并把结果组织成了自然语言。同时主应用日志里应出现两个进程各自的痕迹(缺一个都说明没真正跨进程):

    # Host 侧:模型确实发了这一次请求
    ... c.e.springai.ch08.FileAssistantService : [mcp-ask] 2007ms question=订单 2024001 发货了吗? answerLen=58
    
    # Server 侧:工具在【另一个进程】里被执行(这是「真的跨进程」的确凿证据)
    ... i.m.c.transport.StdioClientTransport : STDERR Message received: ... [OrderMcpTools] queryOrderStatus 被调用 orderNo=2024001

    ★ 别只看 JSON 答案。模型完全可能在没调工具的情况下编一个「已发货」出来(8.7 末尾的反例会演示),所以「答案对不对」不是判据,「Server 侧有没有那行日志」才是判据。

  3. 用文件系统 MCP 工具(跨语言:Server 是 Node 写的):

    curl -X POST http://localhost:8080/api/v1/mcp-assistant/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"列出 D:/springai-lab/data 目录下的所有文件"}'

    看到什么算通过:答案里能列出你白名单目录下真实存在的文件名(本章环境是 readme.txt 与 orders.csv,换成你自己建的目录就以实际为准)——证明 Java 主应用成功驱动了一个 Node 实现的 Server。这一条的价值是语言无关性:整个链路里 Java 只认协议,不认实现语言。

  4. 越权测试:让文件助手读白名单外的目录:

    curl -X POST http://localhost:8080/api/v1/mcp-assistant/ask \
      -H "Content-Type: application/json" \
      -d '{"question":"帮我读一下 C:/Windows/System32/drivers/etc/hosts 文件"}'

    看到什么算通过(实测原文,注意错误是 Server 侧返回的):MCP 工具调用抛错,主应用日志出现:

    ERROR ... o.s.ai.mcp.SyncMcpToolCallback : Error calling tool: [TextContent[...,
        text=Access denied - path outside allowed directories:
        C:\Windows\System32\drivers\etc\hosts not in D:\springai-lab\data, ...]]

    而给用户的回答是一句礼貌的「无权访问该目录」——模型没有能力绕过它,因为拒绝发生在 Node 进程里,不在模型的判断范围内。这验证了 MCP 的权限边界在 Server 端。

    ★ 排错提示:如果你连 Access denied 都没看到、模型直接说「我读不到」,那可能是路径根本不存在(模型自己判断后就放弃了,压根没调工具)。 判断方法:看日志里有没有 Error calling tool:——有,才是「被 Server 拒绝」;没有,就是「模型没调」,回 8.6 查注册。

# ① 先看工具有没有注册上(结构化 JSON,直接看数字)
curl http://localhost:8080/api/v1/mcp-assistant/tools
# 期望:clientCount=2, toolCount=16, 两个 server 名分别为
#       order-mcp-server / secure-filesystem-server

# ② 绕过模型,手工调一次(把「连接问题」和「模型不选工具」分开)
curl "http://localhost:8080/api/v1/mcp-assistant/call?tool=queryOrderStatus&arg=2024001"
# 期望:result 里有 未发货 / 2026-10-28(这一步过了,说明连接与 Server 都没问题)

# ③ 走模型查订单(模型不知道这是远程的)
curl -X POST http://localhost:8080/api/v1/mcp-assistant/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"订单 2024001 发货了吗?"}'
# 期望:答案含「未发货 + 2026-10-28」,且日志出现 [OrderMcpTools] queryOrderStatus 被调用

# ④ 用文件系统 MCP 工具(跨语言:Server 是 Node 写的)
curl -X POST http://localhost:8080/api/v1/mcp-assistant/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"列出 D:/springai-lab/data 目录下的所有文件"}'
# 期望:列出 readme.txt / orders.csv 这类真实文件名

# ⑤ 越权测试:读白名单外的目录
curl -X POST http://localhost:8080/api/v1/mcp-assistant/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"帮我读一下 C:/Windows/System32/drivers/etc/hosts 文件"}'
# 期望:Server 返回 Access denied - path outside allowed directories
# ★ 这验证了 MCP 的价值:权限约束在 Server 端,而不是靠模型自觉

# ⑥ 反例对照组:同一个问题,问一个【没挂工具】的裸 client
curl -X POST http://localhost:8080/api/v1/mcp-assistant/ask-no-tools \
  -H "Content-Type: application/json" \
  -d '{"question":"订单 2024001 发货了吗?"}'
# 期望:说没有查询能力 / 或者【编造一个答案】 —— 两种都可能出现,见下方警告
第 ④ 步是本章最有价值的一个验证。 它直观地展示了 MCP 的安全模型:权限边界由 Server 端强制,与模型的行为无关。 对比第 7 章「工具实现内部做权限校验」,MCP 把这件事提升到了服务治理层面—— 可以由安全团队独立维护,而不是散落在各个 AI 应用的代码里。
端到端跑不通时,按「Server → 连接 → 工具 → 模型」四层定位:
  1. Server 本身对不对?用 8.8 的 Inspector 直连,能列工具、能调用 → Server OK。
  2. 客户端连上了没?调 /tools 看 clientCount(不用去日志里找 Connected to MCP server——那句话不存在);clientCount=0 时先看启动日志里有没有 约 20 秒后超时(日志里搜 timed out)。
  3. 连上了但工具数为 0?看那个 Server 的 initialized 是不是 false(握手没完成),再查 SyncMcpToolCallbackProvider 是否注入成功。toolcallback.enabled 不用查,默认开。
  4. 工具有了但模型不用?回到「描述/提示词」(同第 7 章),并确认 .defaultToolCallbacks(...) 已调用,且请求打的就是那个 client。

若 /ask 返回空答案或模型说「没有工具」:八成是漏了 8.6 的显式注册,或 /tools 显示工具数为 0(说明连接或注册环节断了,回到第 ②、③ 步)。

现象:curl /ask 回来「抱歉,我没有相关工具 / 无法查询订单」。按优先级排查:① 调 /tools 接口,看 toolCount 是不是 0——是 0 就回 8.5 / 8.6 查连接和显式注册;② 工具数正常但仍不用,多半是「工具描述写得太含糊」,模型不知道何时该调——把 @McpTool(description=...) 写具体(像 8.4 那样写明「何时调用」);③ 确认 .defaultToolCallbacks(mcpTools) 真的被调用了,且没有在别处被一个没带工具的 ChatClient 覆盖。
★★ 反例实验的「不稳定」现象,比失败更值得警惕。 按 ⑥ 调 /ask-no-tools(一个没挂工具的裸 client)问同一个订单问题,实测出现了两种完全不同的结果:
第几次模型实际回答性质
第 1 次「订单 2024001 已发货」——语气肯定、格式像模像样编造
第 2 次「我没有查询订单的能力,请提供订单系统入口」诚实拒答

这就是为什么反例实验必须跑两次以上:跑一次就「通过」不代表安全,只代表这次它没编。

为什么第一次会编?因为 8.7 ② 的 askWithoutTools 里 system prompt 写着「你是智能助手,可以查询订单状态与文件系统」——它给了模型一个错误的能力暗示。现实中的生产事故就长这样: 提示词声称有能力,而工具没注册上 → 模型用「合理的措辞」填满空白,用户完全看不出破绽。

三条可落地结论:① 别在 system prompt 里写「你能做 X」而不真的把 X 的工具挂上;② 关键数据类回答必须能回溯到工具调用日志(这就是 8.7 ③ 那行 [OrderMcpTools] 的意义);③ 评估一个 AI 应用是否可靠,看的是「工具调用成功率」而不是「回答看起来对不对」。

端到端验收清单(提交作业前逐项打勾):

  • ☐ /tools 返回 clientCount=2,且两个 server 名字是 order-mcp-server 与 secure-filesystem-server;
  • ☐ /call?tool=queryOrderStatus&arg=2024001 绕过模型直接返回订单(证明连接与 Server 都没问题);
  • ☐ 查订单 curl 答案正确,且日志出现 [OrderMcpTools] queryOrderStatus 被调用(只看答案不算过);
  • ☐ 列目录 curl 返回白名单目录里真实存在的文件名;
  • ☐ 读越权路径时日志出现 Error calling tool: [... Access denied - path outside allowed directories ...];
  • ☐ 反例对照组(/ask-no-tools)连跑两次:至少一次暴露「没有工具」,若两次都给出了具体订单状态,说明你的 system prompt 在诱导编造 —— 回去改(见上方警告)。
一个常见错觉:「一次回答只调一个工具」。模型拿到多个工具后,可能在一个回答里连续调用多个工具(例如先 listRecentOrders 拿到订单号,再用 queryOrderStatus 查明细),也可能一句话里同时查订单和列目录。你不需要为此写任何额外代码——ToolCallingAdvisor 会循环执行:模型出工具调用 → 本地执行 → 结果回填 → 模型继续,直到模型给出最终自然语言。你只要在 system prompt 里把「何时用什么工具」写清楚即可。
一句话记忆(8.7):端到端验证按 /tools(数字)→ /call(绕过模型)→ /ask(走模型)→ 越权(Server 拒绝)→ /ask-no-tools(反例,跑两次)推进;每一步都要有日志或数字证据,不能只看回答像不像。

8.8调试与常见坑

用官方 Inspector 调试 Server

MCP Inspector 是一个网页版调试工具,能列出工具、手工填参数调用、看原始报文。它在定位「Server 端问题」时比看日志高效得多。

  1. 在 mcp-order-server 工程目录下执行(需联网首次拉取):

    # MCP Inspector 是一个网页版调试工具,能列出工具、手工填参数调用、看原始报文
    npx @modelcontextprotocol/inspector java -jar target/mcp-order-server-1.0.0.jar
    
    # 它会启动一个本地 Web 界面(通常是 http://localhost:6274)
    # 在里面你可以:
    #   1. 看到 Server 声明的全部工具与参数 schema
    #   2. 手工填参数调用,验证 Server 端逻辑
    #   3. 查看完整的 JSON-RPC 请求/响应报文

    看到什么算通过:浏览器打开 http://localhost:6274,左侧列出 queryOrderStatus / listRecentOrders,填 2024001 点调用,右侧返回订单状态 JSON。这证明 Server 端逻辑正确,之后再从客户端连它。

调试 MCP 的正确顺序(能省掉你 80% 的排查时间):
  1. 用 Inspector 直连 Server —— 确认 Server 本身没问题(工具能列出、能调用成功)。
  2. 用 listAllTools() 从客户端看 —— 确认连接建立、工具被发现。
  3. 用 callToolManually() 手工调 —— 确认客户端到 Server 的调用链路通。
  4. 最后才接模型 —— 如果前三步都过了但模型不用工具,那是「描述/提示词」的问题,不是 MCP 的问题。

常见坑排错表

现象原因解决
★ 主应用启动直接失败,约 20 秒后超时(日志里搜 timed out),前面还有几行 Unexpected character ('-')Server 的 stdout 被 Boot 日志污染。只关 Web 容器不够加 logback-spring.xml,ConsoleAppender 的 <target> 设为 System.err(8.4 ②′);判据是 stdout 0 字节
★ 启动失败:Cannot run program "npx": CreateProcess error=2Windows 上 npx 实为 npx.cmd,Java 的 ProcessBuilder 不经过 shell别再用 npx:包 npm install 到本地,command: node + dist/index.js(8.5 差异一)
★ 启动失败:did not observe any item ... within 20000ms,且该连接 MCP server starting 之后没有任何 stderrServer 启动超过 20 秒初始化窗口——npx 实测 96 秒(缓存热也一样)同上,换 node 直跑(0.47 秒);request-timeout 管不了初始化
mvn clean package 报「另一个程序正在使用此文件」/ Cannot delete ...jar上次启动失败后残留的 MCP 子进程锁着 jar(框架只在正常退出时回收子进程)按命令行关键字找 mcp-order-server 的 java 进程并结束(8.3 末尾给了 PowerShell 命令)
日志里搜不到 Connected to MCP server 'x'这句字符串在 2.0.1 里不存在,不是故障改看 MCP server started(子进程起来了)+ Server response with Protocol:(握手成功,含对端服务名)
/tools 里 clientCount 比配置少某一个连接没建起来(路径错 / npx 失败 / 超时)看启动日志里那个连接附近的报错;逐个连接单独排查,别一起改
某个 Server 的 initialized=false握手没走完就超时了同上:先解决这个连接的启动问题;initialized 本身不用改
工具数为 0,但连接是通的Server 里没有 @McpTool 方法,或工具类不是 Spring bean确认类上有 @Component、方法上有 @McpTool,且在主类所在包及子包下(Server 启动日志会打 Registered tools: N)
工具能列出但模型不用漏了 .defaultToolCallbacks(...),或工具描述含糊,或请求打到了别的 client按 8.6 的排查顺序:先看工具数,再查注册,最后改描述
Server 进程启动后立刻退出main 跑完就结束了用 Spring Boot 常驻;不要手动 exit
每次启动都很慢(不是「只有第一次」)启动器是 npx:每次都要做包解析 / registry 请求,与缓存是否热门无关换 node 直跑;别指望「预热缓存」——实测缓存热时 npx 仍要 96 秒
工具调用超时(不是启动)Server 端逻辑慢(大文件 / 慢查询)调 spring.ai.mcp.client.request-timeout(client 层,不是 stdio 下);Server 端做分页/限制
远程 HTTP Server 401缺少 Authorization 头在远程连接配置的 headers 里带 token(yml 写法,不是 mcp-servers.json)
文件工具报 Access denied - path outside allowed directories这不是 bug,是白名单在正常工作要访问就把它加进 Server 启动参数的白名单;不要为了「调通」而放宽到盘符根目录
排错总路线(从 Server 到模型,逐层定位):
  1. Server 本身对不对?用 Inspector 直连,能列工具、能调用 → Server OK。
  2. 客户端连上了没?调 /tools 看 clientCount 与各 initialized(别去日志里搜 Connected to MCP server)。
  3. 连上了但工具数为 0?先看 Server 端有没有注册工具,再看 SyncMcpToolCallbackProvider 是否注入成功。
  4. 工具有了但模型不用?回到「描述/提示词」(同第 7 章),并确认 .defaultToolCallbacks(...) 已调用、且请求用的就是那个 client。

顺序不要跳:90% 的「工具不生效」是在第 2 层就已经断了(应用都没起来 / 连接没建),却被当成第 4 层(模型问题)去调提示词。

选做:把 stdio Server 改成 Streamable HTTP 独立部署

课程建议先 stdio 跑通,再试远程。把订单 Server 从「本地子进程」改成「独立 Web 服务」只需三步:

  1. 把 pom.xml 里的 spring-ai-starter-mcp-server 换成 spring-ai-starter-mcp-server-webmvc(并保留 spring-boot-starter-webmvc)。
  2. 改 application.yml:删掉 web-application-type: none 和 stdio: true,改为 spring.ai.mcp.server.protocol=STREAMABLE(或 STATELESS),并设 server.port(如 8081)。
  3. 重新 mvn clean package,用 java -jar 直接启动(不再是子进程,而是一个常驻 Web 服务)。
  4. 主应用那边:把 stdio.connections.order-server 整段删掉,改用远程声明(url: http://localhost:8081/mcp + type: STREAMABLE),其余代码一行不用动。

看到什么算通过:启动订单 Server 能看到 Tomcat 监听 8081;主应用 /tools 里 clientCount 仍是 2、order-mcp-server 的 initialized=true;8.7 的 curl 验证全部照常通过。这证明「换传输方式对业务代码零侵入」——这正是协议标准化的好处。

切到 Streamable HTTP 后,你顺手拿到了 stdio 缺的那一样东西:鉴权通道。 远程形态可以在 Server 端加一个 WebMvcConfigurer/Filter 校验 Authorization 头, 把 token 里的租户/用户信息绑定到本次调用;客户端只需在 yml 的 headers 里带上同一个 token。 这是 stdio 做不到的(子进程没有网络请求头可读)。所以「生产环境为什么推荐 Streamable HTTP」有两个理由: ① 可水平扩展;② 有真正的身份边界。这条在本章的动手范围内不展开,但你要知道它是存在的。
一句话记忆(8.8):调试走「Inspector 直连 → listTools → callToolManually → 接模型」四步法;连不上先查 jar 路径与 Node 是否就绪,解析报错先查 stdout 污染。

8.9自测题

Q1. MCP 相比进程内 @Tool 的核心优势是什么?什么情况下不需要 MCP?

核心优势:跨进程、跨语言、可复用、可独立治理(鉴权限流审计), 让工具从「应用的一部分」变成「企业的公共能力」。 不需要 MCP 的情况:工具只服务于自己这一个应用、数量少、不需要独立权限管控—— 这时候用 @Tool 更简单,且没有进程间通信开销。

Q2. Host、Client、Server 各自的职责是什么?

Host 是承载 AI 应用与用户界面的宿主程序(我们的 Spring Boot 应用); Client 是 Host 内部的连接器,与 Server 一对一连接,负责协议握手与消息收发; Server 是暴露能力(Tools/Resources/Prompts)的独立服务。

Q3. stdio 模式下「关闭 Web 容器 + 禁止 System.out」够不够?为什么?

不够,这是本章最容易被旧教程带错的一题。因为 stdout 是 MCP 的通信通道, 而 web-application-type: none 只挡掉了 Tomcat,Spring Boot 自己的启动日志还有十几行照旧写进 stdout。 协议里混进这些行之后,客户端先报 Unexpected character ('-'),接着握手 20 秒超时、整个主应用启动失败—— 注意这个后果不是「工具调不通」,而是「连应用都起不来」。

正解:加 logback-spring.xml,把 ConsoleAppender 的 <target> 设为 System.err; 可断言判据是把 stdout/stderr 分别重定向到文件,重定向后 stdout 必须是 0 字节。

Q4. 从 MCP Server 拿到的工具,和本项目的 @Tool 方法,在传给模型时有区别吗?

没有区别。两者最终都被统一成 ToolCallback, 通过 .defaultTools(...) 或 .defaultToolCallbacks(...) 交给模型。 模型看到的只有「工具名 + 描述 + 参数 schema」,它无法区分工具是本地还是远程的。 这正是 MCP 的价值:在协议层统一,在实现层解耦。

Q5. 能不能用 ToolContext 把当前登录用户传给 MCP Server?

不能。ToolContext 只存在于当前进程的内存里,它的读取者是进程内的 ToolCallback 执行器。 MCP 工具在另一个进程执行,tools/call 报文里没有这个字段,Server 端读不到。

传身份只能走传输层:Streamable HTTP 用 headers(Authorization)——这是唯一真正的鉴权通道; stdio 没有鉴权通道,权限等于宿主进程权限。这也说明为什么要区分「参数」和「边界」: 把 tenantId 拼进工具参数只是业务字段,模型可以改,不是安全边界。

Q6. 为什么说 npx 不能当 MCP Server 的启动器?正确写法是什么?

两个原因,任何一个都足以致命:

① Windows 上根本拉不起来:npx 实为 npx.cmd。终端能用是因为 shell 帮你解析扩展名; 但 MCP Client 用 Java 的 ProcessBuilder 直接 CreateProcess,不经过 shell, 报 Cannot run program "npx": CreateProcess error=2。

② 就算绕过去了,它也来不及:MCP SDK 的初始化握手窗口是硬编码 20 秒, 而 npx 每次启动都要做包解析 / registry 元数据请求,实测 96 秒(npm 缓存热时同样慢,--offline 也不够)。 结果就是超时 → Bean 创建失败 → 应用起不来。

正解:把包 npm install 到工程内固定目录,然后 command: node + args: [<包>/dist/index.js, <白名单目录>]——实测 0.47 秒 ready。 这不只是「快一点」,而是「能不能启动」的差别。

Q7.【动手题】把第 7 章的「天气查询工具」改造成一个独立的 MCP Server,并用 Inspector 验证它能被外部调用。

步骤:① 新建工程,加 spring-ai-starter-mcp-server(不要加 webmvc); ② 把 WeatherTools 类复制过来,把 @Tool/@ToolParam 改为 @McpTool/@McpToolParam(包 org.springframework.ai.mcp.annotation); ③ yml 里设 web-application-type: none 与 spring.ai.mcp.server.stdio=true,并加 logback-spring.xml 把日志改道 stderr; ④ mvn package 后先验 stdout 0 字节,再用 npx @modelcontextprotocol/inspector java -jar target/*.jar 连接; ⑤ 在 Inspector 界面里调用 queryWeather,确认返回正确。 (注:若用 WebMVC 远程形态,配置键以官方文档为准。)

一句话记忆(8.9):自测过关标准——能说清 Host/Client/Server 分工、三种传输区别、 stdio 光关容器不够、必须把日志改道 stderr、ToolContext 跨不过进程、MCP 工具与本地 @Tool 对模型无差别。

8.10实操作业与本章验收

课堂练习(课上 30 分钟,分组完成):

  • 用 stdio 模式新建 mcp-order-server,照抄 8.4 的 OrderMcpTools,记得加 logback-spring.xml 并把 stdout 0 字节验一遍,mvn package 后用 Inspector 连接,确认 queryOrderStatus 能被列出并调用成功。
  • 在主应用配置里连接至少两个 Server(自写的订单 Server + 官方 filesystem),调 /tools 确认 clientCount=2;然后做反例对照实验:先用挂了工具的 /ask 问订单,再用裸 client 的 /ask-no-tools 问同一个问题,每边各跑两次,把四次回答记下来。
    重点观察:裸 client 那两次可能一次编造、一次拒答——这个不稳定本身就是结论,比「模型说没有工具」更值得写进实验报告。

课后作业(独立完成,提交构建与验证截图):

  • 把第 7 章的天气工具改造为一个独立 MCP Server(stdio),用 Inspector 验证 queryWeather 可被外部调用;再把该 Server 接到主应用的 ChatClient 上,用 curl 验证端到端问答。
  • 写 250 字说明:结合 8.7 第 ④ 步,描述为什么「把文件系统访问做成 MCP Server、用目录白名单做权限」比「在主应用里直接写文件读取工具」更适合企业级治理。

8.11动手清单与自查表

① 你要建的文件(以及「建对了」的可观察判据)

文件对应小节建对了的判据(可观察、可断言)
mcp-order-server/pom.xml8.4 ①依赖里只有 spring-ai-starter-mcp-server + spring-ai-bom;跑出 BUILD SUCCESS 且 target/ 下有 22 MB 左右的 jar
mcp-order-server/…/application.yml8.4 ②web-application-type: none、stdio: true、type: SYNC;启动后不监听任何 HTTP 端口(netstat -ano | findstr :8081 无输出)
mcp-order-server/…/logback-spring.xml8.4 ②′stdout 重定向文件 0 字节、stderr 约 6 行。判据命令见 8.4 运行验证 ① 第 2 步
mcp-order-server/…/McpOrderServerApplication.java8.4 ③不加任何额外注解,SpringApplication.run 即可
mcp-order-server/…/OrderMcpTools.java8.4 ④类上有 @Component,两个方法有 @McpTool;Server 启动日志出现 Registered tools: 2(或探针 tools/list 返回 2 个)
lab pom.xml8.5 ①加了 spring-ai-starter-mcp-client(版本交给 BOM,不写 <version>)
lab application.yml8.5 ②stdio.connections 下有两个连接;filesystem 的 command 是 node、args[0] 指向 …/server-filesystem/dist/index.js(不是 npx)
ch08/McpInspectService.java8.5 ③/tools 返回 clientCount / toolCount / servers,且每个 server 带 initialized
ch08/McpChatClientConfig.java8.6启动日志「已从 MCP Server 加载 N 个工具」+ 逐条工具名;bean 名 mcpAssistantClient 出现在 已加载模型客户端: 那行
ch08/FileAssistantService.java8.7 ②ask() 与 askWithoutTools() 都在;全文搜不到 SecurityContextHolder(它不存在)
ch08/McpAssistantController.java8.7 ③GET /api/v1/mcp-assistant/endpoints 返回 4 条端点说明

② 分步验收(每一步都有「等于什么」的判据)

步命令通过判据
1cd mcp-order-server && mvn clean package -DskipTestsBUILD SUCCESS,target/mcp-order-server-1.0.0.jar 存在
2stdout/stderr 分流运行(8.4 运行验证 ①)stdout = 0 字节,stderr ≈ 6 行。这一条不过,后面全部免谈
3npx @modelcontextprotocol/inspector java -jar target/mcp-order-server-1.0.0.jar页面能列出 queryOrderStatus / listRecentOrders,调用 2024001 返回「未发货」
4mvn spring-boot:run(lab)日志出现 MCP server started ×2、Server response with Protocol: 2025-11-25、已从 MCP Server 加载 16 个工具
5curl localhost:8080/api/v1/mcp-assistant/tools"clientCount": 2;order-mcp-server 与 secure-filesystem-server 的 initialized 都是 true
6curl "…/call?tool=queryOrderStatus&arg=2024001"result 含「未发货」与「2026-10-28」——绕过模型也通,证明连接与 Server 没问题
7POST /ask 问「订单 2024001 发货了吗?」答案正确 且 Server 侧日志出现 [OrderMcpTools] queryOrderStatus 被调用 orderNo=2024001
8POST /ask 问「列出 …/data 目录下的文件」列出白名单目录里真实存在的文件名(Java 驱动 Node Server,跨语言成立)
9POST /ask 让它读 C:/Windows/System32/drivers/etc/hosts日志出现 Error calling tool: [... Access denied - path outside allowed directories ...]
10反例:POST /ask-no-tools 问同一个订单问题,连跑 2 次至少一次明确说「没有查询能力」;若两次都给出具体状态 → 你的 system prompt 在诱导编造,回去改(见 8.7 的反例警告)
三个值得主动做的「破坏性实验」(做完记得改回来): ① 把 logback-spring.xml 改名 → 重现「20 秒超时 + 应用起不来」,亲眼看一次污染的真实后果; ② 把 filesystem 的启动器改回 npx(先试 command: npx,再试 cmd /c npx)→ 依次重现 CreateProcess error=2 与「96 秒启动 + 20 秒超时」,亲身体会为什么这条不是「性能问题」而是「能不能启动」; ③ 注释掉 .defaultToolCallbacks(mcpTools) → 观察模型是拒答还是编造(大概率两次都不一样)。 这三个坑加起来覆盖了本章 80% 的真实故障,见过一次就不会再被它绕住。
关于「不装 Inspector 怎么验 Server」:Inspector 依赖 npx 联网拉包(首次几秒到几十秒,网络差会卡住), 且它只是一个交互式调试界面——不适合放进自动化验证里。 备选方案是自己写一个裸 JSON-RPC 探针:用 Python 起子进程,往它的 stdin 依次写 initialize → notifications/initialized → tools/list → tools/call,读 stdout 逐行解析 JSON。 三十行代码,比 Inspector 更快,而且能直接断言(例如「serverInfo.name 必须等于 order-mcp-server」)。 它的副作用是让你彻底看清「stdio 就是两根管道」这件事。

③ 卡住了去哪查

症状先去哪节一句话提示
lab 启动失败,约 20 秒后超时(日志里搜 timed out)8.4 ②′缺 logback-spring.xml,stdout 被 Boot 日志污染
Cannot run program "npx": CreateProcess error=28.5 差异一别用 npx 当启动器,包装到本地用 node 直跑
启动卡 20 秒后失败,did not observe any item ... within 20000ms8.5 差异一同上:npx 启动 96 秒过不了 20 秒窗口;先确认该连接有没有 stderr 输出来区分「污染」和「太慢」
mvn clean package 报 jar 被占用8.3 末尾上次启动失败留下的 MCP 子进程没回收,按命令行杀 java
搜不到 Connected to MCP server8.5 运行验证 ②这句日志在 2.0.1 里不存在,改看 Server response with Protocol:
/tools 里 toolCount 是 08.5 / 8.6先分「连接没建起来」还是「Server 没注册工具」,再看显式注册
模型说「没有相关工具」或答错了还很有把握8.7 反例警告先看 Server 侧有没有 [OrderMcpTools] 日志——没有就是没调工具,别调提示词
文件工具报 Access denied8.7 ④这是白名单在正常工作,不是 bug
其余现象8.8按「现象 → 原因 → 解决」对照表逐条排查