Administrator
发布于 2025-09-21 / 6323 阅读
46

用 Java 实现一个 MCP Server 的完整过程

给公司内部的 CMDB 写了个 MCP Server

八月份我们决定把内部 CMDB、发布系统、监控平台接进 Agent。一开始想的是直接写 Function Calling,写了两周发现每个 Agent 框架(我们内部有 Spring AI 和 LangChain4j 两套)都要实现一遍,工具定义还容易不同步。后来换成了 MCP——把能力实现一次,所有支持 MCP 的客户端都能用。

这篇是从零写一个 Java MCP Server 的完整记录,包括协议细节、三个能力(工具/资源/提示模板)怎么暴露,以及最折腾的调试部分。

先搞清楚 MCP 在传什么

动手之前我把协议规范读了一遍。MCP 本质上是 JSON-RPC 2.0 加了几个约定的方法名,传输层有两种:stdio(本地进程)和 HTTP。

这里有个时间点要说清楚:今年早些时候的规范里 HTTP 传输用的是 SSE(两个端点,一个 GET 收事件一个 POST 发请求),现在的版本已经改成 Streamable HTTP——单个端点,POST 请求可以返回普通 JSON 也可以升级成 SSE 流。SSE 传输被标记为废弃了。我们直接用新的。

一次完整的握手和调用,网络上大概是这样:

// 1) 客户端 → 服务端:初始化
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"roots":{"listChanged":true},"sampling":{}},
  "clientInfo":{"name":"claude-desktop","version":"0.9.1"}}}

// 2) 服务端 → 客户端:能力声明
{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":true},
                   "resources":{"subscribe":false,"listChanged":true},
                   "prompts":{"listChanged":false}},
  "serverInfo":{"name":"cmdb-mcp","version":"1.2.0"}}}

// 3) 客户端 → 服务端:通知初始化完成(注意没有 id,是 notification)
{"jsonrpc":"2.0","method":"notifications/initialized"}

// 4) 客户端 → 服务端:列工具
{"jsonrpc":"2.0","id":2,"method":"tools/list"}

// 5) 客户端 → 服务端:调工具
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
  "name":"query_host","arguments":{"service":"order-service"}}}

第 3 步那个 notifications/initialized 是个坑点。我第一版实现的时候忘了处理这条通知,客户端一直卡在初始化状态不往下走。这类 notification 消息的特征是没有 id 字段,且不需要响应,实现时一定要区分开。

用 Spring AI MCP Server 起手

自己解析 JSON-RPC 不难但没必要,Spring AI 已经提供了 server starter。依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>

注意有两个 artifact:-webmvc 是基于 Servlet 的(阻塞式,适合传统 Spring MVC 项目),-webflux 是响应式的。还有个 spring-ai-starter-mcp-server 是 stdio 版本。我们选了 webmvc,因为要部署成内部服务给多个客户端用。

配置:

spring:
  ai:
    mcp:
      server:
        name: cmdb-mcp
        version: 1.2.0
        type: SYNC
        protocol: STREAMABLE          # 或 STATELESS
        streamable-http:
          mcp-endpoint: /mcp
        capabilities:
          tool: true
          resource: true
          prompt: true
          completion: false

STREAMABLESTATELESS 的区别值得说。STREAMABLE 会在服务端维护会话(通过 Mcp-Session-Id 请求头),支持服务端向客户端发起请求(sampling);STATELESS 完全无状态,每个请求独立处理,可以随意水平扩展。

我们内部工具不需要服务端反向调用客户端,一开始图省事用了 STATELESS。后来发现有个问题:无状态下无法做逐会话的权限校验缓存,每次调用都要重新解析 token,多了 40ms。但 STATELESS 的部署优势太大(可以随便扩缩容、不需要会话亲和),最后还是保留了,把权限校验换成了本地缓存 + 短 TTL。

暴露工具:注解背后的 schema 生成

工具定义就是给方法加注解,Spring AI 会反射生成 JSON Schema 发给客户端:

@Service
public class CmdbTools {

    private final CmdbClient cmdb;

    @Tool(description = """
        按服务名查询该服务下所有主机的信息。
        返回主机名、IP、环境、部署版本、CPU/内存规格、负责人。
        当用户问"某服务部署在哪台机器上"时使用此工具。
        """)
    public List<HostInfo> queryHosts(
            @ToolParam(description = "服务名,如 order-service,必须是完整的 hyphen 格式")
            String service,
            @ToolParam(description = "环境:prod / staging / test,不传则查所有环境",
                       required = false)
            String env) {
        return cmdb.queryHosts(service, env);
    }

