Spring AI 2 手把手:把 filesystem MCP Server 跑通(SSE / stdio + 真调工具)

先把结论说清楚:MCP 是一套开放标准,用来统一「大模型 / 宿主如何发现并调用外部能力」。你在 Server 上声明 tools(以及可选的 resources、prompts),Client 按名字发现、按参数调用;换传输(stdio / SSE / 后续还可扩展 Streamable 等)时,业务工具代码可以复用。

仓库里有两个可运行的独立工程,覆盖从 Server 到协议联调、再到模型真实调工具的完整链路:

  • ai-mcp-demo:MCP Server,四个 filesystem 工具,Profile 切换 SSE / stdio
  • ai-mcp-demo-test:协议联调(不经过大模型)+ OpenAI 兼容端点验证「模型会识别并真实调用工具」

技术栈:JDK 21 + Spring Boot 4.1.1 + Spring AI 2.0.0 。LLM 侧默认走 OpenAI 兼容协议,模型名默认 gpt-5-mini;也可改 baseUrl / 模型名对接国内兼容平台(如阿里云百炼),前提是支持 tool calling。

下面按「能跟着敲命令复现」来写,代码、配置、联调命令和常见问题都会展开。

这篇文章你读完能干什么

  1. 用 Spring AI 搭一个 MCP Server,暴露 filesystem 四工具:列允许根、列目录、读文件、写文件(可按同样模式继续扩展工具面)
  2. 同一个 JAR 支持 SSE(默认 8101)与 stdio,便于 HTTP 联调,也便于 Cursor 等宿主以子进程接入
  3. McpSyncClient 做协议层验收:initialize / listTools / callTool,不依赖大模型
  4. 用 OpenAI 兼容端点 + CountingToolCallback 做模型层验收:能列出工具名,且真实 call、写文件可落盘核对
  5. 掌握 roots 沙箱、stdio 静默日志、显式 protocol: SSE 等联调要点,并知道后续可往哪扩

仓库地址:hei-ddd-ai-lite,目录:ai-mcp-demo/ai-mcp-demo-test/

一、MCP 是什么,和框架私有 Tool 怎么选

可以把 MCP 理解成 AI 应用的统一扩展接口(也常有 USB 这类比喻):

  • Server:声明并执行 tools;协议还支持 resources、prompts 等能力(本演示优先把 tools 链路跑通,capabilities 里先只开 tool,后续可再打开)
  • Client:发现能力并调用(IDE 宿主、自研 Java 客户端、或经 Spring AI 桥接到 ChatClient)
  • Transport:本地常用 stdio;远程 / 常驻进程常用 SSE(以及生态里其他传输形态)

和 Spring AI 里直接 @Tool 挂到 ChatClient 相比,两者解决的问题有重叠,也有分工:

方式 更适合 特点
框架私有 @Tool 工具主要在单个应用进程内使用 路径短,配置少,和框架绑定更紧
MCP Server 希望同一套能力被 IDE、多客户端、多语言宿主复用 标准统一,可独立部署与发现;多一层协议与传输配置

两条路可以并存:应用内高频、强业务耦合的能力用私有 Tool;要对齐生态、给 Cursor / 其他 MCP Client 用的能力做成 MCP。本仓库走 MCP,是为了把 Server 声明 → 双传输 → 协议直连 → 模型调工具 这条链路讲清楚,方便你按需扩展到业务 API、数据库查询等更多工具。

本演示的工具面选取官方 @modelcontextprotocol/server-filesystem 的核心子集,名字对齐,方便对照 Node 官方实现;完整官方 Server 工具更多,你熟悉闭环后可以继续加移动、搜索、文件信息等(共用 resolveAllowed 即可):

工具名 作用
list_allowed_directories 列出沙箱允许根
list_directory 列目录
read_text_file 读 UTF-8 文本
write_file 写 UTF-8 文本(可建父目录)

二、为啥拆成两个工程?为啥协议测试不绑大模型?

1)Server 和 Test 拆开

ai-mcp-demo 放 MCP Server 栈;ai-mcp-demo-test 放客户端、JUnit、OpenAI starter。两边各自 cdmvn package / mvn test,依赖与构建互不影响。

也可以合成一个多模块或单工程------只要你能接受测试 classpath 与 Server Bean 边界更紧。拆开的好处是:测协议时不必拉起无关业务 Bean,排障时依赖也更清晰。

注意:这两个目录当前 不是 父 POM 的 <module>,需要进入各自目录单独构建。

2)协议一层、模型一层

「Server 工具挂没挂对」和「模型会不会选工具」是两件事。

  • 协议层:McpSyncClient.initializelistToolscallTool,参数写死,断言读写字符串
  • 模型层:ChatModel.call / ChatClientToolCallback,再用计数器看真实 call 次数

协议红了先修 Server;协议绿、LLM 红,再去查 Key、模型是否支持 tool calling、提示有没有点名工具英文名。

3)stdio 和 SSE 各解决啥

