1. 为什么要在原生 Java 里手搓一个 MCP Server如果你正在做后端服务最近大概率会碰到一个需求让大模型能调用你系统里的能力比如查订单、算价格、读配置。MCPModel Context Protocol就是干这个的它把「模型能调用的工具」标准化成一套协议客户端负责发现工具、传参、拿结果服务端负责注册工具、执行逻辑、返回数据。问题在于很多教程一上来就让你用 Spring AI或者直接上官方 Java SDK 的 Spring 传输模块。框架确实省事但如果你只是想在一个已有的 Java 服务里暴露两三个工具引入一整套 WebFlux 或者 Spring 上下文启动慢、依赖重、排查问题还得先理解框架的自动装配反而更累。我这次的目标很明确不依赖 Spring AI不依赖任何 Web 框架用原生 Java 加官方 SDK 的 STDIO 传输从零搭一个能跑、能被 Inspector 调通的 MCP Server同时把模型调用通道统一走 TaoToken 的 Key这样本地调试和后续接入都不用来回换配置。这篇适合谁有 Java 基础、能看懂 Maven 依赖、想在服务里暴露 MCP 工具的后端同学。你不需要懂 MCP 协议细节我会把配置骨架、启动类、工具注册、打包、验证一条龙写清楚你照着复制就能跑起来。核心检索词就三个Java、MCP Server、原生实现。下面所有代码都围绕这三个词展开不绕弯子。先说清楚整体链路你的 Java 程序通过 STDIO 和 MCP 客户端通信客户端把工具列表和调用请求发过来你的程序执行完把结果写回标准输出。模型侧如果要调用外部能力走 TaoToken 的统一 Key 和 API 通道这样你本地验证和线上接入用的是同一套凭证不用改代码。2. TaoToken 前置准备统一 Key 与通道配置在写代码之前先把通道这块理清楚。TaoToken 在这里的角色是统一模型调用入口你拿一个 Key就能在 MCP Server 的工具实现里发起模型请求不用每个模型单独配一套凭证。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址不带 UTM 参数配置的时候别拼错。你需要做的第一件事是拿到 API Key。进控制台创建地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建完在 API Keys 页面复制页面是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。这个 Key 后面会写进 config.toml也会在 Java 代码里读取。为什么要有 config.toml因为 MCP Server 经常要在不同环境跑本地、测试、线上Key 和模型名不一样。硬编码在 Java 里改起来麻烦还容易把 Key 提交到仓库。用一个外部配置文件启动时读取既安全又灵活。下面这个骨架你可以直接复制字段含义我写在注释里。# config.toml - MCP Server 统一配置骨架 [server] name java-native-mcp version 1.0.0 # 传输方式原生 Java 用 stdio transport stdio [taotoken] # 统一 API 入口不要带 UTM base_url https://taotoken.net/api # 从控制台复制的 Key api_key sk-你的实际Key # 默认模型按需替换 default_model claude-3-5-sonnet # 请求超时单位秒 timeout_seconds 60 [tools] # 启用的工具列表逗号分隔 enabled [calculator, model_chat]读取这个文件用 Java 原生方式就行不引额外库也可以但为了省事我用了 Jackson 的ObjectMapper读 TOML 不太合适TOML 解析建议用tomlj或者简单点用java.util.Properties改 ini 格式。这里为了保持「原生」和依赖最少我实际用的是tomlj一个小库Maven 坐标是org.tomlj:tomlj:1.1.1。如果你连这个都不想加可以把配置改成.properties用Properties.load()读效果一样。Key 的安全提醒config.toml 一定要加进.gitignore别提交。线上环境用环境变量覆盖代码里优先读TAOTOKEN_API_KEY读不到再读配置文件。这样本地调试方便线上也安全。3. 可复制配置pom.xml 依赖与打包骨架原生 Java 不等于不用 Maven依赖管理还是要的。核心依赖就两个官方 MCP SDK 和 Jackson。SDK 用 BOM 统一版本避免版本冲突。下面这段 pom 可以直接用注意maven-shade-plugin是必须的原因后面排障章节会讲。project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdjava-native-mcp/artifactId version1.0.0/version packagingjar/packaging properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencyManagement dependencies dependency groupIdio.modelcontextprotocol.sdk/groupId artifactIdmcp-bom/artifactId version0.8.1/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies !-- MCP 核心 SDK含 STDIO 传输 -- dependency groupIdio.modelcontextprotocol.sdk/groupId artifactIdmcp/artifactId /dependency !-- JSON 序列化 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.17.0/version /dependency !-- TOML 配置解析 -- dependency groupIdorg.tomlj/groupId artifactIdtomlj/artifactId version1.1.1/version /dependency /dependencies build plugins !-- 打胖 JAR必须 -- plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.example.mcp.McpServerApp/mainClass /transformer /transformers /configuration /execution /executions /plugin /plugins /build /project这里有个细节mcp-bom的scope是import只放在dependencyManagement里真正引依赖的时候不写版本号由 BOM 统一管。这样以后升级 SDK 只改一个地方。maven-shade-plugin的mainClass要和你实际的启动类全路径一致写错了打包能成功但运行会报找不到主类。配置骨架到这里就齐了config.toml 管运行时参数pom.xml 管依赖和打包。接下来写 Java 代码分三块配置加载、工具注册、服务器启动。4. 启动类与工具注册原生 Java 实现先写配置加载类把 config.toml 读进来Key 优先从环境变量取。这个类很简单但能避免后面到处传参。package com.example.mcp; import org.tomlj.Toml; import org.tomlj.TomlParseResult; import java.nio.file.Path; public class AppConfig { public final String serverName; public final String serverVersion; public final String baseUrl; public final String apiKey; public final String defaultModel; public final int timeoutSeconds; public AppConfig(Path configPath) throws Exception { TomlParseResult toml Toml.parse(configPath); if (toml.hasErrors()) { throw new IllegalStateException(config.toml 解析失败: toml.errors()); } this.serverName toml.getString(server.name); this.serverVersion toml.getString(server.version); this.baseUrl toml.getString(taotoken.base_url); // 环境变量优先方便线上覆盖 String envKey System.getenv(TAOTOKEN_API_KEY); this.apiKey (envKey ! null !envKey.isBlank()) ? envKey : toml.getString(taotoken.api_key); this.defaultModel toml.getString(taotoken.default_model); this.timeoutSeconds toml.getLong(taotoken.timeout_seconds).intValue(); } }然后是工具注册。MCP 工具的核心是「Schema 回调」。Schema 告诉客户端参数长什么样回调负责真正执行。我注册两个工具一个calculator做本地计算一个model_chat走 TaoToken 通道调模型。这样既能验证工具能被调用又能验证通道连通。package com.example.mcp; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; public class McpServerApp { public static void main(String[] args) throws Exception { AppConfig config new AppConfig(java.nio.file.Path.of(config.toml)); ObjectMapper mapper new ObjectMapper(); // 1. STDIO 传输原生 Java 不依赖 Web 框架 StdioServerTransportProvider transport new StdioServerTransportProvider(mapper); // 2. 构建服务器并声明能力 McpSyncServer server McpServer.sync(transport) .serverInfo(config.serverName, config.serverVersion) .capabilities(McpSchema.ServerCapabilities.builder() .tools(true) .logging() .build()) .build(); // 3. 注册 calculator 工具 String calcSchema { type: object, properties: { operation: {type: string}, a: {type: number}, b: {type: number} }, required: [operation, a, b] } ; server.addTool(new McpServerFeatures.SyncToolSpecification( new McpSchema.Tool(calculator, 基础计算器, calcSchema), (exchange, arguments) - { String op (String) arguments.get(operation); double a ((Number) arguments.get(a)).doubleValue(); double b ((Number) arguments.get(b)).doubleValue(); double result switch (op) { case add - a b; case sub - a - b; case mul - a * b; case div - b 0 ? Double.NaN : a / b; default - throw new IllegalArgumentException(不支持: op); }; return new McpSchema.CallToolResult( String.valueOf(result), false); } )); // 4. 注册 model_chat 工具走 TaoToken 通道 String chatSchema { type: object, properties: { prompt: {type: string} }, required: [prompt] } ; server.addTool(new McpServerFeatures.SyncToolSpecification( new McpSchema.Tool(model_chat, 调用模型对话, chatSchema), (exchange, arguments) - { String prompt (String) arguments.get(prompt); String reply callTaoToken(config, prompt); return new McpSchema.CallToolResult(reply, false); } )); // 5. 保持运行等待 STDIO 请求 Thread.currentThread().join(); } private static String callTaoToken(AppConfig config, String prompt) throws Exception { ObjectMapper mapper new ObjectMapper(); String body mapper.writeValueAsString(java.util.Map.of( model, config.defaultModel, messages, java.util.List.of( java.util.Map.of(role, user, content, prompt)) )); HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(config.timeoutSeconds)) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(config.baseUrl /v1/chat/completions)) .header(Content-Type, application/json) .header(Authorization, Bearer config.apiKey) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() ! 200) { return 调用失败: HTTP response.statusCode() response.body(); } var node mapper.readTree(response.body()); return node.path(choices).path(0) .path(message).path(content).asText(); } }几个关键点解释一下。StdioServerTransportProvider负责把请求从标准输入读进来、把结果写到标准输出这是原生 Java 最轻的通信方式不需要起 HTTP 服务。capabilities里只开了tools和logging没开resources和prompts因为这次用不到开了反而增加客户端发现成本。Thread.currentThread().join()是让主线程挂住否则 main 方法一结束进程就退了服务器根本来不及处理请求。model_chat工具里我直接用了 Java 11 的HttpClient没引 OkHttp 之类的库保持依赖干净。请求路径是/v1/chat/completions这是 OpenAI 兼容格式TaoToken 的 API 入口是 https://taotoken.net/api 拼起来就是完整的调用地址。Key 从配置里读走Authorization: Bearer头。5. 验证请求打包、Inspector 连接与调用代码写完先打包。注意必须用mvn package触发 shade 插件生成的是胖 JAR。如果你只跑mvn compile或者用 IDE 直接运行本地能跑但打包出来给 Inspector 用会缺依赖。mvn clean package # 输出在 target/ 下找不带 original- 前缀的那个 # 例如 target/java-native-mcp-1.0.0.jar打包完先本地自测一下确认 JAR 能独立启动。因为 STDIO 模式下程序启动后会等输入直接java -jar会看起来「卡住」这是正常的按 CtrlC 退出即可。java -jar target/java-native-mcp-1.0.0.jar # 无报错、进程挂起等待输入说明启动正常接下来用 MCP Inspector 验证。Inspector 不用安装直接 npx 跑npx modelcontextprotocol/inspector java -jar target/java-native-mcp-1.0.0.jar打开它提示的本地页面后传输方式选STDIOCommand 填javaArguments 填-jar target/java-native-mcp-1.0.0.jar然后点 Connect。连上后左侧会列出两个工具calculator和model_chat。先测calculator参数填{operation: mul, a: 6, b: 7}点调用返回应该是42。这一步验证的是工具注册和 STDIO 通道本身没问题。再测model_chat参数填{prompt: 用一句话说明什么是 MCP}点调用。如果返回一段模型生成的文本说明 TaoToken 通道连通、Key 有效、模型名正确。如果返回「调用失败: HTTP 401」就是 Key 错了返回 404多半是 base_url 拼错或者模型名不对。成功的结果长这样Inspector 右侧显示CallToolResultcontent里是返回文本isError为 false。两个工具都调通就说明整条链路——原生 Java 启动、STDIO 通信、工具注册、TaoToken 通道——全部打通了。如果你更习惯在对话界面里验证模型也可以直接进模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 手动发一条消息确认 Key 和模型可用再回到 Inspector 测工具调用。这样排查时能快速区分是通道问题还是代码问题。6. 本篇常见错排查第一个坑也是最多人踩的mvn package打出来的是瘦 JAR运行报NoClassDefFoundError或ClassNotFoundException。原因就是默认打包只包含你自己的 class第三方依赖没进去。解决办法就是 pom 里配maven-shade-plugin打包后在 target 下找不带original-前缀的那个 JAR。如果你用 IDE 的 Artifacts 功能选「extract to the target JAR」也能达到同样效果但命令行场景还是 shade 插件更稳。第二个坑Inspector 连不上或者连上后工具列表是空的。先检查 Command 和 Arguments 是否分开填java是 Command-jar xxx.jar是 Arguments别整行塞进 Command。再检查 JAR 路径是不是相对路径导致找不到建议用绝对路径。工具列表为空通常是capabilities没开tools(true)或者addTool在build()之前调用了顺序错了。第三个坑model_chat返回 401 或 403。先确认TAOTOKEN_API_KEY环境变量有没有覆盖掉配置文件里的值有时候本地设了个旧 Key代码读的是环境变量你以为改的是 toml其实没生效。再确认Authorization头是Bearer加 Key中间有空格别漏了。403 还可能是模型名不对换成配置里default_model对应的可用模型再试。第四个坑程序启动后立刻退出。检查 main 方法最后有没有Thread.currentThread().join()没有的话主线程跑完就结束了。另外别在启动后调server.close()那会直接关掉服务器。第五个坑TOML 解析报错。tomlj对格式比较敏感字符串要加引号数字不要加引号数组用方括号。timeout_seconds读的时候用getLong写成字符串会抛类型异常。第六个坑中文返回乱码。STDIO 默认编码可能不是 UTF-8启动时加-Dfile.encodingUTF-8或者在 Inspector 的 Arguments 里加上这个 JVM 参数。排查思路就一条先确认 JAR 能独立启动再确认 Inspector 能连上再确认工具能列出最后确认调用能返回。哪一步断了就查哪一步别跳。7. 后续接入与通道选择工具跑通之后接下来看你的使用场景选通道。如果你只是偶尔验证模型输出直接用模型对话页面最省事地址是 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。如果你要把这个 MCP Server 接到长期运行的编码助手或者 Agent 里建议用 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它更适合持续调用场景配额和稳定性比按次调用更可控。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的 API 说明和参数列表遇到请求格式问题先翻文档。Key 管理统一在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 可以创建多个 Key 分环境用本地一个、线上一个泄露了直接吊销不影响其他环境。如果你用的是 Claude Code 这类工具它有自己的接入方式参考 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite 配置思路和这篇的 config.toml 类似都是统一 Key 加统一入口。最后说个实际经验原生 Java 搭 MCP Server 最大的好处是可控。出问题的时候从 main 方法一路跟到工具回调中间没有框架黑盒日志打在哪、请求从哪进、结果从哪出全都看得见。代价就是打包和配置要自己管但这两块一旦配好后面加工具就是复制一段 Schema 加一个回调的事。我建议你先把 calculator 跑通再加 model_chat最后按业务加自己的工具一步一步来别一上来就堆一堆工具排查起来会乱。
