1. 为什么要在 Java 里自己写一个 MCP 服务端
如果你正在做企业内部的 AI 助手,大概率会遇到一个尴尬场景:模型很聪明,但它看不到你数据库里的真实数据。你问它"上个月华东区退货率是多少",它只能编一个看起来合理的数字。要解决这个问题,就得让模型能主动调用你的数据库查询能力,而 MCP(Model Context Protocol)就是目前最顺手的方案。
MCP 本质上是一套"模型和外部工具之间的约定"。你可以把它理解成 USB 接口:模型是电脑,你的数据库查询服务是 U 盘,只要双方都遵守 MCP 协议,插上就能用,不需要为每个模型单独写适配层。服务端负责把"查表列表""查表结构""执行 SQL"这些能力暴露成工具,客户端(比如 Cherry Studio、Claude 桌面端、各类支持 MCP 的 IDE)发现这些工具后,模型就能在对话中自主决定调用哪个。
这篇面向的是需要让 AI 工具安全访问业务库的 Java 开发者。我会用一个可运行的 Spring Boot 项目,把 MCP 服务端的骨架搭起来,接入 MySQL,暴露三个工具,最后用客户端跑一次"从提问到返回分析结果"的完整链路。源码结构参考了开源项目 DataExploration-MCP 的思路,但配置和排障部分我会写得更细,方便你直接抄。
适合谁:有 Java/Spring Boot 基础,想让大模型安全查询业务库,又不想把数据库账号密码直接塞给模型平台的开发者。读完你能拿到一份可复制的settings.json和一套能跑通的工具定义。
2. 前置准备:依赖、TaoToken 与项目骨架
2.1 Maven 依赖怎么选
MCP 官方提供了 Java SDK,核心包是io.modelcontextprotocol.sdk:mcp。传输层有两种选择:WebMVC 的 SSE 和 WebFlux 的 SSE。如果你项目本来就是 Spring Boot WebMVC,直接用mcp-spring-webmvc,别为了这个引入 WebFlux,否则线程模型会对不上。
<dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> <version>0.10.0</version> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-spring-webmvc</artifactId> <version>0.10.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.4.0</version> </dependency>版本号建议去 Maven Central 确认一下最新的,SDK 迭代比较快,0.10.x 和 0.9.x 在 API 上有细微差别,尤其是McpServer.sync()的构建方式。
2.2 模型侧接入用 TaoToken
服务端跑起来只是第一步,你还需要一个能连 MCP 客户端的模型入口来验证。我这边习惯用 TaoToken 做模型调用,它的 API 地址是https://taotoken.net/api,兼容 OpenAI 风格的请求格式,接进 Cherry Studio 或者自己写的测试客户端都很顺。如果你还没配 Key,先去控制台建一个:
- 模型对话入口:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
注意:MCP 服务端本身不负责模型推理,它只暴露工具。模型调用走的是 TaoToken 这类 API,两者是配合关系,别搞混。
2.3 项目目录结构
src/main/java/com/example/mcp/ ├── McpApplication.java ├── config/McpConfig.java ├── server/McpServerManager.java ├── tools/DatabaseTools.java └── util/JdbcExecutor.javaMcpConfig负责注册传输层 Bean,McpServerManager在启动时创建同步服务器并挂载工具,DatabaseTools放三个工具的定义,JdbcExecutor封装 JDBC 查询和结果格式化。
3. 可复制配置:传输层、服务端与三个工具
3.1 传输层配置(WebMVC SSE)
@Configuration public class McpConfig { @Bean public WebMvcSseServerTransportProvider transportProvider(ObjectMapper mapper) { return new WebMvcSseServerTransportProvider(mapper, "/messages"); } @Bean public RouterFunction<ServerResponse> mcpRouterFunction( WebMvcSseServerTransportProvider provider) { return provider.getRouterFunction(); } }这里的/messages是客户端发消息的 URI,不是建立 SSE 连接的 URI。SSE 连接默认走/sse。这两个 URI 在客户端配置里要分清,后面排障会重点讲。
3.2 创建同步 MCP 服务器
@Component public class McpServerManager { private static final Logger log = LoggerFactory.getLogger(McpServerManager.class); @Bean public McpSyncServer mcpSyncServer(WebMvcSseServerTransportProvider provider, DatabaseTools tools) { McpSyncServer server = McpServer.sync(provider) .serverInfo("db-query-server", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder() .tools(true) .logging() .build()) .build(); try { server.addTool(tools.listTables()); server.addTool(tools.getTableSchema()); server.addTool(tools.executeQuery()); server.loggingNotification(McpSchema.LoggingMessageNotification.builder() .level(McpSchema.LoggingLevel.INFO) .logger("db-query") .data("MCP server initialized") .build()); } catch (Exception e) { log.error("MCP 服务器初始化失败: {}", e.getMessage(), e); } return server; } }同步模式意味着客户端发请求后会阻塞等待结果。数据库查询通常几百毫秒到几秒,同步完全够用,编程模型也简单。异步模式适合长任务,这里没必要。
3.3 三个工具的定义
工具的核心是Tool对象加一个执行 lambda。Tool的 schema 用 JSON 描述参数,模型会根据这个 schema 决定传什么。
@Component public class DatabaseTools { private final JdbcExecutor executor; public DatabaseTools(JdbcExecutor executor) { this.executor = executor; } public McpServerFeatures.SyncToolSpecification listTables() { String desc = "获取当前数据库中所有表的列表。"; String schema = """ {"type":"object","id":"urn:jsonschema:Operation","properties":{}} """; return new McpServerFeatures.SyncToolSpecification( new McpSchema.Tool("listTables", desc, schema), (exchange, args) -> { List<McpSchema.Content> result = new ArrayList<>(); try { result.add(new McpSchema.TextContent( "所有表: " + executor.query("SHOW TABLES;"))); } catch (Exception e) { result.add(new McpSchema.TextContent("查询失败: " + e.getMessage())); } return new McpSchema.CallToolResult(result, false); }); } public McpServerFeatures.SyncToolSpecification getTableSchema() { String desc = "获取指定数据表的结构(列信息)。参数 tableName 为表名。"; String schema = """ {"type":"object","id":"urn:jsonschema:Operation", "properties":{"tableName":{"type":"string"}}} """; return new McpServerFeatures.SyncToolSpecification( new McpSchema.Tool("getTableSchema", desc, schema), (exchange, args) -> { List<McpSchema.Content> result = new ArrayList<>(); String tableName = (String) args.get("tableName"); try { result.add(new McpSchema.TextContent( "表结构: " + executor.query("DESCRIBE " + tableName + ";"))); } catch (Exception e) { result.add(new McpSchema.TextContent("查询失败: " + e.getMessage())); } return new McpSchema.CallToolResult(result, false); }); } public McpServerFeatures.SyncToolSpecification executeQuery() { String desc = "执行给定的 SQL 查询语句并返回结果。参数 sqlQuery 为 SQL 语句。"; String schema = """ {"type":"object","id":"urn:jsonschema:Operation", "properties":{"sqlQuery":{"type":"string"}}} """; return new McpServerFeatures.SyncToolSpecification( new McpSchema.Tool("execute_query_sql", desc, schema), (exchange, args) -> { List<McpSchema.Content> result = new ArrayList<>(); String sql = (String) args.get("sqlQuery"); try { result.add(new McpSchema.TextContent( "查询结果: " + executor.query(sql))); } catch (Exception e) { result.add(new McpSchema.TextContent("查询失败: " + e.getMessage())); } return new McpSchema.CallToolResult(result, false); }); } }3.4 JDBC 执行与结果格式化
模型看不懂ResultSet,得转成文本表格。这里用StringBuilder拼一个简单的对齐表格,列宽按内容动态算。
@Component public class JdbcExecutor { private final DataSource dataSource; public JdbcExecutor(DataSource dataSource) { this.dataSource = dataSource; } public String query(String sql) throws SQLException { try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { ResultSetMetaData meta = rs.getMetaData(); int cols = meta.getColumnCount(); StringBuilder sb = new StringBuilder(); for (int i = 1; i <= cols; i++) { sb.append(meta.getColumnLabel(i)).append("\t"); } sb.append("\n"); int rows = 0; while (rs.next() && rows < 200) { for (int i = 1; i <= cols; i++) { sb.append(rs.getString(i)).append("\t"); } sb.append("\n"); rows++; } if (rows == 200) { sb.append("... 结果已截断,仅显示前 200 行\n"); } return sb.toString(); } } }application.yml里配好数据源:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/business_db?useSSL=false&serverTimezone=Asia/Shanghai username: readonly_user password: your_password driver-class-name: com.mysql.cj.jdbc.Driver server: port: 8080注意:生产环境务必用只读账号,并且只授予 SELECT 权限。MCP 工具暴露给模型后,模型理论上能执行任何 SQL,权限收窄是第一道防线。
4. 验证请求:从提问到返回分析结果
4.1 客户端 settings.json 配置
以 Cherry Studio 为例,在 MCP 服务器配置里填:
{ "mcpServers": { "db-query-server": { "type": "sse", "url": "http://127.0.0.1:8080/sse" } } }这里填的是/sse,不是/messages。这是最容易踩的坑,后面单独讲。
4.2 启动服务端
mvn clean package -DskipTests java -jar target/mcp-db-server-1.0.0.jar启动日志里看到MCP server initialized就说明工具挂载成功。如果报Address already in use,改server.port。
4.3 第一次验证:查表列表
在客户端里问:"数据库里一共有多少张表?"
模型会调用listTables,服务端执行SHOW TABLES;,返回类似:
Tables_in_business_db orders order_items customers products模型拿到结果后回答:"当前数据库共有 4 张表,分别是 orders、order_items、customers、products。"
4.4 第二次验证:多工具串联分析
问:"2024 年上半年各月的订单总金额是多少?"
模型会依次调用:
listTables→ 发现orders表getTableSchema传tableName=orders→ 拿到列信息,发现order_date和amount字段execute_query_sql传生成的 SQL:
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(amount) AS total FROM orders WHERE order_date BETWEEN '2024-01-01' AND '2024-06-30' GROUP BY month ORDER BY month;服务端返回:
month total 2024-01 128340.50 2024-02 98320.00 2024-03 156780.25 2024-04 142100.00 2024-05 167890.75 2024-06 189450.30模型据此给出分析:"上半年订单金额呈上升趋势,6 月达到峰值 18.9 万,2 月因春节因素最低。"
这条链路跑通,说明 MCP 服务端的工具发现、参数传递、结果回传全部正常。
5. 本篇常见错误排查
5.1 客户端连不上,报 SSE 404
最常见的原因是 URL 填错。/messages是客户端发消息的端点,/sse才是建立连接的端点。客户端配置里必须填/sse。如果你在McpConfig里改了/messages这个路径,客户端那边不用跟着改,因为客户端只关心/sse。
5.2 工具列表为空
检查server.addTool()是否在build()之后调用。SDK 要求先构建服务器再挂载工具,顺序反了不会报错,但工具不会注册。另外确认capabilities里.tools(true)开了。
5.3 模型不调用工具,直接编答案
两个原因:一是工具描述写得太模糊,模型不知道什么时候用。把desc写清楚,比如"获取当前数据库中所有表的列表"比"查表"好得多。二是客户端没把工具列表传给模型。在 Cherry Studio 里确认 MCP 服务器状态是绿色,并且对话时勾选了工具。
5.4 SQL 执行报权限错误
只读账号执行SHOW TABLES和DESCRIBE需要SELECT权限,information_schema的访问也要放开。如果报Access denied,检查账号授权:
GRANT SELECT ON business_db.* TO 'readonly_user'@'%'; FLUSH PRIVILEGES;5.5 结果太长导致模型截断
JdbcExecutor里我加了 200 行截断。如果你的表字段特别多,单行就很长,可以再加列数限制,或者让模型先COUNT(*)再决定要不要拉明细。
5.6 中文乱码
MySQL 连接串加characterEncoding=utf8,服务端返回的TextContent默认 UTF-8,一般不会乱。如果客户端显示乱码,检查客户端的编码设置。
6. 把查询能力稳定接进 AI 工具
服务端跑通只是起点,真正上线要考虑的是稳定性。我自己的做法是给JdbcExecutor加一层超时控制,用stmt.setQueryTimeout(10),避免模型生成一个全表扫描的 SQL 把连接池占满。另外把工具调用日志单独打到一张审计表里,记录谁在什么时候查了什么,方便回溯。
如果你打算长期跑编码类或 Agent 类任务,模型调用量会比较大,可以考虑用 Coding Plan 来管理额度:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite
需要看完整可运行源码的话,参考这个结构自己搭一遍最快:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 建好 Key,把application.yml里的数据源换成你自己的库,启动后先用curl http://127.0.0.1:8080/sse确认 SSE 端点有响应,再进客户端配。这一步过了,后面基本不会卡。