第 2 篇:Java 本地实战------官方 SDK + stdio + 单能力 Server
📍 系列导航 :1. 认识 MCP | 2. 本地实战 | 3. 多能力工程 | 4. HTTP URL | 5. Spring AI | 6. 企业治理
2.1 本篇目标
写完这一篇,你会得到:
- 一个可运行的 Java MCP Server,暴露
query_sales一个工具 java -jar启动,接入 Claude Desktop- 一个自测 Client
- 一套单测
- 一份可交付的 README
先单能力,下一篇再多能力。 一次只加一个变量,排错最容易。
2.2 环境要求
- JDK 17+
- Maven 3.8+
- Claude Desktop(可选,用于接入验证)
- Node.js 18+(可选,用于 MCP Inspector;如果只跑 Server 不需要)
2.3 项目结构
text
sales-mcp-server/
├── pom.xml
├── README.md
└── src/
├── main/java/com/example/sales/
│ ├── SalesMcpServer.java
│ ├── model/SalesRecord.java
│ ├── service/SalesService.java
│ └── client/SalesClientDemo.java
└── test/java/com/example/sales/
└── SalesServiceTest.java
2.4 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>sales-mcp-server</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mcp.version>0.11.0</mcp.version>
<jackson.version>2.17.2</jackson.version>
<slf4j.version>2.0.13</slf4j.version>
<junit.version>5.10.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>${mcp.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.sales.SalesMcpServer</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.2.0</version>
</plugin>
</plugins>
</build>
</project>
依赖说明:
| 依赖 | 作用 |
|---|---|
io.modelcontextprotocol.sdk:mcp |
官方 MCP Java SDK |
jackson-databind |
JSON 序列化 |
slf4j-simple |
日志,默认输出 stderr,不污染 stdio |
junit-jupiter |
单测 |
2.5 数据模型
java
// src/main/java/com/example/sales/model/SalesRecord.java
package com.example.sales.model;
public record SalesRecord(String region, String week, long revenue, int orders) {
}
Java record 天然适合作为 Tool 返回结构。
2.6 业务服务
java
// src/main/java/com/example/sales/service/SalesService.java
package com.example.sales.service;
import com.example.sales.model.SalesRecord;
import java.util.LinkedHashMap;
import java.util.Map;
public class SalesService {
private final Map<String, Map<String, SalesRecord>> data = new LinkedHashMap<>();
public SalesService() {
put("华东", "2026-W20", 65000, 5);
put("华南", "2026-W20", 50000, 5);
put("华北", "2026-W20", 26000, 4);
}
private void put(String region, String week, long revenue, int orders) {
data.computeIfAbsent(region, k -> new LinkedHashMap<>())
.put(week, new SalesRecord(region, week, revenue, orders));
}
public SalesRecord query(String region, String week) {
validateRegion(region);
validateWeek(week);
Map<String, SalesRecord> byWeek = data.get(region);
if (byWeek == null || !byWeek.containsKey(week)) {
throw new IllegalArgumentException(
"无数据:region=" + region + ", week=" + week);
}
return byWeek.get(week);
}
private void validateRegion(String region) {
if (region == null || region.isBlank() || region.length() > 50) {
throw new IllegalArgumentException("region 非法:不能为空且长度 <= 50");
}
}
private void validateWeek(String week) {
if (week == null || !week.matches("\\d{4}-W\\d{1,2}")) {
throw new IllegalArgumentException(
"week 格式必须为 YYYY-Www,例如 2026-W20");
}
}
}
💡 要点:
- 业务和协议解耦:
SalesService不依赖 MCP,方便单测和替换数据源。- 输入必须校验:Agent 可能因幻觉传入非法参数。
- 错误用
IllegalArgumentException:上层 Tool 捕获后转成可读错误。
2.7 MCP Server 入口(单能力)
java
// src/main/java/com/example/sales/SalesMcpServer.java
package com.example.sales;
import com.example.sales.model.SalesRecord;
import com.example.sales.service.SalesService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class SalesMcpServer {
private static final Logger log = LoggerFactory.getLogger(SalesMcpServer.class);
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
SalesService service = new SalesService();
// 1. Transport:stdio
var transport = new StdioServerTransportProvider(mapper);
// 2. Tool:query_sales
var querySalesTool = new McpServerFeatures.SyncToolSpecification(
new McpSchema.Tool("query_sales", "按地区和 ISO 周查询销售数据", QUERY_SALES_SCHEMA),
(exchange, arguments) -> {
try {
String region = asString(arguments.get("region"));
String week = asString(arguments.get("week"));
SalesRecord record = service.query(region, week);
return new McpSchema.CallToolResult(
List.of(new McpSchema.TextContent(mapper.writeValueAsString(record))),
false
);
} catch (Exception e) {
return new McpSchema.CallToolResult(
List.of(new McpSchema.TextContent("错误:" + e.getMessage())),
true
);
}
}
);
// 3. 启动
var server = McpServer.sync(transport)
.serverInfo("sales-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder()
.tools(true)
.build())
.tools(querySalesTool)
.build();
log.info("sales-server 已启动(stdio)");
// 4. 保活
CountDownLatch latch = new CountDownLatch(1);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
server.closeGracefully();
} catch (Exception e) {
log.warn("关闭 Server 时出错", e);
} finally {
latch.countDown();
}
}));
latch.await();
}
private static String asString(Object value) {
if (value == null) {
throw new IllegalArgumentException("缺少必需参数");
}
return value.toString();
}
private static final String QUERY_SALES_SCHEMA = """
{
"type": "object",
"properties": {
"region": {
"type": "string",
"description": "地区名称,例如 华东、华南、华北"
},
"week": {
"type": "string",
"description": "ISO 周,格式 YYYY-Www,例如 2026-W20"
}
},
"required": ["region", "week"]
}
""";
}
💡 要点:
- JSON Schema 就是给模型看的参数文档:
description写清格式、示例、范围。- Tool 内部 try/catch:异常转成
CallToolResult(isError=true),模型能看懂并纠正。- stdio 下禁止
System.out.println:stdout 是协议通道,日志走 stderr。CountDownLatch保活:build()后进程不能退出。
⚠️ Schema 类型不匹配的兜底 :如果编译报错提示Tool构造器不接受 String,用mapper.readValue(QUERY_SALES_SCHEMA, McpSchema.JsonSchema.class)转成对象即可。
2.8 构建与运行
bash
mvn clean package
java -jar target/sales-mcp-server-1.0.0.jar
本地直接跑会等待 stdin 协议消息,Ctrl+C 退出。
2.9 接入 Claude Desktop
配置路径:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
json
{
"mcpServers": {
"sales": {
"command": "java",
"args": ["-jar", "/absolute/path/to/sales-mcp-server-1.0.0.jar"]
}
}
}
重启 Claude Desktop,问:
"查一下 2026-W20 华东的销售数据。"
Claude 会调用 query_sales,返回 JSON,再总结成自然语言。
Claude Code:
bash
claude mcp add sales -- java -jar /absolute/path/to/sales-mcp-server-1.0.0.jar
claude mcp list
2.10 自测 Client
java
// src/main/java/com/example/sales/client/SalesClientDemo.java
package com.example.sales.client;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import java.time.Duration;
import java.util.List;
import java.util.Map;
public class SalesClientDemo {
public static void main(String[] args) throws Exception {
String jarPath = args.length > 0
? args[0]
: "target/sales-mcp-server-1.0.0.jar";
// 不需要环境变量时用 Map.of() 即可。
// 需要传环境变量时写成 Map.of("KEY", "VALUE")。
// 注意:Map.of() 不允许 value 为 null,null 值会抛 NPE。
// 如果确实需要传 null(少见),改用 new HashMap<>()。
var params = new ServerParameters(
"java",
List.of("-jar", jarPath),
Map.of()
);
var transport = new StdioClientTransport(params);
var client = McpClient.sync(transport)
.requestTimeout(Duration.ofSeconds(30))
.build();
client.initialize();
var tools = client.listTools();
System.out.println("可用工具:" +
tools.tools().stream().map(McpSchema.Tool::name).toList());
var result = client.callTool(new McpSchema.CallToolRequest(
"query_sales",
Map.of("region", "华东", "week", "2026-W20")
));
System.out.println("结果:" + result.content());
System.out.println("是否错误:" + result.isError());
client.closeGracefully();
}
}
运行:
bash
mvn -q exec:java \
-Dexec.mainClass=com.example.sales.client.SalesClientDemo \
-Dexec.args="target/sales-mcp-server-1.0.0.jar"
💡 类名提示 :
ServerParameters在不同 SDK 版本可能包路径不同,或直接用StdioClientTransport构造器传 command/args/env。以当前版本 README 为准。
2.11 测试
java
// src/test/java/com/example/sales/SalesServiceTest.java
package com.example.sales;
import com.example.sales.service.SalesService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SalesServiceTest {
private final SalesService service = new SalesService();
@Test
void query_returns_expected_record() {
var r = service.query("华东", "2026-W20");
assertEquals("华东", r.region());
assertEquals(65000, r.revenue());
}
@Test
void query_missing_data_throws() {
assertThrows(IllegalArgumentException.class,
() -> service.query("华东", "2026-W99"));
}
@Test
void query_invalid_week_throws() {
assertThrows(IllegalArgumentException.class,
() -> service.query("华东", "2026-20"));
}
}
bash
mvn test
协议层测试:
bash
npx @modelcontextprotocol/inspector java -jar target/sales-mcp-server-1.0.0.jar
💡 Inspector 提示 :如果提示找不到
npx,请确保已安装 Node.js 18+ 。Inspector 会启动一个本地 Web UI(通常是http://localhost:5173),在浏览器中打开即可可视化调试工具列表和调用结果。
📌 发布建议:Java 开发者对"黑盒 stdio"容易有恐惧感。发博客或内部文档时,强烈建议配一张 Inspector 截图,展示左侧 Tools 列表、右侧 Call 结果。看到图形化界面,接入成本的心理门槛会大幅降低。
2.12 交付 README
markdown
# sales-mcp-server 接入说明
## 这是什么
销售数据 MCP Server,提供 query_sales 工具。
## 本地 stdio
要求:Java 17+,拿到 sales-mcp-server-1.0.0.jar。
Claude Desktop 配置:
(见 2.9)
## 工具清单
| 工具 | 参数 | 返回 | 常见错误 |
|---|---|---|---|
| query_sales | region: string, week: string | SalesRecord JSON | 无数据:region=..., week=... |
## 问题排查
1. Inspector 确认工具能列出
2. 检查 Java 版本、jar 路径
3. 日志必须在 stderr
2.13 常见坑
| 坑 | 现象 | 解决 |
|---|---|---|
| stdout 被日志污染 | 客户端握手失败、解析错误 | 禁用 System.out.println,slf4j-simple 默认 stderr |
| 进程启动即退出 | 客户端连接后无响应 | CountDownLatch 保活 |
| shade 签名冲突 | 启动时 SecurityException |
排除 META-INF/*.SF/*.DSA/*.RSA |
| 缺 manifest | no main manifest attribute |
配 ManifestResourceTransformer |
| Tool 异常未捕获 | 客户端收到协议错误 | try/catch 返回 isError=true |
mvn exec:java 找不到插件 |
No plugin found for prefix 'exec' |
pom 加 exec-maven-plugin |
Map.of() 传 null |
启动时 NPE | Map.of() 不允许 null;改 HashMap 或去掉该 key |
2.14 本篇小结
- 单能力 Server 的最小闭环:Service → Tool → Transport → 启动 → 保活。
- stdio 适合本地,
java -jar由 Host 启动。 - 单测抓业务 bug,Inspector 抓协议问题。
- 安全底线在 Server 端:输入校验、异常可读、日志隔离。
下一篇:一个工程多个能力------多 Tool、多 Resource、多 Prompt,以及多 Server / 多 module 拆分。