1. 为什么要在 Spring Boot 里手搓一个 MCP 服务
MCP(Model Context Protocol)说白了就是给大模型装了一双能伸进你业务系统的手。以前你想让模型查一下数据库里的订单,得自己写 Function Calling 的 JSON Schema、自己解析参数、自己拼 SQL,模型每次换个问法你就得改一遍代码。MCP 把这套东西标准化了:你只要把「工具」按协议暴露出去,模型就能自己决定什么时候调、调哪个、传什么参数。
我这次要做的场景很具体:一个 Spring Boot 服务,底层用 MyBatis 连 MySQL,对外通过 SSE 把模型的流式回答推给前端,中间让模型能直接调用 Mapper 方法查数据。适合谁看?如果你手上有现成的 MyBatis 项目,想让 AI 用自然语言查库,又不想把数据库账号密码直接塞给模型,这套结构就是为你准备的。
核心检索词先摆出来:Spring Boot MCP 服务端搭建、MyBatis 动态方法调用、SSE 流式响应、Model Context Protocol 工具注册。这四个词贯穿全文,你照着做就能跑通。
整体链路是这样的:前端发一个问题 → Spring Boot 用 SSE 建长连接 → 服务端把问题连同「可用工具列表」发给模型 → 模型返回一个 tool_call,说「我要调 HuiyuanDao.selectListView,参数是 sex=男」→ 服务端用反射执行这个 Mapper 方法 → 把结果塞回对话 → 模型把结果整理成人话 → 通过 SSE 一段段推回前端。
这里有个容易踩的坑:很多人以为 MCP 必须用官方 SDK,其实协议本身很轻,核心就是「工具描述 + 调用请求 + 结果回填」三件事。你用 Spring Boot 的 SseEmitter 加上反射,完全能自己实现一个符合协议精神的服务端。下面我从依赖开始,一步步给你可复制的代码。
2. TaoToken 前置准备:统一 Key 与 API 通道
在写业务代码之前,得先把模型通道搞定。我这里不直接对接某一家厂商的 SDK,而是走 TaoToken 的统一 API 通道。原因很简单:MCP 服务里模型调用是高频动作,如果每家模型都写一套 SDK 适配,代码会烂掉。TaoToken 提供 OpenAI 兼容的接口格式,Base URL 固定,Key 统一管理,换模型只改一个 model 字段。
你需要准备三样东西,我称之为「三件套」:
第一是 Base URL。所有请求都发到https://taotoken.net/api,注意这个地址后面不加任何路径后缀,具体的端点由 SDK 或 HTTP 客户端拼接。第二是 API Key,去控制台生成,格式通常是一串以sk-开头的字符串。第三是 Model ID,比如claude-sonnet-4-5、gpt-4o这类,填你实际要用的模型标识。
获取 Key 的入口在控制台的 API Keys 页面,生成后立刻复制,页面刷新就看不到了。如果你还没账号,先从官网进:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台里找 API Keys。
这里要提醒一句:Key 千万不要硬编码进代码提交到 Git。我习惯用环境变量注入,Spring Boot 里通过${TAOTOKEN_API_KEY}读取。本地开发就在 IDE 的运行配置里加环境变量,线上就配到服务器的环境变量或配置中心。
关于模型选择,如果你这个 MCP 服务主要是做工具调用(Function Calling),建议选工具调用能力强的模型。我实测下来,Claude 系列和 GPT 系列在解析复杂参数时都比较稳。Model ID 具体填什么,以你控制台里看到的为准,不要照抄我文章里的示例。
配置通道的时候,我建议先用最简的方式验证一下 Key 能不能用。你可以打开模型对话页面,随便发一句话,能正常返回就说明 Key 和通道没问题。这个页面地址是 https://taotoken.net/api ,进去后选个模型聊两句即可。等确认通道通了,再回来写 Spring Boot 代码,能省掉很多「到底是 Key 错了还是代码错了」的排查时间。
另外,如果你的 MCP 服务要长期跑、调用量比较大,可以考虑 Coding Plan 这类套餐,比按量计费划算。入口在 https://taotoken.net/api ,具体选哪个档位看你的调用频率。我个人的经验是,开发调试阶段用按量就够,上线后再根据实际 QPS 决定。
3. 可复制配置:pom.xml、application.yml 与 MCP 工具注册
这一节是全文最干的部分,你直接复制粘贴就能用。我按「依赖 → 配置 → 工具注册」的顺序来。
3.1 Maven 依赖
打开你的pom.xml,在<dependencies>里加上这些。注意 Spring Boot 版本我用的是 2.7.x,如果你用 3.x,把javax.annotation换成jakarta.annotation即可。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>这里我特意没有引入任何厂商的 AI SDK,而是用 OkHttp 直接发 HTTP 请求。为什么?因为 TaoToken 是 OpenAI 兼容格式,用原生 HTTP 客户端反而更透明,出问题你能看到完整的请求体和响应体,排查起来比黑盒 SDK 快得多。
3.2 application.yml 配置
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/mcp_demo?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_db_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/*.xml type-aliases-package: com.example.mcp.entity configuration: map-underscore-to-camel-case: true taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model: claude-sonnet-4-5 timeout-seconds: 120注意api-key这里用的是环境变量占位符,你本地跑的时候在 IDE 里配TAOTOKEN_API_KEY=sk-xxxx。model字段填你控制台里实际可用的 Model ID。
3.3 MCP 工具注册的 JSON 描述
MCP 的核心是「工具描述」。模型需要知道有哪些工具可用、每个工具接受什么参数。我用一个 JSON 结构来定义,放在resources/mcp/tools.json:
{ "tools": [ { "name": "query_with_mybatis", "description": "执行 MyBatis Mapper 方法查询数据库。优先使用返回 List 的方法。", "input_schema": { "type": "object", "properties": { "mapper": { "type": "string", "description": "Mapper 接口的简单类名,例如 HuiyuanDao" }, "method": { "type": "string", "description": "要调用的方法名,例如 selectListView" }, "params": { "type": "object", "description": "查询条件键值对,例如 {\"sex\": \"男\"}" } }, "required": ["mapper", "method"] } } ] }这个 JSON 会在每次请求模型时,作为tools字段塞进请求体。模型看到这个描述后,就知道自己可以调query_with_mybatis,并且知道要传mapper、method、params三个参数。
3.4 工具执行器的核心配置类
@Configuration @ConfigurationProperties(prefix = "taotoken") @Data public class TaoTokenConfig { private String baseUrl; private String apiKey; private String model; private int timeoutSeconds; }这个类把 yml 里的配置绑定成 Java 对象,后面发请求时直接用。到这里,配置层就齐了。下一节我们写真正的请求逻辑和 SSE 推送。
4. 验证请求:SSE 端点、工具调用与成功结果
配置写完了,现在写代码验证。我按「Controller → Service → 工具执行 → SSE 推送」的顺序来,每一段你都能直接跑。
4.1 SSE 端点
@RestController @RequestMapping("/api/mcp") @RequiredArgsConstructor public class McpController { private final McpService mcpService; @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(@RequestParam String question) { return mcpService.processQuestion(question); } }这个端点返回SseEmitter,前端用EventSource连上来就能收到流式数据。注意produces必须是text/event-stream,否则浏览器不会按 SSE 解析。
4.2 Service 核心逻辑
@Service @RequiredArgsConstructor public class McpService { private final TaoTokenConfig config; private final SqlSessionFactory sqlSessionFactory; private final OkHttpClient httpClient = new OkHttpClient.Builder() .readTimeout(120, TimeUnit.SECONDS) .build(); private final ObjectMapper objectMapper = new ObjectMapper(); public SseEmitter processQuestion(String question) { SseEmitter emitter = new SseEmitter(600_000L); CompletableFuture.runAsync(() -> { try { List<Map<String, Object>> messages = new ArrayList<>(); messages.add(systemMessage()); messages.add(userMessage(question)); loop(messages, emitter); } catch (Exception e) { send(emitter, "处理失败: " + e.getMessage()); } finally { emitter.complete(); } }); return emitter; } }loop方法是核心,它负责「发请求 → 检查 tool_call → 执行工具 → 回填结果 → 再发请求」这个循环:
private void loop(List<Map<String, Object>> messages, SseEmitter emitter) throws Exception { while (true) { Map<String, Object> body = new HashMap<>(); body.put("model", config.getModel()); body.put("messages", messages); body.put("tools", loadTools()); body.put("stream", false); String resp = post(config.getBaseUrl() + "/v1/chat/completions", body); JsonNode root = objectMapper.readTree(resp); JsonNode choice = root.path("choices").get(0).path("message"); messages.add(objectMapper.convertValue(choice, Map.class)); JsonNode toolCalls = choice.path("tool_calls"); if (toolCalls.isMissingNode() || toolCalls.isEmpty()) { send(emitter, choice.path("content").asText()); return; } send(emitter, "正在查询数据库,请稍候..."); for (JsonNode call : toolCalls) { String args = call.path("function").path("arguments").asText(); String result = executeMyBatis(args); Map<String, Object> toolMsg = new HashMap<>(); toolMsg.put("role", "tool"); toolMsg.put("tool_call_id", call.path("id").asText()); toolMsg.put("content", result); messages.add(toolMsg); } } }4.3 工具执行:反射调 Mapper
private String executeMyBatis(String argsJson) { try { JsonNode args = objectMapper.readTree(argsJson); String mapperName = args.path("mapper").asText(); String methodName = args.path("method").asText(); JsonNode params = args.path("params"); try (SqlSession session = sqlSessionFactory.openSession()) { Class<?> mapperClass = null; for (Class<?> c : sqlSessionFactory.getConfiguration() .getMapperRegistry().getMappers()) { if (c.getSimpleName().equals(mapperName)) { mapperClass = c; break; } } if (mapperClass == null) { return "{\"error\":\"Mapper not found: " + mapperName + "\"}"; } Method target = null; for (Method m : mapperClass.getDeclaredMethods()) { if (m.getName().equals(methodName)) { target = m; break; } } if (target == null) { return "{\"error\":\"Method not found: " + methodName + "\"}"; } Object mapper = session.getMapper(mapperClass); Object[] methodArgs = buildArgs(target, params); Object result = target.invoke(mapper, methodArgs); return objectMapper.writeValueAsString(result); } } catch (Exception e) { return "{\"error\":\"" + e.getMessage() + "\"}"; } }buildArgs负责把 JSON 里的查询条件转成EntityWrapper:
private Object[] buildArgs(Method method, JsonNode params) { Object[] args = new Object[method.getParameterCount()]; for (int i = 0; i < args.length; i++) { EntityWrapper<Object> ew = new EntityWrapper<>(); if (params != null && params.isObject()) { params.fields().forEachRemaining(e -> ew.eq(e.getKey(), e.getValue().asText())); } args[i] = ew; } return args; }4.4 验证成功结果
启动服务后,用 curl 测一下:
curl -N "http://localhost:8080/api/mcp/chat?question=查询所有男性会员"你会看到类似这样的流式输出:
data: 正在查询数据库,请稍候... data: 根据查询结果,共有 3 位男性会员,分别是:张三、李四、王五。如果模型返回的是tool_calls,说明工具注册成功了;如果直接返回文字,说明模型没识别出需要调工具,检查一下tools.json里的描述是否清晰。我实测下来,描述里写清楚「优先使用返回 List 的方法」能显著提高工具调用命中率。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
这一节我按真实报错来,你遇到哪个直接对号入座。
5.1 401 Unauthorized
这是最常见的。报错长这样:
{"error":{"message":"Invalid API key","type":"invalid_request_error"}}原因就三个:Key 没配、Key 配错、Key 过期。排查步骤:先确认环境变量TAOTOKEN_API_KEY有没有生效,在代码里打印一下config.getApiKey()的前 8 位(别打全,安全)。如果打印出来是null或者${TAOTOKEN_API_KEY}原样,说明环境变量没注入。Spring Boot 读取环境变量是在启动时,你改了 IDE 配置要重启。
还有一种情况是 Key 复制的时候带了空格或换行。我踩过的坑就是从网页复制时末尾多了个换行符,肉眼看不出来。建议用trim()处理一下。
5.2 local proxy failed
这个报错通常出现在你本地网络环境有代理设置的时候。完整报错类似:
java.net.ConnectException: Failed to connect to taotoken.net/... Caused by: local proxy failed这说明你的 HTTP 客户端走了系统代理,但代理不可用。解决办法是在 OkHttpClient 里显式禁用代理:
OkHttpClient client = new OkHttpClient.Builder() .proxy(Proxy.NO_PROXY) .build();或者检查你的 JVM 启动参数里有没有-Dhttp.proxyHost之类的设置,有就删掉。企业内网环境有时候会强制走代理,这种情况你需要确认代理是否允许访问taotoken.net。
5.3 reading choices 空指针
报错长这样:
java.lang.NullPointerException: Cannot invoke "JsonNode.get(int)" because "choices" is null这个错误的根源是响应体里没有choices字段。为什么没有?大概率是请求体格式不对,服务端返回了一个错误 JSON,但你的代码直接去读choices了。正确的做法是先判断:
JsonNode choices = root.path("choices"); if (choices.isMissingNode() || choices.isEmpty()) { String errMsg = root.path("error").path("message").asText("unknown error"); throw new RuntimeException("模型返回错误: " + errMsg); }我建议你在post方法里把原始响应体打日志,出问题一眼就能看到服务端到底返回了什么。
5.4 OAuth 相关报错
如果你看到OAuth token expired或invalid_grant,说明你用的不是 API Key 而是 OAuth 流程。TaoToken 的 API 通道用的是 Key 认证,不需要 OAuth。检查一下你的请求头是不是Authorization: Bearer sk-xxx,而不是Authorization: Bearer <oauth_token>。
5.5 工具调用不触发
模型返回了文字但没调工具,检查三件事:tools字段有没有正确放进请求体;tools.json里的input_schema是否符合 JSON Schema 规范;模型本身是否支持 Function Calling。有些轻量模型不支持工具调用,换一个能力强的 Model ID 试试。
5.6 SSE 连接断开
前端报EventSource failed,后端日志显示IOException: Broken pipe。这通常是客户端主动断开(比如用户关了页面),属于正常现象。但如果是服务端处理太慢导致超时,就要检查SseEmitter的超时时间设置。我设的是 600 秒,一般够用。如果模型响应特别慢,可以适当调大。
6. 语义一致 CTA:把通道和工具串起来
代码跑通之后,你会发现真正决定体验的是「模型通道稳不稳」和「工具描述准不准」。通道这块,TaoToken 的 API 入口是 https://taotoken.net/api ,Key 在控制台的 API Keys 页面生成,接入文档在 https://taotoken.net/api 里有完整的请求示例。我建议你先把文档里的 curl 示例跑一遍,确认通道通了再回来调 Spring Boot 代码。
如果你主要是做编码类 Agent,比如让模型帮你写 Mapper、生成 SQL,那 Coding Plan 会更合适,入口在 https://taotoken.net/api 。我自己的用法是:开发调试阶段用模型对话页面快速验证 prompt 和工具描述,稳定后再落到代码里。
最后给你一个实用技巧:把tools.json里的工具描述当成 prompt 来写。描述越具体,模型选错方法的概率越低。比如我写「优先使用返回 List 的方法,避免 selectOne」,模型在遇到可能返回多条记录的场景时,就会主动选selectListView。这个细节比调参管用得多。
现在你可以启动服务,用 curl 发一个「查询姓名包含张的会员」,看看 SSE 是不是一段段推回来了。如果卡在某一步,回到第 5 节对号入座。