1. SpringBoot3 项目里为什么还要引入 SolonMCP
如果你手上已经有一个跑得好好的 SpringBoot3 后端,现在产品或者架构那边提了个需求:把现有的几个业务方法暴露成 MCP 工具,让 Claude Code、Cline 这类客户端能直接调用。第一反应可能是上 Spring AI,但真动手会发现两件事:一是 Spring AI 的 MCP 模块版本迭代快,二是把已有 Service 改造成工具类要动不少注解和配置。这时候 SolonMCP(solon-ai-mcp)就是一个很轻的替代路径——它本身是 Solon AI 的扩展,但支持内嵌到 SpringBoot3 里,用注解把普通 Bean 标成 MCP 端点,不需要你把整个项目迁到 Solon 体系。
MCP 是什么,用一句话说:它是一个让大模型客户端按标准协议发现并调用你服务端能力的通道。你写一个getWeather方法,客户端就能在工具列表里看到它,传参调用,拿到返回值。适合谁?适合那些已经有 Java 后端、想快速给 AI 编码工具或 Agent 暴露内部接口的团队,尤其是你不想为了一个 MCP 能力去重构整个依赖树的情况。
我试过在 SpringBoot3.2 + JDK17 的环境里把 SolonMCP 嵌进去,整体感受是:依赖只多两个包,配置类一个,工具类按普通 Spring 组件写,启动后通过 SSE 端点验证注册结果。下面按依赖配置、初始化、工具方法、验证请求、排错、联调通道的顺序走一遍,每一步都给可复制的代码。
需要先明确一点:SolonMCP 在 SpringBoot3 下和 SpringBoot2 的差别,主要就是 servlet 容器那层依赖包名不同,因为 Jakarta EE 改名了。SpringBoot3 用solon-web-servlet-jakarta,SpringBoot2 用旧的solon-web-servlet-javax。其余 API 基本一致,所以 SpringBoot2 的经验可以平移过来。
2. 前置准备:依赖、目录与 SolonMCP 初始化配置
2.1 pom.xml 里加什么依赖
SpringBoot3 项目引入 SolonMCP,核心是两个依赖:solon-ai-mcp提供 MCP 服务端能力,solon-web-servlet-jakarta负责把 Solon 的 Servlet 过滤器挂到 SpringBoot3 的 Jakarta 容器上。版本号建议用当前稳定版,下面给一个可复制的片段:
<properties> <java.version>17</java.version> <solon.version>3.0.5</solon.version> </properties> <dependencies> <!-- SpringBoot3 Web 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- SolonMCP 核心:提供 McpServerEndpoint、ToolMapping 等注解 --> <dependency> <groupId>org.noear</groupId> <artifactId>solon-ai-mcp</artifactId> <version>${solon.version}</version> </dependency> <!-- SpringBoot3 专用 Servlet 适配(Jakarta 命名空间) --> <dependency> <groupId>org.noear</groupId> <artifactId>solon-web-servlet-jakarta</artifactId> <version>${solon.version}</version> </dependency> </dependencies>这里有个容易踩的坑:如果你从 SpringBoot2 的项目复制依赖,很可能带进来的是solon-web-servlet-javax,在 SpringBoot3 下启动会报ClassNotFoundException: javax.servlet.Filter。因为 SpringBoot3 全面切到jakarta.servlet,所以必须换成 jakarta 版本。这也是 SpringBoot3 和 SpringBoot2 使用 SolonMCP 唯一的依赖差异。
2.2 目录结构建议
为了让 SpringBoot 的组件扫描和 Solon 的端点收集不打架,建议单独开一个包放 MCP 相关类:
src/main/java/com/example/demo/ ├── HelloApp.java // 启动类 └── mcpserver/ ├── IMcpServerEndpoint.java // 标记接口 ├── McpServerConfig.java // 初始化配置 └── tool/ └── McpServerTool.java // 具体工具端点2.3 标记接口与初始化配置
先定义一个空接口,作用只是让 Spring 能按类型收集所有 MCP 端点组件:
package com.example.demo.mcpserver; public interface IMcpServerEndpoint { }然后是配置类,它做三件事:启动 Solon 生命周期、把收集到的端点转成McpServerEndpointProvider、注册 Solon 的 Servlet 过滤器到/mcp/*:
package com.example.demo.mcpserver; import org.noear.solon.Solon; import org.noear.solon.ai.mcp.server.McpServerEndpointProvider; import org.noear.solon.ai.mcp.server.annotation.McpServerEndpoint; import org.noear.solon.ai.mcp.server.tool.MethodToolProvider; import org.noear.solon.ai.mcp.server.resource.MethodResourceProvider; import org.noear.solon.ai.mcp.server.prompt.MethodPromptProvider; import org.noear.solon.web.servlet.SolonServletFilter; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.AnnotationUtils; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import java.util.List; @Configuration public class McpServerConfig { @PostConstruct public void start() { Solon.start(McpServerConfig.class, new String[]{"--cfg=mcpserver.yml"}); } @PreDestroy public void stop() { if (Solon.app() != null) { Solon.stopBlock(false, Solon.cfg().stopDelay()); } } @Bean public McpServerConfig init(List<IMcpServerEndpoint> serverEndpoints) { for (IMcpServerEndpoint serverEndpoint : serverEndpoints) { // 注意:SpringBoot 下必须用 Spring 的 AnnotationUtils, // 否则拿不到 @McpServerEndpoint 注解 McpServerEndpoint anno = AnnotationUtils.findAnnotation( serverEndpoint.getClass(), McpServerEndpoint.class); if (anno == null) { continue; } McpServerEndpointProvider provider = McpServerEndpointProvider.builder() .from(serverEndpoint.getClass(), anno) .build(); provider.addTool(new MethodToolProvider(serverEndpoint)); provider.addResource(new MethodResourceProvider(serverEndpoint)); provider.addPrompt(new MethodPromptProvider(serverEndpoint)); provider.postStart(); } return this; } @Bean public FilterRegistrationBean<SolonServletFilter> mcpServerFilter() { FilterRegistrationBean<SolonServletFilter> filter = new FilterRegistrationBean<>(); filter.setName("SolonFilter"); filter.addUrlPatterns("/mcp/*"); filter.setFilter(new SolonServletFilter()); return filter; } }这里AnnotationUtils用的是org.springframework.core.annotation.AnnotationUtils,不是 Solon 自带的那个。因为 Spring 的代理机制会给 Bean 生成子类,直接getClass().getAnnotation()可能拿不到注解,必须用 Spring 的工具去查父类。这个点我在第一次写的时候没注意,结果端点一直注册不上,日志里也没有报错,排查了半天。
3. 可复制配置:MCP 工具方法、SSE 端点与 mcpserver.yml
3.1 工具端点类怎么写
工具类就是一个普通 Spring 组件,实现刚才的标记接口,加上@McpServerEndpoint注解。注解里的name是端点名,sseEndpoint是客户端连接的 SSE 路径:
package com.example.demo.mcpserver.tool; import com.example.demo.mcpserver.IMcpServerEndpoint; import org.noear.solon.ai.chat.message.ChatMessage; import org.noear.solon.ai.mcp.server.annotation.McpServerEndpoint; import org.noear.solon.ai.mcp.server.annotation.Param; import org.noear.solon.ai.mcp.server.annotation.PromptMapping; import org.noear.solon.ai.mcp.server.annotation.ResourceMapping; import org.noear.solon.ai.mcp.server.annotation.ToolMapping; import org.springframework.stereotype.Component; import java.util.Arrays; import java.util.Collection; @Component @McpServerEndpoint(name = "demo1", sseEndpoint = "/mcp/demo1/sse") public class McpServerTool implements IMcpServerEndpoint { @ToolMapping(description = "查询天气预报") public String getWeather(@Param(description = "城市位置") String location) { return "晴,14度"; } @ResourceMapping(uri = "config://app-version", description = "获取应用版本号") public String getAppVersion() { return "v3.2.0"; } @ResourceMapping(uri = "db://users/{user_id}/email", description = "根据用户ID查询邮箱") public String getEmail(@Param(description = "用户Id") String user_id) { return user_id + "@example.com"; } @PromptMapping(description = "生成关于某个主题的提问") public Collection<ChatMessage> askQuestion(@Param(description = "主题") String topic) { return Arrays.asList( ChatMessage.ofUser("请解释一下'" + topic + "'的概念?") ); } }@Component这个注解别用错,Solon 里也有同名的@Component,如果你 import 成了org.noear.solon.annotation.Component,Spring 扫描不到,端点就不会注册。建议在 IDE 里确认 import 是org.springframework.stereotype.Component。
3.2 编译参数与参数名
@Param注解里写了 description,但参数名location、user_id要能被反射拿到,需要在编译时保留参数名。Maven 里加:
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</parameters> </configuration> </plugin> </plugins> </build>如果不加-parameters,客户端调用时可能报参数缺失,或者你得在@Param里显式写name = "location"。两种方式都行,但加编译参数更省事。
3.3 mcpserver.yml 配置
Solon 启动时会读--cfg=mcpserver.yml,这个文件放在src/main/resources下。最小配置如下:
solon: app: name: demo-mcp-server # 关闭 Solon 自带的 HTTP 服务,只借用它的 MCP 能力 # 因为 Web 容器由 SpringBoot 提供 server: port: 0把 Solon 自己的 server 端口设为 0 或者干脆不启用,避免和 SpringBoot 的 8080 冲突。MCP 的请求实际是通过 SpringBoot 的 Servlet 容器进来的,Solon 只负责处理/mcp/*路径下的协议逻辑。
3.4 启动类
启动类保持标准 SpringBoot 写法即可:
package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class HelloApp { public static void main(String[] args) { SpringApplication.run(HelloApp.class, args); } }启动后,SpringBoot 监听 8080,Solon 的过滤器挂在/mcp/*,客户端连http://localhost:8080/mcp/demo1/sse就能拿到工具列表。
4. 验证请求:确认工具注册与调用是否生效
4.1 用 curl 看 SSE 端点
启动应用后,先确认 SSE 端点能连上。MCP 的 SSE 是长连接,用 curl 加-N禁用缓冲:
curl -N http://localhost:8080/mcp/demo1/sse正常的话会看到类似event: endpoint和data: /mcp/demo1/message?sessionId=xxx的输出,说明端点已注册,客户端可以拿这个 sessionId 去发消息。
4.2 写一个 Java 客户端验证工具调用
更直观的方式是写个测试类,用McpClientProvider连上去调工具:
import org.noear.solon.ai.mcp.client.McpClientProvider; import java.util.Collections; import java.util.Map; public class McpClientTest { public static void main(String[] args) throws Exception { McpClientProvider toolProvider = McpClientProvider.builder() .apiUrl("http://localhost:8080/mcp/demo1/sse") .build(); // 工具调用 Map<String, Object> map = Collections.singletonMap("location", "杭州"); String rst = toolProvider.callToolAsText("getWeather", map).getContent(); System.out.println("工具返回: " + rst); // 资源读取 String version = toolProvider.readResourceAsText("config://app-version").getContent(); System.out.println("资源返回: " + version); } }跑起来应该输出:
工具返回: 晴,14度 资源返回: v3.2.0如果callToolAsText返回空或者抛异常,先检查getWeather方法上的@ToolMapping是否被扫描到,再看McpServerConfig里的init方法有没有真的执行——可以在里面加一行日志打印serverEndpoints.size()。
4.3 把 MCP 客户端当 LLM 工具集用
SolonMCP 还支持把 MCP 客户端直接挂到 ChatModel 上,让模型自己决定调哪个工具。本地测试可以用 Ollama:
import org.noear.solon.ai.chat.ChatModel; import org.noear.solon.ai.chat.ChatResponse; import org.noear.solon.ai.mcp.client.McpClientProvider; public class McpWithLlmTest { public static void main(String[] args) throws Exception { McpClientProvider toolProvider = McpClientProvider.builder() .apiUrl("http://localhost:8080/mcp/demo1/sse") .build(); ChatModel chatModel = ChatModel.of("http://127.0.0.1:11434/api/chat") .provider("ollama") .model("qwen2.5:1.5b") .defaultToolsAdd(toolProvider) .build(); ChatResponse resp = chatModel.prompt("杭州今天的天气怎么样?").call(); System.out.println(resp.getMessage()); } }模型会先调getWeather,拿到「晴,14度」,再组织成自然语言回答。这一步验证的是工具注册和模型调用链都通了。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
5.1 启动报 ClassNotFoundException: javax.servlet.Filter
这是 SpringBoot3 下最常见的错,原因就是依赖引成了solon-web-servlet-javax。检查 pom,确保是:
<artifactId>solon-web-servlet-jakarta</artifactId>SpringBoot3 的spring-boot-starter-web带的是jakarta.servlet-api,两者命名空间必须一致。
5.2 端点注册不上,客户端连 /mcp/demo1/sse 返回 404
先看McpServerConfig里的init方法有没有被调用。如果List<IMcpServerEndpoint>是空的,说明 Spring 没扫描到McpServerTool。检查两点:一是@Component的 import 是不是 Spring 的;二是McpServerTool所在的包是否在@SpringBootApplication的扫描范围内。
还有一种情况是AnnotationUtils用错了包。必须用org.springframework.core.annotation.AnnotationUtils,用 Solon 的AnnotationUtils在 Spring 代理下拿不到注解,anno为 null 就直接 continue 了,端点静默丢失。
5.3 客户端调用报 401 或 local proxy failed
如果你把 MCP 服务端地址指向了远程统一通道,比如 TaoToken 的 API 通道,出现 401 通常是 Key 没带或者带错位置。MCP 客户端配置里要同时给全三件套:Base URL、API Key、Model ID。以 Cline 或 Claude Code 这类客户端为例,配置片段长这样:
{ "mcpServers": { "demo1": { "url": "https://taotoken.net/api/mcp/demo1/sse", "headers": { "Authorization": "Bearer sk-你的Key" } } } }local proxy failed一般是客户端本地代理层连不上目标地址,先确认url能不能在浏览器或 curl 里通,再确认 Key 有没有过期。如果是 Codex 的auth.json方式,字段名要对齐:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "claude-sonnet-4-5" }5.4 报 reading choices 或返回体解析失败
reading choices这类错通常出现在把 MCP 端点和 OpenAI 兼容接口混用的时候。MCP 的 SSE 返回的是事件流,不是choices结构。如果你用 OpenAI SDK 去请求 MCP 的/sse路径,解析器会找不到choices字段而报错。正确做法是:MCP 客户端用McpClientProvider或支持 MCP 协议的客户端;只有走 LLM 对话接口时才用 OpenAI 兼容格式,两者路径不同。
5.5 OAuth 相关报错
部分客户端在连远程 MCP 时会尝试 OAuth 流程,如果服务端没开对应能力,会报OAuth discovery failed之类。这种情况下在客户端配置里显式指定用 Bearer Token 认证,跳过 OAuth 发现。TaoToken 的 API 通道用统一 Key 即可,不需要额外 OAuth 配置。
6. 把服务端地址切到 TaoToken 统一通道联调
本地验证通过后,下一步通常是把 MCP 服务端暴露到能被外部客户端访问的地址。如果你不想自己维护公网入口和 Key 管理,可以把请求通道切到 TaoToken 的统一 Key/API 通道。它的 API 入口是https://taotoken.net/api,官网是https://taotoken.net/?utm_source=taotoken_aicg_blog_end。
具体做法分两步。第一步,在 TaoToken 控制台创建一个 API Key,路径是 console 里的 API Keys 页面。第二步,把客户端配置里的url从http://localhost:8080/mcp/demo1/sse换成统一通道地址,并在 headers 里带上 Key。这样你的 SpringBoot3 服务端不用改代码,只是客户端连接的目标变了。
联调时建议先用模型对话页面确认 Key 本身可用,再切到 MCP 客户端。如果对话能通、MCP 报 401,那问题就在 MCP 客户端的 header 配置,而不是 Key 本身。长期跑编码 Agent 或需要稳定调用额度的场景,可以看下 Coding Plan 的额度方案,比按次调用更适合高频工具调用。
最后留一个实操建议:MCP 工具方法的返回值尽量保持简单字符串或小 JSON,别在工具里做重业务查询。因为客户端调用工具是同步等待的,一个慢查询会拖住整个对话轮次。把重逻辑放到异步任务里,工具方法只负责触发和返回任务 ID,这样联调时不容易超时。