stdio SSE(本 demo 默认)
通道路径 子进程 stdin/stdout HTTP:GET /sse + 消息端点 /mcp/message
典型用法 Cursor / Claude Desktop 拉本地 JAR 常驻进程,客户端连 http://host:port
关键配置 stdio=true,关 Web、关 Banner、关 console 日志 protocol: SSE,端口 8101
stdout 被协议独占,日志一打就毁会话 不写显式 SSE,可能握手失败(WebMVC 默认有时偏 Streamable)

工具代码一份,传输靠 Profile 切换。也可以拆成两个 Server 工程,只是工具与配置容易重复;本仓选择单 JAR + Profile,便于对照同一套 FilesystemTools

三、硬门槛:JDK 必须是 21

两个工程的 pom.xml 都写了:

xml 复制代码
<java.version>21</java.version>

你终端如果还是 JDK 17,两种挂法都能复现:

  1. mvn clean packagerelease version 21 not supported
  2. 用 17 跑 21 打出来的 JAR → UnsupportedClassVersionError(class file 65.0,runtime 只认到 61.0)

开跑前先核:

bash 复制代码
java -version    # 必须是 21.x
mvn -v           # 必须显示 Java version: 21.x

不是就切:

bash 复制代码
export JAVA_HOME=/path/to/jdk-21
export PATH="$JAVA_HOME/bin:$PATH"

javamvn 需要指向同一套 JDK 21,只改其中一个不够。这类报错优先查运行时版本,再去查 MCP 配置。


四、Server 工程骨架:ai-mcp-demo

目录长这样:

text 复制代码
ai-mcp-demo/
├── pom.xml
└── src/main/
    ├── java/io/github/jiangbyte/aimcp/demo/
    │   ├── AiMcpDemoApplication.java
    │   ├── config/FilesystemProperties.java
    │   └── tools/FilesystemTools.java
    └── resources/
        ├── application.yml
        ├── application-sse.yml
        └── application-stdio.yml

包名 io.github.jiangbyte.aimcp.demo,跟业务包分开,以后也不容易被别的应用误扫。

1)pom:Boot parent + Spring AI BOM + webmvc starter

完整依赖思路就三句话:

  • parent 用 spring-boot-starter-parent:4.1.1
  • 导入 spring-ai-bom:2.0.0
  • Server 用 spring-ai-starter-mcp-server-webmvc(有 HTTP;stdio 再 Profile 关 Web)

关键片段:

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
</parent>

<properties>
    <java.version>21</java.version>
    <spring-ai.version>2.0.0</spring-ai.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

独立工程记得给 maven-compiler-plugin 配 Lombok annotationProcessorPaths,否则 @Data / @RequiredArgsConstructor 编译挂。spring-boot-maven-plugin 打 fat JAR,stdio 测试要靠这个 JAR 起子进程。

2)入口类

java 复制代码
@SpringBootApplication
@EnableConfigurationProperties(FilesystemProperties.class)
public class AiMcpDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(AiMcpDemoApplication.class, args);
    }
}

入口注释里说明了 Profile 含义:默认 sse--spring.profiles.active=stdio 切换标准流传输。

3)沙箱配置属性

java 复制代码
@Data
@ConfigurationProperties(prefix = "ai.mcp.filesystem")
public class FilesystemProperties {

    /** 允许访问的根目录列表(绝对路径) */
    private List<String> roots = new ArrayList<>();

    public List<Path> resolveRoots() {
        return roots.stream()
                .filter(r -> r != null && !r.isBlank())
                .map(r -> Path.of(r.trim()).toAbsolutePath().normalize())
                .distinct()
                .toList();
    }
}

默认 YAML 里 roots 是 ${user.home},方便本地快速试用。联调测试建议用命令行显式指定沙箱 ,与测试常量 /tmp/ai-mcp-demo-sandbox 对齐,避免断言路径与运行时允许根不一致。

bash 复制代码
--ai.mcp.filesystem.roots=/tmp/ai-mcp-demo-sandbox

五、四个工具怎么写:FilesystemTools

核心就一句话:@Component + @McpTool + 返回 CallToolResult,所有带路径的先过 resolveAllowed

启动日志出现 Registered tools: 4 表示注解扫描成功。若 tools 为 0,建议先查包扫描、@Component、注解名拼写、capabilities.tool,再排查传输层。

完整实现(跟仓库一致):