    @Tool(description = "查询服务最近 N 分钟的发布记录,包含发布人、时间、版本、结果")
    public List<DeployRecord> queryDeployments(
            @ToolParam(description = "服务名") String service,
            @ToolParam(description = "时间窗口,单位分钟,默认 1440(24 小时)",
                       required = false)
            Integer windowMinutes) {
        int w = (windowMinutes == null) ? 1440 : Math.min(windowMinutes, 43200);
        return cmdb.queryDeployments(service, w);
    }
}

description 这件事比想象中重要。我第一版写得很简略(「查询主机信息」),结果 Agent 经常在不该调用的时候调用,或者参数传错。改成像上面这样描述清楚「什么时候用」+「返回什么」之后,调用准确率从 68% 提到 94%。

这个数字是我们拿 120 条测试问题跑出来的。判断标准是:该调的调了、参数正确、没有多余调用。

注册工具:

@Bean
ToolCallbackProvider cmdbTools(CmdbTools tools) {
    return MethodToolCallbackProvider.builder()
            .toolObjects(tools)
            .build();
}

错误处理:别把异常栈抛给模型

工具抛异常时,MCP 会把错误消息回传。默认行为是把整个异常栈文本发出去,一次能占 3000 token,而且模型看到栈会开始瞎猜。我们包了一层:

@Tool(description = "...")
public String queryHostsSafe(String service) {
    try {
        return toJson(cmdb.queryHosts(service));
    } catch (ServiceNotFoundException e) {
        // 短、明确、带可执行的下一步建议
        return "错误:服务 '" + service + "' 不存在。"
             + "可用工具 list_all_services 查询所有服务名。";
    } catch (CmdbTimeoutException e) {
        return "错误:CMDB 查询超时(>5s),请稍后重试。";
    }
}

关键是把错误翻译成模型能据此调整行为的信息。「服务不存在,用 list_all_services 查」比「404 Not Found」有用得多。加上这个之后,Agent 自主纠错的成功率提升明显——我们统计过一次参数写错的服务名,Agent 能自己纠正的比例是 71%。

资源:让 Agent 能「读文件」

工具是「做事」,资源是「读数据」。我们暴露了两类资源:SOP 文档和配置文件。

@Component
public class SopResources {

    @McpResource(uri = "cmdb://sop/{category}",
                 name = "运维 SOP 文档",
                 description = "按类别返回标准操作流程,如 database、network、deploy")
    public ReadResourceResult getSop(String category) {
        String content = sopRepo.load(category);
        if (content == null) {
            throw new McpResourceNotFoundException("cmdb://sop/" + category);
        }
        return new ReadResourceResult(List.of(
            new TextResourceContents("cmdb://sop/" + category,
                                     "text/markdown", content)));
    }

    @McpResource(uri = "cmdb://config/{service}/{env}",
                 name = "服务配置",
                 mimeType = "application/yaml")
    public ReadResourceResult getConfig(String service, String env) {
        return new ReadResourceResult(List.of(new TextResourceContents(
            "cmdb://config/%s/%s".formatted(service, env),
            "application/yaml",
            configRepo.load(service, env))));
    }
}

URI 模板里的 {category} 这类变量会被自动解析。客户端调 resources/list 能拿到资源列表,resources/read 读具体内容。

有个设计上的考虑值得说:为什么不把 SOP 做成工具?因为资源是幂等读取,工具是有副作用的操作。这个语义区分对客户端有用——Claude Desktop 这类客户端会把资源显示成可附件的文档,用户可以主动选择加载,而工具只能由模型主动调用。另外资源可以被订阅(resources/subscribe),内容变了能推送通知。

我们还没用上订阅能力,但 URI 设计上已经留好了。

提示模板:把团队经验固化下来

Prompt 是 MCP 里最容易被忽略的能力,我觉得反而是性价比最高的。它让服务端提供预置的提示词模板,用户在客户端斜杠命令触发。

@Component
public class CmdbPrompts {

