MCP第 2 篇:Java 本地实战:官方 SDK + stdio + 单能力 Server

第 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 拆分。

相关推荐
无障碍研究院1 小时前
2026TADC|圆桌对话:AI的包容性、反歧视、向善
人工智能
派小心.1 小时前
DAU和MAU怎么计算?先确定活跃事件、用户键和统计窗口
大数据·服务器·前端·人工智能·数据分析
诺鸭船长1 小时前
AI硬件大白话扫盲指南:从入门到榨干
人工智能
RockChinQ1 小时前
群机器人别再只会回消息:给入群、好友申请各配一个处理器
人工智能
m0_547486661 小时前
《人工智能安全》全套PPT课件2026(复旦大学)
人工智能·安全·人工智能安全
是翎1 小时前
Vicquant的RAG评测盲区:20道文档问题漏掉18道,先查路由再看回答质量
大数据·人工智能
枯木◊靠推文躺平版1 小时前
2026 企业 AI 办公工具选型指南:理清需求再做采购决策
大数据·人工智能
szxinmai主板定制专家1 小时前
告别 ARM 实时性短板:基于 ZYNQ 的电力故障录波 + 工业振动数据采集系统设计
arm开发·人工智能·fpga开发·zynq·数据采集卡·arm+fpga·rk3588+fpga
互联网资讯1 小时前
AI短剧制作系统哪家好?2026热门短剧工具天空ai测评
大数据·人工智能·ai短剧系统