Java 手写第一个 MCP Server:Spring AI MCP 半小时跑通

Java 手写第一个 MCP Server:Spring AI MCP 半小时跑通

系列三第 2 篇,动手篇。上一篇聊了 MCP 和 Skill 的区别,结尾答应过:这篇用 Java + Spring AI 把一个 MCP Server 从建工程到客户端调通完整跑一遍,包括踩的坑。今天兑现。

还是先给新朋友三十秒背景。我们是四人 Java 团队,今年 AI 全职写代码,人负责描述需求、审代码、拍板架构。五个月下来,线上 bug 月均从 3 个冲到 11 个、再降回 2 个,PR 平均周期从 32 小时压到 9 小时。过程中攒下的 41 条 AI 代码审查规则已经开源(github.com/wangheng19901021/skills,MIT 协议):并发安全关 9 条、事务关 8 条、安全关 11 条、空指针关 7 条、需求评审关 6 条,其中 13 条是线上事故炸出来的,28 条是架构推演时先立的。

这篇要干的事:把这 41 条规则库包装成一个 MCP Server,对外提供 3 个查询工具,让 AI 审查代码时能实时查规则全文。全程用 Spring AI 官方的 MCP Server starter,从建工程到调通,半小时。敢把"半小时"写进标题,是因为坑我都提前蹚过了,一共四个------这篇最值钱的部分就在最后。

选题:为什么拿规则库练手

先声明一句:规则库放 Skill 里依然够用,这个上一篇的结论没变。拿它做 demo,是因为它是我手头最熟的数据集,练手成本低;真正打算接 MCP 的是查数据库 schema 那类活,哪天接上了再单独写复盘。

第一个 MCP Server 练手,题材怎么挑?我的标准:自己天天在用、数据量小、查询语义清晰。审查规则库三条全占------41 条规则天然就是个可查询的数据集,AI 审查到一段高危代码时按 id 拉回规则全文,是真实会发生的需求。而且规则全文是带决策树和正反例的长段多行文本,刚好能检验返回值在这种形态下的表现,后面坑二证明这个担心并不多余。

工具设计了三个,刚好覆盖"查数据集"的三种基本动作:

demo 里内置 3 条完整示例规则(concurrent-stock-deduct、tx-rpc-after-commit、ssrf-metadata),完整 41 条在 GitHub 仓库里。

版本组合:先把版本钉死

MCP 和 Spring AI 这两条线今年都变得快,动手前先把版本钉死:

两个提醒。一是 Spring AI 2.0.1 已经不支持 Boot 3.x,网上 2025 年的教程大多是 Boot 3.x 配 1.0.0-M 系列的版本号,依赖坐标对不上,照抄容易起不来。二是传输协议的口径:上一篇说过,Spring AI 2.0 里 SSE 传输已标记 deprecated,官方推荐 Streamable HTTP,端点是 POST /mcp。我这回为了配合经典的 SSEClientTransport 客户端写法,显式配了 spring.ai.mcp.server.protocol: SSE,用回老的 /sse 端点。新工程建议直接上 Streamable HTTP,这里只是为了让客户端代码最短。

建工程:两个依赖

pom.xml 的关键部分:

XML 复制代码
<parent>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-parent</artifactId>

    <version>4.1.1</version>

</parent>



<properties>

    <java.version>21</java.version>

    <spring-ai.version>2.0.1</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>

    <!-- webmvc starter 只带 spring-webmvc,嵌入式 Tomcat 由 web starter 提供 -->

    <dependency>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-web</artifactId>

    </dependency>

</dependencies>

就两个依赖,但有个细节:spring-ai-starter-mcp-server-webmvc 只带 spring-webmvc,嵌入式 Tomcat 得靠 spring-boot-starter-web 提供。少了后者,启动时连 Servlet 容器都找不到。

写工具:三个 @Tool 方法

核心类 ReviewRulesService 就是个普通 @Service,规则数据用 Map 内置。每个工具是一个加了 @Tool 注解的方法,拿 getReviewRule 举例:

java 复制代码
@Tool(name = "getReviewRule", description = "按规则 id 获取单条审查规则全文,四段式:适用条件、决策树、禁止写法、正反例", resultConverter = PlainTextResultConverter.class)

public String getReviewRule(

        @ToolParam(description = "规则 id,如 concurrent-stock-deduct、tx-rpc-after-commit、ssrf-metadata") String ruleId) {

    Rule rule = rules.get(ruleId);

    if (rule == null) {

        return "未找到规则 [" + ruleId + "]。本 demo 可用 id:" +

                rules.keySet().stream().collect(Collectors.joining(", ")) +

                "。完整 41 条见 github.com/wangheng19901021/skills";

    }

    return "规则 " + rule.id() + "(" + rule.gate() + " · " + rule.title() + ")\n"

            + "来源:" + rule.origin() + "\n\n" + rule.body();

}

