ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

实战MCP:让LLM拥有“超能力”,打造专属工具库(Java版完整基座项目)——TaoToken统一Key接入与config.toml骨架

实战MCP:让LLM拥有“超能力”,打造专属工具库(Java版完整基座项目)——TaoToken统一Key接入与config.toml骨架 1. 为什么要在 Spring Boot 里搭一个 MCP 工具库MCPModel Context Protocol说白了就是给 LLM 装一个外挂工具箱模型本身只会生成文字但通过 MCP 协议它可以主动调用你写好的 Java 方法——查数据库、算指标、调内部接口甚至触发一段业务逻辑。对 Java 团队来说这件事的价值在于你不需要把已有系统推倒重来只要在 Spring Boot 工程里加一层 MCP Server 骨架就能让 LLM 直接复用你现有的 Service、Mapper、工具类。这篇要交付的是一个能跑起来的最小闭环Spring Boot 3.x 工程里搭 MCP Server 骨架工具用注解式注册LLM 侧通过 TaoToken 统一 Key 接入配置文件用config.toml骨架管理再用 CC Switch 做一次通道切换验证。适合谁有 Java 基础、想让自己的系统接上大模型、但不想被各家 API Key 和协议细节拖住的开发者。读完你能拿到三样东西可复制的config.toml、可运行的 MCP 工具注册代码、一次端到端调用验证的完整步骤。我试过把工具注册写成纯手写 JSON Schema维护起来很痛苦后来改成注解 反射扫描新增一个工具只要加个方法省事很多。下面按这个思路来。2. TaoToken 前置统一 Key 与通道准备在写代码之前先把模型侧的通道打通。MCP Server 负责暴露工具但真正决定用哪个模型、走哪条通道的是 LLM 客户端。TaoToken 在这里的角色是统一入口一个 Key 覆盖多家模型省得你在代码里塞一堆OPENAI_API_KEY、ANTHROPIC_API_KEY。你需要做两件事第一拿到 API Key。登录控制台在 API Keys 页面创建一个新 Key复制保存。地址是https://taotoken.net/api-keys注意这个 Key 只显示一次丢了只能重建。第二确认接入地址。TaoToken 的 API 基址是https://taotoken.net/api兼容 OpenAI 风格的/v1/chat/completions也支持 Anthropic 风格通道。MCP 客户端比如 Claude Code、Cline 这类通常要求你填 Base URL 和 API Key把这两个填进去即可。注意API 基址不要带任何查询参数直接写https://taotoken.net/api。带 UTM 的链接只用于官网跳转不要写进代码配置里。如果你用的是 Claude Code 这类支持 Anthropic 协议的客户端接入文档在https://taotoken.net/doc里面有各客户端的字段对照。想先验证模型通不通可以直接在模型对话页发一条消息测试https://taotoken.net/models。3. 可复制配置config.toml 骨架与 CC SwitchMCP 客户端普遍用config.toml或mcp.json描述要启动哪些 MCP Server。下面这份骨架可以直接抄改掉路径和 Key 就能用。# ~/.config/mcp/config.toml # MCP 客户端配置骨架一个 LLM 通道 一个 Java MCP Server [llm] # TaoToken 统一通道 base_url https://taotoken.net/api api_key sk-你的TaoTokenKey # 默认模型按需替换 model claude-sonnet-4-20250514 timeout 60 [mcp_servers.java_tools] # Java MCP Server 以 stdio 方式启动本地进程 command java args [ -jar, /opt/mcp/java-mcp-server-1.0.0.jar, --spring.profiles.activestdio ] # 环境变量透传给 Java 进程 env { TAOTOKEN_BASE_URL https://taotoken.net/api, TAOTOKEN_API_KEY sk-你的TaoTokenKey } [mcp_servers.java_tools.restart] # 进程异常退出时自动重启 enabled true max_retries 3 delay_ms 2000几个关键点解释一下。commandargs是 stdio 模式的启动方式MCP 客户端会把 Java 进程当子进程拉起通过标准输入输出收发 JSON-RPC 消息。env里透传 TaoToken 的地址和 Key这样 Java 侧读环境变量就能拿到不用硬编码。CC Switch 是切换通道的动作。当你想从默认通道切到另一个模型比如从 Claude 切到 GPT 系不需要改 Java 代码只改[llm]段的model和base_url即可。如果 TaoToken 支持多通道切换就是改这两行然后重启 MCP 客户端。实测下来这种配置驱动的切换比在代码里写 if-else 干净得多。提示config.toml里不要提交真实 Key 到 Git。用env引用系统环境变量或者用客户端的密钥管理功能。4. MCP 工具注册代码注解 反射扫描Java 侧的核心是怎么把一个普通方法变成 MCP 工具。手写 JSON Schema 太累我用注解标记方法启动时反射扫描并生成工具定义。先定义注解// src/main/java/com/example/mcp/annotation/McpTool.java package com.example.mcp.annotation; import java.lang.annotation.*; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface McpTool { /** 工具名LLM 调用时使用 */ String name(); /** 工具描述帮助 LLM 判断何时调用 */ String description(); }再定义参数注解用来描述每个入参// src/main/java/com/example/mcp/annotation/McpParam.java package com.example.mcp.annotation; import java.lang.annotation.*; Target(ElementType.PARAMETER) Retention(RetentionPolicy.RUNTIME) Documented public interface McpParam { String name(); String description(); boolean required() default true; String type() default string; // string / integer / number / boolean }写一个工具类把业务方法暴露出去// src/main/java/com/example/mcp/tools/OrderTools.java package com.example.mcp.tools; import com.example.mcp.annotation.McpParam; import com.example.mcp.annotation.McpTool; import org.springframework.stereotype.Component; Component public class OrderTools { McpTool(name query_order_status, description 根据订单号查询订单当前状态返回状态码和描述) public String queryOrderStatus( McpParam(name order_id, description 订单号例如 ORD20240101001) String orderId) { // 这里替换成真实的 Service 调用 if (orderId null || orderId.isBlank()) { return {\error\:\order_id 不能为空\}; } // 模拟查询 return String.format({\order_id\:\%s\,\status\:\PAID\,\desc\:\已支付待发货\}, orderId); } McpTool(name calc_order_amount, description 计算订单总金额输入单价和数量返回总价) public String calcOrderAmount( McpParam(name unit_price, description 单价, type number) double unitPrice, McpParam(name quantity, description 数量, type integer) int quantity) { double total unitPrice * quantity; return String.format({\unit_price\:%s,\quantity\:%d,\total\:%.2f}, unitPrice, quantity, total); } }然后是扫描器启动时把带McpTool的方法注册进 MCP Server// src/main/java/com/example/mcp/core/McpToolScanner.java package com.example.mcp.core; import com.example.mcp.annotation.McpParam; import com.example.mcp.annotation.McpTool; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.stereotype.Component; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.*; Component public class McpToolScanner implements BeanPostProcessor { private final McpServer mcpServer; private final ObjectMapper objectMapper new ObjectMapper(); public McpToolScanner(McpServer mcpServer) { this.mcpServer mcpServer; } Override public Object postProcessAfterInitialization(Object bean, String beanName) { for (Method method : bean.getClass().getDeclaredMethods()) { McpTool tool method.getAnnotation(McpTool.class); if (tool null) continue; // 构建输入参数 schema ObjectNode schema objectMapper.createObjectNode(); schema.put(type, object); ObjectNode props schema.putObject(properties); ListString required new ArrayList(); for (Parameter p : method.getParameters()) { McpParam mp p.getAnnotation(McpParam.class); if (mp null) continue; ObjectNode prop props.putObject(mp.name()); prop.put(type, mp.type()); prop.put(description, mp.description()); if (mp.required()) required.add(mp.name()); } schema.set(required, objectMapper.valueToTree(required)); // 注册到 MCP Server mcpServer.registerTool(tool.name(), tool.description(), schema, args - { try { Object[] params new Object[method.getParameterCount()]; Parameter[] ps method.getParameters(); for (int i 0; i ps.length; i) { McpParam mp ps[i].getAnnotation(McpParam.class); Object raw args.get(mp.name()); params[i] convert(raw, ps[i].getType()); } Object result method.invoke(bean, params); return String.valueOf(result); } catch (Exception e) { return {\error\:\ e.getMessage() \}; } }); } return bean; } private Object convert(Object raw, Class? target) { if (raw null) return null; if (target String.class) return String.valueOf(raw); if (target int.class || target Integer.class) return ((Number) raw).intValue(); if (target double.class || target Double.class) return ((Number) raw).doubleValue(); if (target boolean.class || target Boolean.class) return (Boolean) raw; return raw; } }McpServer是核心容器负责存工具、处理tools/list和tools/call两类请求// src/main/java/com/example/mcp/core/McpServer.java package com.example.mcp.core; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.springframework.stereotype.Component; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; Component public class McpServer { public record ToolDef(String name, String description, JsonNode schema, FunctionMapString, Object, String handler) {} private final MapString, ToolDef tools new ConcurrentHashMap(); private final ObjectMapper mapper new ObjectMapper(); public void registerTool(String name, String description, JsonNode schema, FunctionMapString, Object, String handler) { tools.put(name, new ToolDef(name, description, schema, handler)); } public String handle(String method, JsonNode params, String id) { return switch (method) { case tools/list - listTools(id); case tools/call - callTool(params, id); default - error(id, -32601, method not found: method); }; } private String listTools(String id) { try { ObjectNode result mapper.createObjectNode(); ArrayNode arr result.putArray(tools); for (ToolDef t : tools.values()) { ObjectNode node arr.addObject(); node.put(name, t.name()); node.put(description, t.description()); node.set(inputSchema, t.schema()); } ObjectNode resp mapper.createObjectNode(); resp.put(jsonrpc, 2.0); resp.put(id, id); resp.set(result, result); return mapper.writeValueAsString(resp); } catch (Exception e) { return error(id, -32000, e.getMessage()); } } SuppressWarnings(unchecked) private String callTool(JsonNode params, String id) { String name params.path(name).asText(); ToolDef tool tools.get(name); if (tool null) return error(id, -32601, tool not found: name); try { MapString, Object args mapper.convertValue(params.path(arguments), Map.class); String output tool.handler().apply(args); ObjectNode content mapper.createObjectNode(); content.put(type, text); content.put(text, output); ObjectNode result mapper.createObjectNode(); result.putArray(content).add(content); ObjectNode resp mapper.createObjectNode(); resp.put(jsonrpc, 2.0); resp.put(id, id); resp.set(result, result); return mapper.writeValueAsString(resp); } catch (Exception e) { return error(id, -32000, e.getMessage()); } } private String error(String id, int code, String msg) { try { ObjectNode err mapper.createObjectNode(); err.put(code, code); err.put(message, msg); ObjectNode resp mapper.createObjectNode(); resp.put(jsonrpc, 2.0); resp.put(id, id); resp.set(error, err); return mapper.writeValueAsString(resp); } catch (Exception e) { return {\jsonrpc\:\2.0\,\id\:\ id \,\error\:{\code\:-32000,\message\:\fatal\}}; } } }最后是 stdio 入口读标准输入、写标准输出// src/main/java/com/example/mcp/StdioRunner.java package com.example.mcp; import com.example.mcp.core.McpServer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.PrintWriter; Component Profile(stdio) public class StdioRunner implements CommandLineRunner { private final McpServer server; private final ObjectMapper mapper new ObjectMapper(); public StdioRunner(McpServer server) { this.server server; } Override public void run(String... args) throws Exception { BufferedReader reader new BufferedReader(new InputStreamReader(System.in)); PrintWriter writer new PrintWriter(System.out, true); String line; while ((line reader.readLine()) ! null) { if (line.isBlank()) continue; try { JsonNode req mapper.readTree(line); String id req.path(id).asText(); String method req.path(method).asText(); JsonNode params req.path(params); String resp server.handle(method, params, id); writer.println(resp); } catch (Exception e) { writer.println({\jsonrpc\:\2.0\,\error\:{\code\:-32700,\message\:\parse error\}}); } } } }5. 验证请求一次端到端调用代码写完先本地验证 MCP Server 本身。用mvn clean package打包然后手动喂一条tools/list请求echo {jsonrpc:2.0,id:1,method:tools/list,params:{}} \ | java -jar target/java-mcp-server-1.0.0.jar --spring.profiles.activestdio预期输出是一行 JSONresult.tools数组里能看到query_order_status和calc_order_amount两个工具每个都带inputSchema。如果输出为空检查Profile(stdio)是否生效、StdioRunner是否被扫描到。再验证工具调用echo {jsonrpc:2.0,id:2,method:tools/call,params:{name:calc_order_amount,arguments:{unit_price:19.9,quantity:3}}} \ | java -jar target/java-mcp-server-1.0.0.jar --spring.profiles.activestdio预期返回content[0].text为{unit_price:19.9,quantity:3,total:59.70}。到这里MCP Server 侧闭环就通了。接下来把 LLM 接进来。在 MCP 客户端里加载第 3 节的config.toml重启客户端。客户端启动时会拉起 Java 进程发tools/list拿到工具清单。然后你在对话里说帮我算一下单价 19.9 买 3 件的总价模型会自己决定调用calc_order_amount把参数填好发过来Java 侧执行后把结果回传模型再组织成自然语言回答。验证成功的标志客户端日志里能看到tools/call的请求和响应对话里模型给出的总价是 59.70。如果模型没调用工具通常是description写得太模糊把计算订单总金额改成更具体的描述比如输入单价和数量返回两者乘积作为订单总价。6. 本篇常见错排查报错一No such method tools/list或客户端一直转圈。多半是 stdio 模式下 Java 进程把 Spring Boot 的启动日志打到了 stdout污染了 JSON-RPC 通道。解决在application-stdio.properties里把日志级别调到ERROR或者把日志重定向到文件确保 stdout 只有 JSON。报错二tool not found: xxx。检查McpTool(name...)里的名字和客户端请求的名字是否完全一致大小写敏感。另外确认McpToolScanner是BeanPostProcessor且被 Spring 扫描到工具类上有Component。报错三参数类型转换异常ClassCastException。LLM 传过来的数字可能是Integer也可能是Doubleconvert方法里统一用((Number) raw).intValue()处理别直接强转。布尔值同理先判断类型再转。报错四TaoToken 返回 401。Key 没填对或者base_url写成了带路径的形式。正确写法是https://taotoken.net/api不要加/v1后缀客户端会自己拼。Key 从https://taotoken.net/api-keys重新复制一份注意前后不要有空格。报错五CC Switch 切换后模型不生效。改完config.toml必须重启 MCP 客户端配置不是热加载的。另外确认切换后的模型名在 TaoToken 通道里是支持的不确定就先在模型对话页测一条。7. 下一步把工具库扩起来最小闭环跑通后扩展就简单了。新增一个工具只要在任意Component类里加一个带McpTool的方法重启即生效。想把数据库查询暴露出去就在方法里注入JdbcTemplate或 MyBatis 的 Mapper想调内部 HTTP 接口注入RestTemplate或WebClient即可。长期做编码和 Agent 场景的话建议把通道配置和工具配置分开管理通道走 TaoToken 的 Coding Plan工具走本地config.toml这样换模型不影响工具注册。接入文档在https://taotoken.net/doc里面有 stdio 和 HTTP 两种模式的字段说明遇到协议细节可以对照查。最后留一个实用技巧给每个工具的description加上什么时候用的提示比如当用户询问订单状态时调用模型选工具的准确率会明显提升。这比调参管用。
返回列表