先把结论说清楚:MCP 是一套开放标准,用来统一「大模型 / 宿主如何发现并调用外部能力」。你在 Server 上声明 tools(以及可选的 resources、prompts),Client 按名字发现、按参数调用;换传输(stdio / SSE / 后续还可扩展 Streamable 等)时,业务工具代码可以复用。
仓库里有两个可运行的独立工程,覆盖从 Server 到协议联调、再到模型真实调工具的完整链路:
ai-mcp-demo:MCP Server,四个 filesystem 工具,Profile 切换 SSE / stdioai-mcp-demo-test:协议联调(不经过大模型)+ OpenAI 兼容端点验证「模型会识别并真实调用工具」
技术栈:JDK 21 + Spring Boot 4.1.1 + Spring AI 2.0.0 。LLM 侧默认走 OpenAI 兼容协议,模型名默认 gpt-5-mini;也可改 baseUrl / 模型名对接国内兼容平台(如阿里云百炼),前提是支持 tool calling。
下面按「能跟着敲命令复现」来写,代码、配置、联调命令和常见问题都会展开。
这篇文章你读完能干什么
- 用 Spring AI 搭一个 MCP Server,暴露 filesystem 四工具:列允许根、列目录、读文件、写文件(可按同样模式继续扩展工具面)
- 同一个 JAR 支持 SSE(默认 8101)与 stdio,便于 HTTP 联调,也便于 Cursor 等宿主以子进程接入
- 用
McpSyncClient做协议层验收:initialize / listTools / callTool,不依赖大模型 - 用 OpenAI 兼容端点 +
CountingToolCallback做模型层验收:能列出工具名,且真实 call、写文件可落盘核对 - 掌握 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。两边各自 cd 后 mvn package / mvn test,依赖与构建互不影响。
也可以合成一个多模块或单工程------只要你能接受测试 classpath 与 Server Bean 边界更紧。拆开的好处是:测协议时不必拉起无关业务 Bean,排障时依赖也更清晰。
注意:这两个目录当前 不是 父 POM 的 <module>,需要进入各自目录单独构建。
2)协议一层、模型一层
「Server 工具挂没挂对」和「模型会不会选工具」是两件事。
- 协议层:
McpSyncClient.initialize→listTools→callTool,参数写死,断言读写字符串 - 模型层:
ChatModel.call/ChatClient挂ToolCallback,再用计数器看真实 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,两种挂法都能复现:
mvn clean package→release version 21 not supported- 用 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"
java 和 mvn 需要指向同一套 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 要同时给 path 和 content。
十、模型层:OpenAI 兼容端点 + 调用计数
协议绿说明「客户端硬编码 callTool 已通」。要验证「模型能否看见工具、会不会真实调用」,再跑 AiMcpDemoLlmTest。
当前工程使用:
spring-ai-starter-model-openai- 环境变量
OPENAI_API_KEY(可选OPENAI_BASE_URL、OPENAI_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.txt 含 from-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-sandbox、hello.txt、spring-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
排查顺序建议:
FilesystemTools是否在入口类默认扫描包下(本工程在tools子包,通常无需额外scanBasePackages)- 是否有
@Component - 日志是否出现工具能力启用与
Registered tools: N 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.readTextFile → resolveAllowed → Files.readString → ok(text) → 传回 → 客户端拼 TextContent。
任一阶段失败都能定位:
- 连不上 / 超时 → 传输
isError=true→ 业务(越界、不是文件)- listTools 没有名字 → 注册
写文件同一条链,多一个 content 参数。协议测试先读 hello.txt,再写 written-by-test.txt,再读回,就是在这条链上走两遍。
逻辑架构就一张图够了:
十四、和官方实现、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,完整可编译文本以仓库源文件为准。
参考
- MCP 官方概念:modelcontextprotocol.io/
- 官方 filesystem Server:github.com/modelcontex...
- Spring AI 参考文档:docs.spring.io/spring-ai/r...
- 本仓库:github.com/jiangbyte/h... (
ai-mcp-demo/、ai-mcp-demo-test/)