    @McpPrompt(name = "troubleshoot-timeout",
               description = "按团队标准流程排查服务超时问题")
    public GetPromptResult troubleshootTimeout(
            @McpArg(name = "service", description = "服务名", required = true)
            String service) {
        return new GetPromptResult("超时问题排查:" + service, List.of(
            new PromptMessage(Role.ASSISTANT, new TextContent("""
                你是一名 SRE,按以下步骤排查 %s 的超时问题:
                1. 用 query_hosts 确认服务部署在哪几台机器、当前版本
                2. 用 query_deployments 查最近 24 小时有没有发布
                3. 如果有发布,对比发布前后的 P99
                4. 用 query_metrics 查 CPU、内存、GC、线程池、连接池
                5. 按 cmdb://sop/troubleshooting 检查常见原因

                每一步都要写出具体数值,不要说"可能""大概"。
                """.formatted(service)))));
    }
}

这个模板把我们团队排查超时问题的顺序固化下来了。以前每个新同事排查时顺序都不一样,漏步骤是常态。现在至少起点是一致的。

我们一共写了 7 个模板:超时排查、容量评估、发布前检查、故障复盘、慢 SQL 分析、告警噪音治理、值班交接。值班交接那个模板用得最多,因为每次交接要交代的东西固定,但大家总是漏。

调试:最折腾的部分

协议实现本身不难,难的是出了问题不知道错在哪。分享三个工具。

MCP Inspector

官方的调试工具,必须会用。它是个本地 web 界面,能连上你的 server 直接列工具、调工具、看原始 JSON-RPC 报文:

npx @modelcontextprotocol/inspector@latest
# 打开 http://localhost:6274
# Transport Type 选 Streamable HTTP,URL 填 http://localhost:8080/mcp

我最常用的功能看原始报文。有次 Agent 说「工具返回为空」,Inspector 里一看,服务端明明返回了完整数据,问题在客户端序列化。没有这个工具,我可能要在 Agent 框架里打半天断点。

开启协议级日志

logging:
  level:
    io.modelcontextprotocol: DEBUG
    org.springframework.ai.mcp: DEBUG

DEBUG 会把每个 JSON-RPC 消息原文打出来。生产别开,量很大,我们测试环境开了一天产生了 2.1 GB 日志。

自己写的连通性检查脚本

部署后要快速确认服务可用,我写了个 shell 脚本放进了发布流程:

#!/bin/bash
# mcp-smoke.sh
MCP_URL="${1:-http://localhost:8080/mcp}"

call() {
  curl -s -X POST "$MCP_URL" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    -d "$1"
}

echo "== initialize =="
call '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
                 "clientInfo":{"name":"smoke","version":"1"}}}'

echo -e "\n== tools/list =="
call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq '.result.tools[].name'

注意那个 Accept 头必须同时包含 application/jsontext/event-stream,这是 Streamable HTTP 的要求。我第一次写脚本时只写了 application/json,服务端返回 406,排查了半小时。

还有一点:如果服务端返回的是 SSE 流(Content-Type: text/event-stream),响应体是 event: message\ndata: {...} 格式,用 jq 解析前要先剥掉前缀。

遇到的其他问题

  • 方法重载会导致工具注册失败queryHosts(String)queryHosts(String, String) 不能同时注册,反射时按方法名找会混乱。拆成不同名字。
  • 返回类型别用复杂泛型。我们有个方法返回 Map<String, List<Map<String, Object>>>,生成的 JSON Schema 是空的,客户端拿不到结构信息。改成显式定义的 DTO 类就好了。
  • 工具数量别超过 40 个。我们一开始暴露了 63 个工具,发现模型选工具的准确率明显下降(从 94% 掉到 79%)。后来按场景拆成多个 MCP Server,每个 15~20 个工具,准确率回来了。这个数字是我按自己场景测的,未必通用,但趋势很明确。
  • 一定要自己实现一次协议再上框架。我们组新同事直接用 Spring AI starter 写,出了协议问题两小时没头绪。我让他花半天用纯 JSON-RPC 手搓了一遍,之后所有框架问题都能自己定位了。
  • JDK 版本。我们用 JDK 21,跑得很稳。JDK 25 这个月刚发布,同事在他的分支上试过,紧凑对象头(JEP 519)能让这个服务(大量短生命周期的 JSON 对象)内存占用降 15% 左右,但我们生产还没动,再观望两个月。

下篇预告

这篇先把《用 Java 实现一个 MCP Server 的完整过程》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考