资讯详情

从零搭建 Spring Boot MCP 服务:MyBatis + SSE 完整教程与 TaoToken 接入

📅 2026/10/12 21:53:58 | 华诺云谱 👁 阅读
从零搭建 Spring Boot MCP 服务:MyBatis + SSE 完整教程与 TaoToken 接入
1. 为什么要在 Spring Boot 里手搓一个 MCP 服务MCPModel 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/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_KEYsk-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 { ListMapString, 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(ListMapString, Object messages, SseEmitter emitter) throws Exception { while (true) { MapString, 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); MapString, 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 工具执行反射调 Mapperprivate 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 里的查询条件转成EntityWrapperprivate Object[] buildArgs(Method method, JsonNode params) { Object[] args new Object[method.getParameterCount()]; for (int i 0; i args.length; i) { EntityWrapperObject 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 节对号入座。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