给公司内部的 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
STREAMABLE 和 STATELESS 的区别值得说。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/json 和 text/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 的完整过程》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。