java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class FilesystemTools {

    private final FilesystemProperties properties;

    @McpTool(
            name = "list_allowed_directories",
            description = "列出本 MCP 允许访问的根目录(沙箱)",
            annotations = @McpTool.McpAnnotations(readOnlyHint = true, openWorldHint = false))
    public CallToolResult listAllowedDirectories() {
        List<String> roots = properties.resolveRoots().stream().map(Path::toString).toList();
        return ok(String.join("\n", roots));
    }

    @McpTool(
            name = "list_directory",
            description = "列出指定目录中的条目(文件名 / 子目录名)",
            annotations = @McpTool.McpAnnotations(readOnlyHint = true, openWorldHint = false))
    public CallToolResult listDirectory(
            @McpToolParam(description = "目录绝对或相对路径(须在允许根内)", required = true) String path) {
        try {
            // 1. 解析并校验路径落在沙箱内
            Path dir = resolveAllowed(path);
            // 2. 确认是目录
            if (!Files.isDirectory(dir)) {
                return error("不是目录: " + dir);
            }
            // 3. 列出直接子项并排序返回
            try (Stream<Path> stream = Files.list(dir)) {
                String listing = stream
                        .sorted(Comparator.comparing(p -> p.getFileName().toString()))
                        .map(p -> (Files.isDirectory(p) ? "[dir] " : "[file] ") + p.getFileName())
                        .collect(Collectors.joining("\n"));
                return ok(listing.isBlank() ? "(空目录)" : listing);
            }
        } catch (Exception e) {
            log.warn("list_directory 失败 path={}", path, e);
            return error(e.getMessage());
        }
    }

    @McpTool(
            name = "read_text_file",
            description = "读取文本文件内容(UTF-8)",
            annotations = @McpTool.McpAnnotations(readOnlyHint = true, openWorldHint = false))
    public CallToolResult readTextFile(
            @McpToolParam(description = "文件绝对或相对路径(须在允许根内)", required = true) String path) {
        try {
            // 1. 沙箱校验
            Path file = resolveAllowed(path);
            // 2. 确认是普通文件
            if (!Files.isRegularFile(file)) {
                return error("不是普通文件: " + file);
            }
            // 3. 读取全文
            return ok(Files.readString(file, StandardCharsets.UTF_8));
        } catch (Exception e) {
            log.warn("read_text_file 失败 path={}", path, e);
            return error(e.getMessage());
        }
    }

    @McpTool(
            name = "write_file",
            description = "写入文本文件(UTF-8,覆盖已存在内容;必要时创建父目录)",
            annotations = @McpTool.McpAnnotations(
                    readOnlyHint = false,
                    destructiveHint = true,
                    idempotentHint = true,
                    openWorldHint = false))
    public CallToolResult writeFile(
            @McpToolParam(description = "目标文件路径(须在允许根内)", required = true) String path,
            @McpToolParam(description = "要写入的文本内容", required = true) String content) {
        try {
            // 1. 沙箱校验目标路径
            Path file = resolveAllowed(path);
            // 2. 确保父目录存在
            Path parent = file.getParent();
            if (parent != null) {
                Files.createDirectories(parent);
            }
            // 3. 写入内容并返回确认信息
            String body = content == null ? "" : content;
            Files.writeString(file, body, StandardCharsets.UTF_8);
            return ok("已写入: " + file.toAbsolutePath().normalize() + " (" + Files.size(file) + " bytes)");
        } catch (Exception e) {
            log.warn("write_file 失败 path={}", path, e);
            return error(e.getMessage());
        }
    }

    private Path resolveAllowed(String rawPath) throws IOException {
        // 1. 规范化用户路径
        if (!StringUtils.hasText(rawPath)) {
            throw new IllegalArgumentException("path 不能为空");
        }
        Path target = Path.of(rawPath.trim()).toAbsolutePath().normalize();

        // 2. 读取允许根;未配置则拒绝一切访问
        List<Path> roots = properties.resolveRoots();
        if (roots.isEmpty()) {
            throw new IllegalStateException("未配置 ai.mcp.filesystem.roots,拒绝访问");
        }

        // 3. 已存在则解析真实路径,再做前缀校验(防 ../ 越界)
        Path check = Files.exists(target) ? target.toRealPath() : target;
        for (Path root : roots) {
            Path realRoot = Files.exists(root) ? root.toRealPath() : root;
            if (check.equals(realRoot) || check.startsWith(realRoot)) {
                return check;
            }
        }
        throw new IllegalArgumentException("路径越出允许根: " + target + ";允许根=" + roots);
    }

    private static CallToolResult ok(String text) {
        return CallToolResult.builder().addTextContent(text == null ? "" : text).isError(false).build();
    }

    private static CallToolResult error(String message) {
        return CallToolResult.builder()
                .addTextContent(message == null ? "unknown error" : message)
                .isError(true)
                .build();
    }
}

几个值得记住的点:

为啥返回 CallToolResult,而不是只靠抛异常?

网络/会话失败走超时或异常;业务失败(越界、不是文件)走 isError=true。客户端好统一展示:「协议往返成功,但业务说失败了」。

沙箱覆盖到哪一层?

本实现做了空白拒绝、绝对路径规范化、可选 toRealPath、前缀匹配,能挡住常见 ../ 越界。若要上生产,通常还会叠加身份 ACL、扩展名 / 大小限制、审计日志等;这些可以在同一 resolveAllowed 入口继续加。

同步工具与阻塞。

当前 type: SYNC,文件 IO 在调用线程上执行,小文件演示足够。若挂到高并发 HTTP,可评估超时、大小限制或异步能力------那是在现有结构上的增强,而不是推倒重来。

六、三份 YAML:公共 + SSE + stdio

application.yml(公共)