@Tool 上有三个属性值得说。name 是工具对外的名字。description 是给模型看的------上一篇说过,模型看到的工具定义本质是一段 prompt 文本,它靠读 description 决定调不调、传什么参数,所以这句描述我写得比 JavaDoc 还认真。resultConverter 先记住它出现过,坑二会讲:不加这个参数,客户端收到的文本没法直接看。

方法参数上的 @ToolParam 同理,它的 description 会进 JSON Schema,模型靠它知道 ruleId 该填什么格式。

注册和配置

光有 @Tool 方法还不够,得告诉 Spring AI 把它们注册成 MCP 工具。一个配置类搞定:

java 复制代码
@Configuration

public class McpServerConfig {


    @Bean

    public ToolCallbackProvider reviewRulesTools(ReviewRulesService reviewRulesService) {

        return MethodToolCallbackProvider.builder().toolObjects(reviewRulesService).build();

    }

}

然后是 application.yml:

XML 复制代码
server:

  port: 8080



spring:

  main:

    banner-mode: "off"

    web-application-type: servlet

  ai:

    mcp:

      server:

        name: review-rules-server

        version: 0.0.1

        protocol: SSE   # Spring AI 2.0 起 SSE 已 deprecated,官方推荐 STREAMABLE;demo 为配合 SSEClientTransport 沿用 SSE



logging:

  level:

    root: warn

    com.example.reviewrules: info   # 必须放行:Boot 的 "Started ..." 日志挂在主类 logger 下

    org.springframework.boot: info

    org.apache.catalina.core: info

    io.modelcontextprotocol: info

    org.springframework.ai.mcp: info

name 和 version 会在握手时作为服务端身份报给客户端。protocol: SSE 就是前面说的显式声明,不写的话 2.0 默认走 Streamable HTTP。logging 那块先不解释------com.example.reviewrules: info 这行注释写着"必须放行",这是坑一留下的疤,后面讲。

启动验证:Node 客户端真调一把

mvn package 之后 java -jar 启动,日志里这三行最关键:

XML 复制代码
2026-08-21T22:31:07.134+08:00  INFO 4516 --- [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat initialized with port 8080 (http)

2026-08-21T22:31:08.596+08:00  INFO 4516 --- [           main] o.s.a.m.s.c.a.McpServerAutoConfiguration : Registered tools: 3

2026-08-21T22:31:09.005+08:00  INFO 4516 --- [           main] c.e.r.McpReviewRulesApplication          : Started McpReviewRulesApplication in 5.646 seconds (process running for 7.037)

Registered tools: 3,三个工具注册成功。你可能要问了:日志里还有一行 WARN 写着 No tool methods found in the provided tool objects: \[\],是不是有东西没扫到?别怕。那是 Spring AI 2.0 新增的 @McpTool 注解扫描器在找另一种注解,我们没用它,扫不到属正常;我们的 @Tool 走的是上面 ToolCallbackProvider 这条注册路径,两码事。

客户端我故意没用 Java 写,用了 Node 加官方 JS SDK:Java 写的 Server 被另一门语言调通,"协议"两个字才算坐实。核心代码如下,注释是我后加的:

javascript 复制代码
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";



const transport = new SSEClientTransport(new URL("http://localhost:8080/sse"));

const client = new Client(

  { name: "review-rules-demo-client", version: "0.0.1" },

  { capabilities: {} }

);

await client.connect(transport);



// tools/list:问 Server 有哪些工具

const { tools } = await client.listTools();



// callTool:真实调用 getReviewRule

const result = await client.callTool({

  name: "getReviewRule",

  arguments: { ruleId: "ssrf-metadata" }

});

跑起来,真实输出原样贴在这里:


client 连接 MCP Server: http://localhost:8080/sse ...

client SSE 连接成功

client tools/list 结果:共 3 个工具

  • getReviewRule : 按规则 id 获取单条审查规则全文,四段式:适用条件、决策树、禁止写法、正反例

  • listReviewRules : 列出团队 AI 代码审查规则库的完整目录:五关各多少条、规则总量、来源构成

  • searchReviewRules : 按关键词模糊搜索审查规则,返回命中规则的 id、所属关卡和摘要

client callTool getReviewRule({ ruleId: "ssrf-metadata" }) 返回:


规则 ssrf-metadata(安全关 · SSRF 白名单必须拦内网段与云元数据接口)

来源:推演立规

【适用条件】

任何由用户传入 URL、由服务端发起请求的代码:头像抓取、网页摘要、

webhook 回调、图片转存。攻击面是服务端代替攻击者访问内网。

【决策树】

  1. URL 是用户可控的吗?

├─ 否 → 走普通 HTTP 审查

└─ 是 → 2

  1. 白名单校验覆盖了哪些目标?

├─ 只拦 127.0.0.1 → 不合格,必须全量拦截:

│ 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、

│ 169.254.169.254(云元数据接口!Capital One 泄露打的就是它)、

::1、0.0.0.0,以及 DNS 重绑定(解析后再校验一次)

└─ 域名白名单 + 解析后 IP 二次校验 → 合格

【禁止写法】

禁止只拦 127.0.0.1。云上 SSRF 的头号目标是 169.254.169.254,

拿到临时凭证等于拿到整台机器的权限。也禁止只校验域名不校验解析结果。

【正反例】

✗ 反例:

if (url.contains("127.0.0.1")) { throw new IllegalArgumentException(); }

httpGet(url); // 169.254.169.254/latest/meta-data/iam/ 畅通无阻

✓ 正例:

InetAddress addr = InetAddress.getByName(host); // 先解析

if (addr.isLoopbackAddress() || addr.isSiteLocalAddress()

|| addr.isLinkLocalAddress() // 169.254.0.0/16

|| !domainWhitelist.contains(host)) {

throw new SecurityException("SSRF blocked: " + host);

}


client 连接已关闭,demo 结束

```

tools/list 拿回 3 个工具,callTool getReviewRule("ssrf-metadata") 拿回安全关那条 SSRF 规则的完整全文,包括为什么白名单必须拦 169.254.169.254 这个云元数据地址。上一篇预告里答应展示的就是这一屏。顺手又验了 searchReviewRules("事务"),命中事务关那条 tx-rpc-after-commit,摘要里写着"推演立规,未真炸,后拦回一次"------模糊搜索这条路径也是通的。

顺带一提:官方还有个图形化的 MCP Inspector(npx @modelcontextprotocol/inspector),能把 Server 的工具列出来点着调、看原始 JSON-RPC 报文,不少人拿它调 stdio 类的 Server。我们这轮没用它,用脚本是为了把输出原样留成文件当证据。

两趟模型请求的循环:决定 → 执行 → 回填 → 再生成

上一篇结尾留了个扣子:模型决定调工具之后发生了什么,这篇拆。

先坦白口径:上面那个脚本没接模型,tools/list 和 callTool 是我手动打的两发协议请求,验证的是 Server 工作正常。真实 AI 应用里,这两发请求中间隔着模型,完整链条长这样:

```

第 1 趟模型请求:用户提问 + 工具清单(启动时 tools/list 注入 system prompt)

→ 模型决定:调 getReviewRule,参数 ruleId="ssrf-metadata"

执行:Host 里的 MCP Client 发 callTool → Server 跑 Java 方法 → 返回规则全文

回填:工具结果作为一条消息塞回对话上下文

第 2 趟模型请求:对话历史 + 工具结果

→ 模型基于规则全文,生成给你看的最终回答

```

之所以要两趟,是因为模型自己执行不了代码:它只能输出"我想调这个工具、参数是什么"这段结构化文本,真正动手的是 Client,跑完把结果回填,模型看到结果再生成最终回答。

这个循环有个实战推论:description 写得好不好,直接决定第一趟请求里模型选不选你的工具、参数填得对不对。所以 @Tool 里那句描述本身就是功能,写的时候别当注释对付。

四个坑(本篇最值钱的部分)

上一篇预告里点过名的版本线换代和 SSE deprecated,前面版本组合一节已经把结论给了,不再展开。真正咬人的是下面这四个。

坑一:Started 日志"消失",服务其实活着

为了控制台干净,我把 logging.level.root 调成了 warn。再启动,那行熟悉的 Started McpReviewRulesApplication in x seconds 不见了,控制台一片安静。当时第一反应是启动卡死,查端口、翻日志折腾了小半天,挺窝火。

真相:这行启动完成日志的 logger 名是主类的全限定名------SpringApplication.getApplicationLog() 里就是 LogFactory.getLog(mainApplicationClass)。root 调成 warn 之后,主类包的 INFO 日志全被吞掉。而服务其实早就起来了:jstack 里能看到 DestroyJavaVM 线程,curl /sse 返回 200。

修复就是 yml 里那行:logging.level 下显式放行 com.example.reviewrules: info。所以注释写的是"必须放行"。(这行注释现在回头看,真是血泪。)

坑二:String 返回值被 JSON 序列化

刚调通时,客户端收到的文本大概是这个样子:

```

"规则 ssrf-metadata(安全关 · SSRF 白名单必须拦内网段与云元数据接口)\n来源:推演立规\n\n【适用条件】\n任何由用户传入 URL、由服务端发起请求的代码......"

```

一整条带引号的 JSON 字符串,换行全变成 \n,没法直接读。一开始怀疑过客户端的打印逻辑,把返回的 content 数组拆开看,里面那条 text 本身就是带引号的 JSON 字符串------问题出在 Server 侧。顺着依赖翻进 starter 的源码才看明白:Spring AI 2.0 的 DefaultToolCallResultConverter 对 String 返回值也统一走 JsonHelper.toJson()。

修法是自定义一个原样返回的转换器,拢共十几行:

java 复制代码
public class PlainTextResultConverter implements ToolCallResultConverter {



    @Override

    public String convert(Object result, Type returnType) {

        return result == null ? "" : result.toString();

    }

}

然后在 @Tool 上挂 resultConverter = PlainTextResultConverter.class------就是前面让你记住的那个参数。改完客户端拿到的就是干干净净的多行文本,也就是上面那屏输出的样子。

坑三:Git Bash 后台化,kill 错了进程

Windows + Git Bash 环境专享。想一条命令搞定"进目录、起服务、后台化":

bash 复制代码
cd /f/notes/projects/.../mcp-review-rules-server && java -jar target/*.jar &

PID=$!

跑完 kill PID,再起新进程,报 8080 端口被占。查下来才发现:\& 会把整条 \&\& 链放进一个子 shell 里后台执行,! 拿到的是子 shell 的 PID;kill 掉它,里面的 java 还活着。(这个坑我踩了两回才长记性。)

解法写在 run-demo.sh 里:cd 先执行完,java 单独一行再 &,这样 ! 才是 java 本尊;Windows 下收尾再补一刀 taskkill //PID PID //F 兜底。

坑四:内置 npm 是残缺的

最后这个轻一点。我本机跑客户端用的是 Kimi Desktop 内置的 Node 运行时,自带的 npm 是个残缺版,install 直接报错。解法:换完整版 npm(拿 npm-cli.js 直接驱动),registry 指向 npmmirror(https://registry.npmmirror.com)。CI 容器、精简镜像里也可能碰到类似情况,记一笔。

收尾

回头看,一个 MCP Server 的骨架就三件事:两个依赖、几个 @Tool 方法、一个 ToolCallbackProvider 配置类,半小时够用。真正花时间的是上面那些教程里没有的细节,它们也是这篇存在的理由。

demo 完整工程(含一键复跑脚本 run-demo.sh 和那份真实输出)已推到 GitHub:github.com/wangheng19901021/skills/tree/master/demo/mcp-review-rules-server,评论区置顶也会放。41 条规则库本身一直在 github.com/wangheng19901021/skills,MIT 协议,拿去把内容换成你们团队自己踩的坑就能跑。

下一篇,系列三第 3 篇《Skill 还是 MCP?一张决策表说清(Java 团队版)》:三把标尺合一,什么时候该把 Skill 升级成 MCP,什么时候千万别升,一张表说清。

你跑通自己的第一个 MCP Server 花了多久、卡在哪个坑,欢迎来评论区对答案。想第一时间看到第 3 篇的,关注专栏「实战skill」,更新会提醒。

------ 硅基书斋主理人,十年 Java 后端

相关推荐
find1star42 分钟前
LeetCode 141:环形链表
java·算法·leetcode·链表
TechEdu2026061 小时前
[人工智能]国内国外大型语言模型技术比较指南V02(2026.9月)
人工智能·ai
AI人工智能集结号1 小时前
2026年9月GEO优化与传统SEO怎么选?预算应该先投向哪一个?
人工智能·geo优化
console.log('npc')1 小时前
Git 冲突与 AI 协助指南
前端·人工智能·git·大模型
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-09-05
人工智能·深度学习·神经网络·搜索引擎·百度
魔众1 小时前
5 分钟用 AIGCPanel 部署阿里 SenseVoice,中粤日韩英语音识别 + 情感分析全搞定
人工智能·语音识别
xwz小王子1 小时前
机器人的“最后一毫米”: 新加坡南洋理工大学Facet-0如何教会基础模型“感受”自己的动作?
大数据·人工智能·机器人
今天AI了吗1 小时前
DeepSeek Harness 深度解析:从评测架构到实战落地
java·网络·数据库·人工智能·架构·java-ee
腾视科技-AI2 小时前
腾视科技AIBOX双版本重磅发布!本地安全与全球适配,解锁视频智能新可能
大数据·人工智能·科技·安全·大模型·腾视科技·ai算力盒