1. 为什么要在纯 Java 里手搓一个 STDIO MCP Server
MCP(Model Context Protocol)这两年成了大模型接工具的事实标准,而 STDIO 通信是它最朴素也最稳的一种传输方式:客户端把 Server 当成一个子进程拉起来,双方通过标准输入(stdin)和标准输出(stdout)交换 JSON-RPC 消息。没有端口、没有 HTTP、没有网络配置,进程活着通道就活着,进程挂了通道就断,调试起来一目了然。
纯 Java 实现 STDIO MCP Server 这件事,适合几类人:一是你不想为了一个工具函数就引入 Spring Boot 全家桶,想要一个几十行 main 方法就能跑起来的最小进程;二是你要把 MCP Server 塞进已有的命令行工具或桌面程序里,进程模型越简单越好;三是你在做客户端联调,需要一个可控的、能随时打断点看 stdin/stdout 的服务端。它本质上就是一个「读一行 JSON、算一下、写一行 JSON」的循环,难点不在算法,而在协议握手、能力声明和传输层别被日志污染。
我这次的目标很明确:用纯 Java(不依赖 Spring Boot)写一个只暴露一个加法 Tool 的 MCP Server,打包成带依赖的 fat jar,再用官方 Java SDK 的客户端把它拉起来,完成一次 initialize → listTools → callTool 的完整往返。同时把鉴权这一层交给 TaoToken 的统一 Key 通道,这样客户端侧不用为每个模型供应商维护一套密钥,Base URL、Key、Model ID 三件套配一次就能复用。下面从依赖、代码、打包、联调到排错,一步步走完。
2. TaoToken 统一 Key 前置准备:Base URL、Key 与 Model ID
在动手写代码之前,先把「鉴权通道」这件事理清楚。MCP Server 本身是本地进程,它不直接跟模型说话;真正需要 Key 的是调用模型的客户端或上层 Agent。TaoToken 在这里扮演的是统一入口:你拿到一个 Key,配一个 Base URL,就能在多个模型之间切换,不用为每家单独申请和轮换密钥。
你需要准备三样东西,我把它叫做「三件套」:
| 项目 | 值 | 说明 |
|---|---|---|
| Base URL | https://taotoken.net/api | OpenAI 兼容风格的基础地址,注意不要带多余路径 |
| API Key | 在控制台生成 | 形如sk-...,只显示一次,务必存好 |
| Model ID | 例如claude-sonnet-4-5等 | 按你实际要调用的模型填,大小写敏感 |
获取路径很直接:打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册登录后进入控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 创建一个新 Key。创建时建议按用途命名,比如mcp-local-dev,方便以后按项目吊销。
注意:Key 只在创建时完整展示一次,页面刷新后就只剩前缀。如果你打算在多个本地项目里复用,建议写进环境变量而不是硬编码进代码,避免提交到 Git。
环境变量可以这样设(Linux/macOS):
export TAOTOKEN_API_KEY="sk-你的Key" export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_MODEL="claude-sonnet-4-5"Windows PowerShell:
$env:TAOTOKEN_API_KEY="sk-你的Key" $env:TAOTOKEN_BASE_URL="https://taotoken.net/api" $env:TAOTOKEN_MODEL="claude-sonnet-4-5"这里要强调一个容易混淆的点:本篇的 MCP Server 是纯本地 STDIO 进程,它自己不读这个 Key;Key 是给「调用模型的客户端」用的。也就是说,链路是「你的 Agent/客户端 → 通过 TaoToken 调模型 → 模型决定调用哪个 Tool → 客户端通过 STDIO 把 Tool 调用转发给本地 MCP Server」。把这条链路想清楚,后面联调时就不会纠结「Server 为什么不需要 Key」。
如果你用的是 Claude Code 这类工具,它的配置里同样填这三件套,Base URL 指向https://taotoken.net/api,Key 填刚生成的,Model ID 按需选。想先验证 Key 是否可用,可以直接去模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 发一句话,能正常返回就说明通道没问题,再往下写代码。
3. 可复制的纯 Java MCP Server 配置与代码
这一节是全文的技术核心。项目结构很简单,一个pom.xml加两个类:Main(Server)和McpClientDemo(客户端验证)。
先看pom.xml的关键部分。依赖用官方 SDK 的 BOM 统一管理版本,当前用 0.9.0:
<dependencyManagement> <dependencies> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-bom</artifactId> <version>0.9.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> </dependency> </dependencies>打包要打成带依赖的 fat jar,否则运行时得手动拼 ClassPath。加maven-assembly-plugin,并指定主类:
<plugin> <artifactId>maven-assembly-plugin</artifactId> <version>3.3.0</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>com.osxm.ai.mcp.purejava.Main</mainClass> </manifest> </archive> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin>Server 端代码的核心是三步:建传输提供程序、建 Tool 规范、建同步 Server。传输用StdioServerTransportProvider,它负责把 stdin/stdout 包装成 JSON-RPC 通道:
StdioServerTransportProvider transportProvider = new StdioServerTransportProvider(new ObjectMapper()); var schema = """ { "type": "object", "properties": { "operation": { "type": "string" }, "a": { "type": "number" }, "b": { "type": "number" } } } """; SyncToolSpecification syncToolSpecification = new McpServerFeatures.SyncToolSpecification( new Tool("calculator", "Basic Calculator", schema), (exchange, arguments) -> { String operation = (String) arguments.get("operation"); int a = (Integer) arguments.get("a"); int b = (Integer) arguments.get("b"); double result = "add".equals(operation) ? a + b : a - b; return new McpSchema.CallToolResult(String.valueOf(result), false); }); var capabilities = ServerCapabilities.builder() .tools(true) .build(); var mcpServer = McpServer.sync(transportProvider) .capabilities(capabilities) .tools(syncToolSpecification) .build();这里tools(true)表示声明工具能力并开启工具列表变更通知。CallToolResult第二个参数false表示不是错误结果。工具名calculator是客户端调用时的唯一标识,别写错。
一个必须记住的坑:不要在pom.xml里加 logback 之类的日志实现依赖。因为 STDIO 通道本身就占用标准输入输出,日志框架默认也往 stdout 写,两者一混,JSON-RPC 消息就被日志文本污染,客户端解析直接失败。开发阶段用System.err打调试信息是安全的,因为 stderr 不参与协议通道。
打包命令:
mvn clean package产物里会有一个pure-java-mcp-jar-with-dependencies.jar,这就是客户端要拉起的那个 jar。
4. 客户端验证:一次完整的 initialize 到 callTool 往返
Server 有了,接下来写客户端把它拉起来验证。客户端用StdioClientTransport,通过ServerParameters指定启动命令和参数,本质就是「用 java -jar 把 Server 当子进程跑起来」:
var stdioParams = ServerParameters.builder("java") .args("-jar", "target/pure-java-mcp-jar-with-dependencies.jar") .build(); StdioClientTransport transport = new StdioClientTransport(stdioParams); var client = McpClient.sync(transport).build(); client.initialize(); ListToolsResult toolsList = client.listTools(); System.out.println("Available Tools = " + toolsList); CallToolResult result = client.callTool( new CallToolRequest("calculator", Map.of("operation", "add", "a", 2, "b", 3))); System.out.println("result=" + result); client.closeGracefully();运行后你会看到两段关键输出:listTools返回了calculator工具及其 schema,callTool返回了5.0。这说明整条 STDIO 链路是通的——客户端写 stdin,Server 读并处理,结果写 stdout,客户端读回。
如果你想把模型也接进来,让模型自己决定调用这个 Tool,那么客户端侧就要配上 TaoToken 的三件套。以 OpenAI 兼容调用为例,配置片段如下:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "claude-sonnet-4-5" }把这段配置放进你的 Agent 或客户端设置里,模型在需要计算时会发出 tool_call,客户端再通过上面的 STDIO 通道转发给本地 Server。这样「模型鉴权走 TaoToken、工具执行走本地进程」两条线就分开了,各管各的,互不干扰。
验证成功的判断标准很简单:listTools有输出、callTool返回正确数值、进程能优雅关闭。三个都满足,链路就算跑通了。想进一步验证模型侧,可以去模型对话 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 手动发一条带工具调用的请求,观察返回里是否出现对calculator的调用意图。
5. 常见报错排查:401、local proxy failed 与 reading choices
联调阶段最容易卡在几个典型报错上,我按「现象 → 原因 → 处理」列一遍。
401 Unauthorized:出现在模型调用侧,不是 MCP Server 侧。原因通常是 Key 没设对、Key 被吊销、或者 Base URL 写成了带多余路径的形式。检查顺序:先确认环境变量TAOTOKEN_API_KEY真的被进程读到了(打印前几位即可,别打印全量),再确认 Base URL 是https://taotoken.net/api而不是别的。如果用的是 Claude Code 或 Cline 这类工具,去它的设置里核对三件套是否齐全,缺 Model ID 也会报鉴权类错误。
local proxy failed / connection refused:这类报错多半是客户端想连一个本地代理端口但没连上。如果你没配任何本地代理,检查是不是工具里残留了旧的代理配置项,把它清空。MCP 的 STDIO 通道本身不走网络,出现 proxy 相关字样通常是上层模型调用的网络配置问题,跟 Server 无关。
reading choices 相关解析错误:这是模型返回体解析失败,常见于 Base URL 指向了不兼容的端点,或者返回的不是标准 OpenAI 格式。确认你用的是 TaoToken 的兼容端点,并且请求体里model字段拼写正确。Model ID 大小写敏感,写错会返回非预期结构。
STDIO 通道被日志污染:现象是客户端报 JSON 解析失败,或者 initialize 直接超时。原因就是前面说的日志依赖。处理办法:移除 logback/log4j 的绑定依赖,或者把日志输出重定向到 stderr。检查pom.xml里有没有意外引入的日志实现。
jar 找不到主类:运行java -jar报no main manifest attribute。说明打包时没配maven-assembly-plugin的mainClass,或者你运行的是那个不带依赖的瘦 jar。确认用的是-jar-with-dependencies.jar结尾的文件。
客户端拉起 Server 后立刻退出:检查 Server 的 main 方法是否在 build 之后没有阻塞。McpServer.sync(...).build()之后进程需要保持存活等待 stdin,如果代码里 build 完就 return,进程会退出,客户端自然读不到响应。
排查时有个通用手法:单独用命令行手动喂一条 JSON-RPC 给 Server,看它 stdout 吐什么。比如:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | java -jar target/pure-java-mcp-jar-with-dependencies.jar如果 stdout 返回了合法的 initialize 响应,说明 Server 本身没问题,问题在客户端配置;如果 stdout 混着日志文本,那就是日志污染。这一招能快速把问题范围缩小一半。
6. 把链路固化下来:从本地验证到长期编码接入
跑通一次不难,难的是把它变成日常能用的东西。我的做法是把「本地 MCP Server + TaoToken 统一 Key」当成一套固定组合:Server 负责具体工具能力,Key 通道负责模型鉴权,两者通过客户端的 STDIO 转发连接。这样每次新增一个工具,只需要在 Server 里加一个SyncToolSpecification,客户端和 Key 配置都不用动。
如果你打算长期用这套组合做编码或 Agent 任务,建议把 Key 和 Base URL 统一走 TaoToken 的 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,这样额度、模型切换、密钥管理都在一个地方,省得每个项目单独维护。接入细节和参数说明可以对照接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 逐项核对,尤其是 Base URL 和 Model ID 这两项,写错一个字符就是 401 或解析失败。
最后留一个实用习惯:把 Server 的启动命令、jar 路径、客户端配置写进一个README或脚本里,下次换机器时直接复制。STDIO 这套东西最大的优势就是可移植——只要 JVM 在、jar 在、Key 在,链路就能原地复活。