yaml 复制代码
spring:
  application:
    name: ai-mcp-demo
  profiles:
    default: sse
  ai:
    mcp:
      server:
        name: ai-mcp-demo
        version: 1.0.0
        type: SYNC
        capabilities:
          tool: true
          resource: false
          prompt: false
          completion: false

ai:
  mcp:
    filesystem:
      roots:
        - ${user.home}

默认 Profile 为 sse,直接 java -jar 即可探活。本演示 capabilities 先只开 tool;若后续要演示 resources / prompts,把对应开关打开并补实现即可。

application-sse.yml

yaml 复制代码
server:
  port: 8101

spring:
  ai:
    mcp:
      server:
        # 显式 SSE(Spring AI 2.0 webmvc 默认可能是 STREAMABLE)
        protocol: SSE
        sse-endpoint: /sse
        sse-message-endpoint: /mcp/message

logging:
  level:
    root: INFO
    io.github.jiangbyte.aimcp: DEBUG

联调时建议显式配置 protocol: SSE

进程已启动、端口也能 curl,但客户端仍握手失败或超时,常见原因之一是传输约定不一致(Spring AI 2.0 WebMVC starter 在部分默认下可能偏向 Streamable)。客户端若走 SSE,配置里写清 protocol: SSE,并与客户端实际传输对齐。

客户端 baseUrl 写 http://127.0.0.1:8101,一般不要自带 /sse------端点由协议配置拼接。

application-stdio.yml

yaml 复制代码
spring:
  main:
    web-application-type: none
    banner-mode: off
  ai:
    mcp:
      server:
        stdio: true

logging:
  pattern:
    console:
  level:
    root: OFF

stdio 模式下 stdout 承载协议帧。Banner、console 日志或 System.out 混进标准输出,可能导致 initialize 阶段会话异常。因此 stdio Profile 里关闭 Web、Banner,并关闭 console 日志;需要排障时建议改打文件 appender。

七、打包、启动、探活

bash 复制代码
cd ai-mcp-demo
mvn -q package -DskipTests

mkdir -p /tmp/ai-mcp-demo-sandbox
printf 'hello-mcp\n' > /tmp/ai-mcp-demo-sandbox/hello.txt

SSE(默认,前台阻塞)

bash 复制代码
java -jar target/ai-mcp-demo-1.0-SNAPSHOT.jar \
  --ai.mcp.filesystem.roots=/tmp/ai-mcp-demo-sandbox

看到类似日志就算就绪:

text 复制代码
Registered tools: 4
Tomcat started on port 8101
Started AiMcpDemoApplication

探活必须带超时,不然 curl 会挂在事件流上:

bash 复制代码
curl -s -o /dev/null -w '%{http_code}\n' --max-time 2 http://127.0.0.1:8101/sse
# 期望非 5xx,常见 200

stdio

bash 复制代码
java -jar target/ai-mcp-demo-1.0-SNAPSHOT.jar \
  --spring.profiles.active=stdio \
  --ai.mcp.filesystem.roots=/tmp/ai-mcp-demo-sandbox

给 Cursor / Claude 的 mcp.json 示例(路径改成你机器上的绝对路径):

json 复制代码
{
  "mcpServers": {
    "ai-mcp-demo": {
      "command": "java",
      "args": [
        "-jar",
        "/绝对路径/hei-ddd-ai-lite/ai-mcp-demo/target/ai-mcp-demo-1.0-SNAPSHOT.jar",
        "--spring.profiles.active=stdio",
        "--ai.mcp.filesystem.roots=/你允许的目录"
      ]
    }
  }
}

别把 Server 和测试粘在同一个前台终端

java -jar 会一直占着。后面的 cd / mvn test 根本跑不到。开两个终端,或者把 Server 丢后台。

ai-mcp-demo 里执行 cd ai-mcp-demo-test 会报找不到目录------应该是 cd ../ai-mcp-demo-test

八、测试工程:ai-mcp-demo-test

依赖

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-mcp</artifactId>
    </dependency>
    <dependency>
        <groupId>io.modelcontextprotocol.sdk</groupId>
        <artifactId>mcp-json-jackson3</artifactId>
        <version>2.0.0</version>
    </dependency>
    <!-- OpenAI:LLM 识别 / 调用 MCP 工具 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Boot / Spring AI 版本跟 Server 对齐:4.1.1 / 2.0.0。

客户端封装 McpDemoClients

建连、调工具、抽文本、解析 JAR 路径都塞这里,用例只写「测什么」:

java 复制代码
@UtilityClass
public class McpDemoClients {

    public static McpSyncClient sse(String baseUrl, Duration timeout) {
        McpSyncClient client = McpClient.sync(HttpClientSseClientTransport.builder(baseUrl).build())
                .requestTimeout(timeout)
                .build();
        client.initialize();
        return client;
    }

    public static McpSyncClient stdio(Path jarPath, Path sandboxRoot, Duration timeout) {
        var params = ServerParameters.builder("java")
                .args(
                        "-jar", jarPath.toAbsolutePath().toString(),
                        "--spring.profiles.active=stdio",
                        "--ai.mcp.filesystem.roots=" + sandboxRoot.toAbsolutePath())
                .build();
        McpSyncClient client = McpClient.sync(
                        new StdioClientTransport(params, new JacksonMcpJsonMapperSupplier().get()))
                .requestTimeout(timeout)
                .build();
        client.initialize();
        return client;
    }

