SpringBoot2 江湖救急:用 SolonMCP 给 Java8 老项目接上 MCP 能力
1. SpringBoot2 Java8 老项目接 MCP 的真实困境如果你手上跑着一个 2019 年前后上线的 SpringBoot2 项目JDK 还锁在 1.8最近又被要求「接入 MCP 能力」那你大概率已经踩过一轮坑了。MCP 官方 Java SDK 明确要求 Java 17 起步Spring AI 里那套 MCP 支持同样卡在 Java 17连编译都过不去。直接升 JDK牵一发动全身SpringBoot2 的很多依赖在 Java 17 上行为不一致线上跑着的定时任务、老版本 MyBatis、内部封装的工具类随便一个都可能炸。我试过的思路有三条一是硬升 JDK评估完直接放弃二是单独起一个 Java 17 的 MCP 服务让老项目通过 HTTP 调它架构上多一跳运维多一个进程三是找一个能在 Java 8 上跑、又能内嵌进现有 SpringMVC 容器的 MCP 实现。第三条就是 SolonMCPsolon-ai-mcp走的路子。SolonMCP 是 Solon 生态里的一个扩展模块它本身不绑定 Web 框架支持内嵌到 JFinal、Vert.x、SpringBoot2、SpringBoot3 里。核心价值就一句话用 Java 8 也能开发 MCP 服务端并且能挂在你现有的 SpringBoot2 Web 容器上共用同一个端口。它不要求你升级框架不要求你换 Web 服务器只加几个依赖、写一个配置类、注册一个 FilterMCP 的 SSE 端点就能和原来的/api/*接口并存。这篇文章面向的就是这类存量项目SpringBoot2 Java8 SpringMVC目标是在不动框架版本的前提下跑通一个最小可用的 MCP 服务端并用客户端验证一次工具调用。下面从依赖引入开始一步步给可复制的配置。2. SolonMCP 前置准备与依赖冲突排查2.1 先确认你的项目基线在动手之前先确认三件事JDK 版本是 1.8java -version输出 1.8.0_xxxSpringBoot 版本是 2.xpom.xml里spring-boot-starter-parent是 2.7.x 或更低Web 层是 SpringMVC有spring-boot-starter-web。这三条满足后面的步骤基本能直接套。SolonMCP 的 Maven 坐标很干净主依赖就一个dependency groupIdorg.noear/groupId artifactIdsolon-ai-mcp/artifactId version3.4.0/version /dependency版本号建议去 Maven 中央仓库查最新的 3.x 稳定版Solon 的版本迭代比较快3.4.0 是我写这篇时验证过的版本。注意不要引solon-ai-mcp-server之类的旧坐标那是早期拆分出来的现在统一在solon-ai-mcp里。2.2 依赖冲突的排查重点老项目引新依赖最怕的就是传递依赖把现有版本顶掉。SolonMCP 会带进来solon-core、solon-ai-core这些包它们本身不依赖 Spring所以和 SpringBoot2 的冲突面很小。真正需要盯的是两个地方第一snakeyaml或jackson的版本。Solon 内部解析 YAML 配置用的是自己的solon-config-yaml不走 Spring 的 snakeyaml所以一般不会冲突。但如果你的项目里已经有jackson-databind且版本很老比如 2.9.xSolon 的 JSON 处理可能会用到新 API这时候用mvn dependency:tree看一下必要时在 Solon 依赖上做 exclusion。第二Servlet API 版本。SolonMCP 内嵌到 SpringBoot2 时靠的是SolonServletFilter这个桥接 Filter它实现的是javax.servlet.FilterJava 8 时代的命名空间。SpringBoot2 用的正是javax.servlet所以能对上如果你哪天升到 SpringBoot3那边是jakarta.servlet就得换对应的桥接类。这一点在 Java 8 场景下反而是优势不用改包名。排查命令直接跑mvn dependency:tree -Dincludesorg.noear:*看输出的树里solon-*系列有没有被其他依赖的dependencyManagement改版本。如果有在你自己pom.xml的dependencyManagement里显式锁一下 Solon 的版本。2.3 编译参数别忘了 -parametersSolonMCP 的Param注解在运行时需要拿到方法参数名。Java 8 默认编译不保留参数名所以要么在每个参数上写Param(description ...)显式给名字要么在maven-compiler-plugin里加-parameters。推荐后者省事plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration source1.8/source target1.8/target compilerArgs arg-parameters/arg /compilerArgs /configuration /plugin不加这个参数工具调用时参数名会变成arg0、arg1客户端传参就对不上了。这是老项目最容易忽略的一步。3. 可复制的 SolonMCP 服务端配置片段3.1 入口类保持原样你的 SpringBoot 启动类不用动还是那个SpringBootApplicationSpringBootApplication public class HelloApp { public static void main(String[] args) { SpringApplication.run(HelloApp.class, args); } }3.2 定义一个端点标记接口为了让 Spring 能扫描到所有 MCP 端点组件先定义一个空接口放在webapp.mcpserver包下package webapp.mcpserver; public interface IMcpServerEndpoint { }这个接口本身没有任何方法纯粹是给 Spring 的ListIMcpServerEndpoint注入用的类型标记。3.3 配置类托管 Solon 生命周期 注册 Filter这是整个接入的核心。新建webapp.mcpserver.McpServerConfigpackage webapp.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.pojo.MethodPromptProvider; import org.noear.solon.ai.mcp.server.pojo.MethodResourceProvider; import org.noear.solon.ai.mcp.server.pojo.MethodToolProvider; 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 javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.List; Configuration public class McpServerConfig { PostConstruct public void start() { Solon.start(McpServerConfig.class, new String[]{--cfgmcpserver.yml}); } PreDestroy public void stop() { if (Solon.app() ! null) { Solon.stopBlock(false, Solon.cfg().stopDelay()); } } Bean public McpServerConfig init(ListIMcpServerEndpoint serverEndpoints) { for (IMcpServerEndpoint serverEndpoint : serverEndpoints) { 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 FilterRegistrationBeanSolonServletFilter mcpServerFilter() { FilterRegistrationBeanSolonServletFilter filter new FilterRegistrationBean(); filter.setName(SolonFilter); filter.addUrlPatterns(/mcp/*); filter.setFilter(new SolonServletFilter()); return filter; } }几个关键点解释一下。PostConstruct里启动 Solon 容器配置文件指向mcpserver.yml这个文件放在resources下内容后面给。init方法用AnnotationUtils.findAnnotation而不是直接getClass().getAnnotation是因为 Spring 的组件可能被 CGLIB 代理直接取注解会拿到 null这是踩过的坑。mcpServerFilter只拦截/mcp/*路径不会影响你原有的接口。3.4 mcpserver.yml 配置在src/main/resources下新建mcpserver.ymlsolon.app: name: springboot2-mcp-demo group: demo solon.logging.appender: console: level: INFO这个文件主要是给 Solon 容器一个应用名和日志级别内容可以极简。Solon 的配置和 Spring 的application.yml是两套体系互不干扰放在同一个resources目录下也不会冲突。3.5 写一个 MCP 端点新建webapp.mcpserver.tool.McpServerTool实现IMcpServerEndpointpackage webapp.mcpserver.tool; import org.noear.solon.ai.annotation.ToolMapping; import org.noear.solon.ai.annotation.Param; import org.noear.solon.ai.annotation.ResourceMapping; import org.noear.solon.ai.annotation.PromptMapping; import org.noear.solon.ai.chat.message.ChatMessage; import org.noear.solon.ai.mcp.server.annotation.McpServerEndpoint; import org.springframework.stereotype.Component; import webapp.mcpserver.IMcpServerEndpoint; 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 CollectionChatMessage askQuestion(Param(description 主题) String topic) { return Arrays.asList( ChatMessage.ofUser(请解释一下 topic 的概念) ); } }注意Component用的是 Spring 的注解不是 Solon 的同名注解别导错包。McpServerEndpoint里的sseEndpoint就是客户端要连的地址完整路径是http://localhost:8080/mcp/demo1/sse。4. 验证请求与成功结果4.1 启动并确认端点注册直接运行HelloApp的 main 方法。启动日志里会看到 Solon 容器初始化的信息以及 MCP 端点注册的记录。启动完成后先确认 Filter 生效curl -i http://localhost:8080/mcp/demo1/sse如果返回200并且是text/event-stream类型的长连接说明 SSE 端点已经挂上了。如果返回 404检查FilterRegistrationBean的addUrlPatterns是不是/mcp/*以及McpServerEndpoint的sseEndpoint路径有没有写错。4.2 用 Java 客户端做一次工具调用写一个独立的测试类不需要放进 Spring 容器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(); MapString, Object map Collections.singletonMap(location, 杭州); String rst toolProvider.callToolAsText(getWeather, map).getContent(); System.out.println(rst); assert 晴14度.equals(rst); String version toolProvider.readResourceAsText(config://app-version).getContent(); System.out.println(version); } }运行后控制台输出晴14度和v3.2.0说明工具调用和资源读取都通了。这一步验证的是 MCP 协议层和 LLM 无关先把协议跑通再往上叠模型。4.3 把 MCP 客户端当作 LLM 的工具集协议通了之后接一个本地 LLM 做端到端验证。用 Ollama 起一个qwen2.5:1.5b然后import org.noear.solon.ai.chat.ChatModel; import org.noear.solon.ai.chat.ChatResponse; import org.noear.solon.ai.mcp.client.McpClientProvider; public class McpClientTest { private static final String apiUrl http://127.0.0.1:11434/api/chat; private static final String provider ollama; private static final String model qwen2.5:1.5b; public static void main(String[] args) throws Exception { McpClientProvider toolProvider McpClientProvider.builder() .apiUrl(http://localhost:8080/mcp/demo1/sse) .build(); ChatModel chatModel ChatModel.of(apiUrl) .provider(provider) .model(model) .defaultToolsAdd(toolProvider) .build(); ChatResponse resp chatModel.prompt(杭州今天的天气怎么样).call(); System.out.println(resp.getMessage()); } }模型会识别出需要调用getWeather工具SolonMCP 自动完成工具调用并把结果回填给模型最终输出类似「杭州今天晴14 度」的回答。到这里一个 Java 8 老项目上的 MCP 服务端就完整跑通了。5. 本篇常见报错排查5.1 401 Unauthorized 或连接被拒如果客户端连http://localhost:8080/mcp/demo1/sse返回 401先确认你的 SpringBoot 项目里有没有全局的 Security 配置或拦截器。Spring Security 默认会拦截所有路径/mcp/*需要放行http.authorizeRequests() .antMatchers(/mcp/**).permitAll() .anyRequest().authenticated();如果用的是自定义HandlerInterceptor同样要在preHandle里对/mcp/前缀放行。这个报错和 SolonMCP 本身无关是老项目安全配置的副作用。5.2 local proxy failed 或 SSE 连接中断客户端报local proxy failed或者 SSE 连上几秒就断通常是 Filter 的 URL 模式没覆盖到。检查FilterRegistrationBean的addUrlPatterns(/mcp/*)注意是/mcp/*不是/mcp/**Servlet 的 URL 模式里/*已经能匹配子路径。另外确认SolonServletFilter的包名是org.noear.solon.web.servlet.SolonServletFilter引错成别的桥接类会导致请求转发失败。5.3 reading choices 报错或工具调用返回空客户端解析响应时报reading choices相关错误一般是 LLM 返回格式和 Solon 的解析器对不上。先确认 Ollama 的/api/chat接口版本老版本 Ollama 的响应字段和新版有差异。另外检查ChatModel.of(apiUrl)里的apiUrl是不是完整的/api/chat路径少写一段会返回 404 的 HTML解析器自然读不到choices。5.4 OAuth 相关报错如果客户端连的是远程 MCP 服务而不是本地可能会遇到 OAuth 认证失败。SolonMCP 的McpClientProvider支持在 builder 里加 headerMcpClientProvider.builder() .apiUrl(https://your-mcp-server/mcp/sse) .header(Authorization, Bearer token) .build();本地验证阶段用不到 OAuth但如果你要把这个 MCP 服务暴露到内网之外认证头必须带上。注意不要在生产环境用明文 token走环境变量注入。5.5 参数名变成 arg0工具调用时客户端传location但服务端收到arg0就是编译没加-parameters。回到 2.3 节把maven-compiler-plugin的compilerArgs补上重新mvn clean compile。如果不想改编译参数就在每个Param上显式写name属性。6. 老项目接入 MCP 的后续路径跑通最小示例之后下一步通常是把真实的业务方法暴露成 MCP 工具。SolonMCP 的ToolMapping可以直接标在你现有的 Service 方法上只要那个类实现了IMcpServerEndpoint并被 Spring 扫描到。这意味着你不需要重写业务逻辑加个注解、注册一下就行。对于需要长期跑 Agent 任务的场景比如让模型自动调用多个工具完成一个流程建议把 MCP 服务端和客户端分开部署服务端挂在老项目上客户端放在独立的调度服务里。这样老项目的职责不变只是多暴露了一组 MCP 端点。如果你在配置过程中卡在依赖冲突或者 Filter 不生效可以去 TaoToken 的接入文档里对照完整的配置示例或者直接在模型对话里贴报错日志让它帮你定位。需要长期跑编码类 Agent 的话Coding Plan 里有针对 MCP 工具链的套餐适合把这类服务固化下来。API Key 在 console 的 api-keys 页面生成Base URL 用https://taotoken.net/api模型 ID 按你实际用的填这三件套配齐就能从本地验证切到线上调用。