    public static ToolCallback[] asSpringAiTools(McpSyncClient client) {
        return new SyncMcpToolCallbackProvider(client).getToolCallbacks();
    }

    public static CallToolResult callTool(McpSyncClient client, String name, Map<String, Object> args) {
        return client.callTool(CallToolRequest.builder().name(name).arguments(args).build());
    }

    public static String textOf(CallToolResult result) {
        if (result == null || result.content() == null) {
            return "";
        }
        return result.content().stream()
                .filter(TextContent.class::isInstance)
                .map(TextContent.class::cast)
                .map(TextContent::text)
                .filter(Objects::nonNull)
                .collect(Collectors.joining("\n"));
    }

    public static List<String> toolNames(McpSyncClient client) {
        McpSchema.ListToolsResult listed = client.listTools();
        return listed.tools().stream().map(McpSchema.Tool::name).toList();
    }

    public static Path resolveDemoJar(Path projectRootHint) {
        Path jar = projectRootHint.resolve("ai-mcp-demo/target/ai-mcp-demo-1.0-SNAPSHOT.jar");
        if (Files.isRegularFile(jar)) {
            return jar;
        }
        Path sibling = Path.of("..").resolve("ai-mcp-demo/target/ai-mcp-demo-1.0-SNAPSHOT.jar").normalize();
        if (Files.isRegularFile(sibling)) {
            return sibling.toAbsolutePath();
        }
        return jar.toAbsolutePath();
    }
}

stdio 那边 ServerParameters 传的 Profile 和 roots,必须跟 YAML / 测试常量一致。少一项,就是子进程起不来或 initialize 超时。

resolveDemoJar 靠相对路径查找相邻工程的 JAR。若 Surefire 工作目录不在 ai-mcp-demo-test,可能解析失败并 skip------此时先打印解析路径确认工作目录即可。

九、协议联调:AiMcpDemoProtocolTest

常量:

java 复制代码
static final String SSE_BASE_URL = "http://127.0.0.1:8101";
static final Path SANDBOX_ROOT = Path.of("/tmp/ai-mcp-demo-sandbox");
static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
static final Path REPO_ROOT = Path.of("..").toAbsolutePath().normalize();

两条用例,SSE / stdio 共用同一套断言,避免两端断言不一致导致假通过。

java 复制代码
@Test
@DisplayName("SSE:listTools + list/read/write")
void test_sse_protocol() throws Exception {
    assumeTrue(sseReachable(SSE_BASE_URL), () ->
            "SSE 不可达: " + SSE_BASE_URL + ",请先启动 ai-mcp-demo(默认 sse)");
    prepareSandbox();

    try (McpSyncClient client = McpDemoClients.sse(SSE_BASE_URL, REQUEST_TIMEOUT)) {
        assertFilesystemTools(client);
        assertSpringAiToolBridge(client);
    }
}

@Test
@DisplayName("STDIO:子进程拉起 JAR + list/read/write")
void test_stdio_protocol() throws Exception {
    Path jar = McpDemoClients.resolveDemoJar(REPO_ROOT);
    assumeTrue(Files.isRegularFile(jar), () ->
            "找不到 JAR: " + jar + ",请先在 ai-mcp-demo 下 mvn package");
    prepareSandbox();

    try (McpSyncClient client = McpDemoClients.stdio(jar, SANDBOX_ROOT, REQUEST_TIMEOUT)) {
        assertFilesystemTools(client);
        assertSpringAiToolBridge(client);
    }
}

共用断言顺序故意写死:listTools → allowed → list → read → write → readBack。

为啥按这个顺序?先确认工具注册,再确认沙箱与读写副作用;任一步失败时,Surefire 停在哪一步,更容易判断是注册、配置还是 IO 问题。

java 复制代码
private void assertFilesystemTools(McpSyncClient client) throws Exception {
    // 1. listTools:应包含四个核心工具
    List<String> names = McpDemoClients.toolNames(client);
    assertTrue(names.containsAll(List.of(
            "list_allowed_directories",
            "list_directory",
            "read_text_file",
            "write_file")), "工具列表不完整: " + names);

    // 2. list_allowed_directories
    CallToolResult allowed = McpDemoClients.callTool(client, "list_allowed_directories", Map.of());
    assertFalse(Boolean.TRUE.equals(allowed.isError()), McpDemoClients.textOf(allowed));
    String allowedText = McpDemoClients.textOf(allowed);
    assertTrue(allowedText.contains(SANDBOX_ROOT.toString())
                    || allowedText.contains(SANDBOX_ROOT.toRealPath().toString()),
            "允许根未包含沙箱: " + allowedText);

    // 3. list_directory
    CallToolResult listing = McpDemoClients.callTool(client, "list_directory",
            Map.of("path", SANDBOX_ROOT.toString()));
    assertFalse(Boolean.TRUE.equals(listing.isError()), McpDemoClients.textOf(listing));
    assertTrue(McpDemoClients.textOf(listing).contains("hello.txt"));

    // 4. read_text_file
    Path hello = SANDBOX_ROOT.resolve("hello.txt");
    CallToolResult read = McpDemoClients.callTool(client, "read_text_file",
            Map.of("path", hello.toString()));
    assertFalse(Boolean.TRUE.equals(read.isError()), McpDemoClients.textOf(read));
    assertTrue(McpDemoClients.textOf(read).contains("hello-mcp"));

    // 5. write_file + 再读回
    Path out = SANDBOX_ROOT.resolve("written-by-test.txt");
    CallToolResult write = McpDemoClients.callTool(client, "write_file",
            Map.of("path", out.toString(), "content", "from-protocol-test"));
    assertFalse(Boolean.TRUE.equals(write.isError()), McpDemoClients.textOf(write));
    CallToolResult readBack = McpDemoClients.callTool(client, "read_text_file",
            Map.of("path", out.toString()));
    assertTrue(McpDemoClients.textOf(readBack).contains("from-protocol-test"));
}

桥接断言用于确认 Spring AI 能从当前 MCP 连接取出不少于 4 个 ToolCallback

java 复制代码
private void assertSpringAiToolBridge(McpSyncClient client) {
    ToolCallback[] callbacks = McpDemoClients.asSpringAiTools(client);
    assertNotNull(callbacks);
    assertTrue(callbacks.length >= 4, "Spring AI ToolCallback 数量不足: " + callbacks.length);
}

它说明「桥接层可用」;模型是否会选中并调用工具,由下一节的 LLM 用例验证。

SSE 没起、JAR 没有时用 assumeTrue skip。看 Surefire 时 连同 skipped 一起读------全绿但 skipped=1,不代表双协议都测过。

callTool 参数对应关系记一下:

text 复制代码
name: read_text_file
arguments: { "path": "/tmp/ai-mcp-demo-sandbox/hello.txt" }

write_file 要同时给 pathcontent

十、模型层:OpenAI 兼容端点 + 调用计数

协议绿说明「客户端硬编码 callTool 已通」。要验证「模型能否看见工具、会不会真实调用」,再跑 AiMcpDemoLlmTest

当前工程使用:

  • spring-ai-starter-model-openai
  • 环境变量 OPENAI_API_KEY(可选 OPENAI_BASE_URLOPENAI_CHAT_MODEL
  • 默认模型 gpt-5-mini
  • 缺 Key / 缺 JAR / SSE 不可达 → Assumption skip

OpenAI 兼容网关均可对接:设置 OPENAI_BASE_URL 即可,前提是支持 tool calling。

国外名模型访问受限时,也可使用国内阿里云百炼等兼容服务:改 baseUrl 与模型名,并配置对应 Key。选择支持 tool calling 的模型,识别 / 调用用例更稳定。

为啥要 CountingToolCallback

若只断言模型回复文本,可能出现「回复里写了已调用,但实际未走工具」的情况。用计数包装真实 ToolCallback:每次 call 按工具名累加;调用层断言 counters.get(name) >= 1,写文件再核对磁盘,验收更扎实。

java 复制代码
final class CountingToolCallback implements ToolCallback {
    private final ToolCallback delegate;
    private final Map<String, AtomicInteger> counters;

    static Bundle wrap(ToolCallback[] source) {
        Map<String, AtomicInteger> counters = new ConcurrentHashMap<>();
        ToolCallback[] wrapped = new ToolCallback[source.length];
        for (int i = 0; i < source.length; i++) {
            ToolCallback cb = source[i];
            counters.putIfAbsent(cb.getToolDefinition().name(), new AtomicInteger());
            wrapped[i] = new CountingToolCallback(cb, counters);
        }
        return new Bundle(wrapped, counters);
    }

    @Override
    public String call(String toolInput) {
        // 1. 按工具名累加,证明走到了 MCP 工具
        counters.get(delegate.getToolDefinition().name()).incrementAndGet();
        // 2. 转发原始回调
        return delegate.call(toolInput);
    }
}

识别:问「有哪些工具」

java 复制代码
ToolCallback[] tools = McpDemoClients.asSpringAiTools(client);
assertToolDefinitions(tools); // 四个 ToolDefinition 名齐全

ChatModel chatModel = OpenAiChatModel.builder()
        .options(OpenAiChatOptions.builder()
                .apiKey(API_KEY)
                .baseUrl(BASE_URL)
                .model(CHAT_MODEL)
                .build())
        .build();

Prompt prompt = Prompt.builder()
        .messages(new UserMessage("""
                有哪些工具可以使用
                请用英文原名列出全部可用工具,不要编造,不要只写中文描述。
                """))
        .chatOptions(OpenAiChatOptions.builder()
                .model(CHAT_MODEL)
                .toolCallbacks(tools)
                .build())
        .build();

ChatResponse chatResponse = chatModel.call(prompt);
assertListsAllToolNames(extractText(chatResponse));

识别用例只看回复里四个英文名齐不齐。调用用例必须计数 ≥1。

调用:ChatClient + 点名工具英文名

提示里写死工具英文名和绝对路径,降低选错概率。四个工具各一条用例,外加一条 SSE 读文件:

用例 期望 额外断言
list_allowed_directories 计数 ≥1 回答含沙箱路径
list_directory 计数 ≥1 回答含 hello.txt
read_text_file 计数 ≥1 回答含 hello-mcp
write_file 计数 ≥1 落盘 written-by-llm.txtfrom-llm-write
SSE read_text_file 计数 ≥1 同上(需 8101 已起)

读文件示例:

java 复制代码
CountingToolCallback.Bundle bundle =
        CountingToolCallback.wrap(McpDemoClients.asSpringAiTools(client));
Path hello = SANDBOX_ROOT.resolve("hello.txt");
String answer = ChatClient.builder(openAiChatModel())
        .defaultSystem("你是助手。需要文件或目录信息时必须调用提供的工具,禁止编造文件内容。")
        .defaultTools(bundle.getCallbacks())
        .build()
        .prompt()
        .user("请调用工具 read_text_file,读取文件:" + hello
                + " 。把文件原文内容原样告诉我,不要猜测。")
        .call()
        .content();
assertTrue(bundle.getCounters().get("read_text_file").get() >= 1);
assertTrue(answer.toLowerCase(Locale.ROOT).contains("hello-mcp"));

写文件除了计数必须看磁盘:

java 复制代码
Path out = SANDBOX_ROOT.resolve("written-by-llm.txt");
Files.deleteIfExists(out);
String answer = chatClient(bundle.getCallbacks()).prompt()
        .user("请调用工具 write_file:path=" + out
                + " ,content 必须恰好是 from-llm-write 。写完后用一句话确认已写入。")
        .call()
        .content();
assertTrue(bundle.getCounters().get("write_file").get() >= 1);
assertTrue(Files.readString(out).contains("from-llm-write"));

超时给到 120 秒,冷启动和网络抖一下有余量。

模型列出了工具但计数为 0:识别用例可以过,调用用例必须挂------这是故意的。

十一、从头到尾:推荐命令顺序

必须两个终端。 路径相对仓库根 hei-ddd-ai-lite

终端 A:打包并起 SSE

bash 复制代码
# 先确认 JDK 21
java -version && mvn -v

cd ai-mcp-demo
mvn -q package -DskipTests
mkdir -p /tmp/ai-mcp-demo-sandbox
printf 'hello-mcp\n' > /tmp/ai-mcp-demo-sandbox/hello.txt

java -jar target/ai-mcp-demo-1.0-SNAPSHOT.jar \
  --ai.mcp.filesystem.roots=/tmp/ai-mcp-demo-sandbox

另开一个小窗口探活:

bash 复制代码
curl -s -o /dev/null -w '%{http_code}\n' --max-time 2 http://127.0.0.1:8101/sse

终端 B:跑协议测试

bash 复制代码
cd ai-mcp-demo-test    # 若在 demo 内则 cd ../ai-mcp-demo-test
mvn -q -Dtest=AiMcpDemoProtocolTest test

只跑某一条:

bash 复制代码
mvn -q -Dtest=AiMcpDemoProtocolTest#test_sse_protocol test
mvn -q -Dtest=AiMcpDemoProtocolTest#test_stdio_protocol test

协议通过时,日志里通常能看到四工具名、allowed => /tmp/ai-mcp-demo-sandboxhello.txtspring-ai toolCallbacks => 4

写回核对:

bash 复制代码
cat /tmp/ai-mcp-demo-sandbox/written-by-test.txt
# 期望:from-protocol-test

再跑 LLM(需要 Key)

bash 复制代码
export OPENAI_API_KEY=sk-...
# 可选:export OPENAI_BASE_URL=... OPENAI_CHAT_MODEL=...
# 国内百炼等:改 baseUrl + 模型名即可

mvn -q -Dtest=AiMcpDemoLlmTest test

落盘核对:

bash 复制代码
cat /tmp/ai-mcp-demo-sandbox/written-by-llm.txt
# 期望含:from-llm-write

stdio / LLM-stdio 每次测试自己拉 JAR 子进程,一般不用手动起 Server。SSE 相关用例才依赖终端 A。

改工具或配置后先重新 mvn package;只改文档可以不起服务。

十二、踩过的坑(按「你遇到什么现象」排)

现象:tools 为空 / Registered tools: 0

排查顺序建议:

  1. FilesystemTools 是否在入口类默认扫描包下(本工程在 tools 子包,通常无需额外 scanBasePackages
  2. 是否有 @Component
  3. 日志是否出现工具能力启用与 Registered tools: N
  4. capabilities.tool 是否为 true

确认 listTools 有四名后再测 callTool。

现象:SSE 握手失败 / 一直超时

显式写 protocol: SSE。baseUrl 和端口对齐。curl 探活加 --max-time

现象:stdio initialize 秒挂

Banner 或 console 日志还在污染 stdout。确认 Profile 真激活了 application-stdio.yml。用真 JAR 测,Mock 客户端证明不了打包后的 Profile。

现象:读写报越界 / allowed 对不上

Server roots 还是 user.home,测试断言 /tmp/...。对齐启动参数和测试常量。

命令行参数通常高于 JAR 内 YAML。改了 YAML 不生效,先查有没有命令行或环境变量残留。

现象:UnsupportedClassVersionError / release 21 not supported

JDK 不是 21。按第三节切。

现象:Surefire 全绿但 skipped > 0

环境不足(没起 SSE、没 package、没 Key)。不要解读成「业务全过了」。故意把 Server 停掉后,SSE 用例应该变成 skip,而不是乱报业务失败------这是 Assumption 的设计意图。

现象:模型回复说调用了,计数仍是 0

说明文本层与工具层不一致。调用用例以 CountingToolCallback 计数和落盘内容为准。

现象:读成功写失败 / 写成功读回失败

前者优先核对沙箱与父目录;后者优先核对断言路径是否写错,再回头查传输。

现象:改了工具名 / 端口只改一端

同步三处:Server 注解或配置、测试常量与断言、文档命令。只改一端常见结果是 SSE skip,或 stdio 子进程带着旧参数启动。推荐顺序:先改 Server,再改 test,再跑 mvn test

十三、一次 read_text_file 在链路上怎么走

客户端构造 CallToolRequest → 传输层发出去 → Server 反序列化参数 → 进 FilesystemTools.readTextFileresolveAllowedFiles.readStringok(text) → 传回 → 客户端拼 TextContent

任一阶段失败都能定位:

  • 连不上 / 超时 → 传输
  • isError=true → 业务(越界、不是文件)
  • listTools 没有名字 → 注册

写文件同一条链,多一个 content 参数。协议测试先读 hello.txt,再写 written-by-test.txt,再读回,就是在这条链上走两遍。

逻辑架构就一张图够了:

flowchart TB subgraph clients [Clients] StdioHost[stdio_host] JUnit[ai-mcp-demo-test] end subgraph server [ai-mcp-demo] Transport[Transport_SSE_or_stdio] Scanner[McpTool_Annotation_Scanner] Tools[FilesystemTools] Props[FilesystemProperties] end StdioHost --> Transport JUnit -->|SSE_or_stdio| Transport Transport --> Scanner Scanner --> Tools Tools --> Props Props --> Disk[Sandbox_Roots]

十四、和官方实现、Spring AI、私有 Tool 的关系

官方 Node 包 @modelcontextprotocol/server-filesystem 提供更完整的文件系统工具集,并强调允许根(roots)安全边界。本仓四个工具名与「允许根」概念与之对齐,实现栈换成 Spring AI,先覆盖「发现根 → 列目录 → 读 → 写」闭环;熟悉后可继续补齐官方有而本仓尚未实现的工具。

Spring AI 2.0 提供 stdio、SSE、Streamable 等 starter 与配置项。本工程选用 spring-ai-starter-mcp-server-webmvc,再用 Profile 启用 stdio;测试侧使用 MCP Java 客户端与 SyncMcpToolCallbackProvider,在官方协议之上做工程化封装,而不是自研协议栈。

应用内也可以继续使用框架私有 @Tool:路径更短,适合进程内闭环。MCP 更适合跨宿主、跨语言、可独立部署的能力暴露。两者可以按边界组合使用------例如业务内核用私有 Tool,对外共享能力再包一层 MCP。

传输与注解细节会随版本演进。升级时对照 Spring AI / MCP 发行说明,同步调整工程配置与测试常量即可。正文代码块为便于阅读会省略部分 import,完整可编译文本以仓库源文件为准。

参考

  1. MCP 官方概念:modelcontextprotocol.io/
  2. 官方 filesystem Server:github.com/modelcontex...
  3. Spring AI 参考文档:docs.spring.io/spring-ai/r...
  4. 本仓库:github.com/jiangbyte/h...ai-mcp-demo/ai-mcp-demo-test/
相关推荐
程序猿DD1 小时前
OctaFuse Gateway 2.11.0:流式请求优化、用户折扣策略与错误契约升级
前端·后端
Bs_MoneyMagnet1 小时前
基于springboot+vue的非遗物质文化遗产系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
打工仔折腾 AI2 小时前
Prometheus 告警推钉钉:从单群 Webhook 到跨网络 Alertmanager 实战
人工智能·后端·python·性能优化
console.log('npc')2 小时前
06 — Model 层:数据模型与操作
前端·后端·node.js·express
aramae2 小时前
模拟实现memset()(C语言)
c语言·开发语言·后端
2501_933923253 小时前
Spring Bean作用域揭秘:单例、原型、请求与会话的区别
java·后端·spring·java-ee
高级程序源3 小时前
django招聘网站信息爬取与分析系统79704-计算机课程设计、毕业设计
后端·python·mysql·小程序·django·flask·课程设计
伊信3 小时前
Spring AI 基础学习与应用
java·后端
步行cgn4 小时前
Spring 通过 factory-method 实例化 Bean 详解与常见错误排查
java·后端