目录
- 一、为什么这一步故意不接 LLM
- 二、MCP Server 的三个要素
- 三、写一个 Server
- 四、写一个 Client
- 五、跑起来看输出
- 六、那个意外的彩蛋:参数校验
- 七、常见误区
- 八、自检清单
- 附:延伸阅读
一、为什么这一步故意不接 LLM
上一篇反复强调 MCP 和 function calling 是两层正交的东西。但光讲概念没用,很多人看完还是会混。
所以我在这篇做了一个刻意的选择:整个 Demo 不引入任何大模型。
是你自己决定要调 add,是你自己填了 a=3, b=4:
client.callTool(new McpSchema.CallToolRequest("add", Map.of("a", 3, "b", 4)));
代码里出现不了任何 claude、anthropic、prompt 字样。
为什么这么做? 因为只要 LLM 在场,你就很难分清哪些行为是协议带来的、哪些是模型带来的。把它拿掉,MCP 的真身就露出来了——一个纯粹的、语言无关的、程序调用工具的协议。
等下一篇把 Claude 接进来,你会清楚地看到:所谓「接 LLM」,只是在这条链路外面套了一层循环而已。
二、MCP Server 的三个要素
一个 MCP Server 本质上只回答两个问题:
「我有什么工具?」 和 「怎么用这些工具?」
对应到代码就是三样东西:
┌──────────────────────────────────────────────────────────┐
│ ① Tool(工具声明) │
│ 名字 + 描述 + 入参的 JSON Schema │
│ → 回答「我有什么工具」 │
├──────────────────────────────────────────────────────────┤
│ ② Handler(处理器) │
│ 收到参数,干活,返回结果 │
│ → 回答「怎么用」 │
├──────────────────────────────────────────────────────────┤
│ ③ Capabilities(能力声明) │
│ 告诉客户端「我提供 tools / resources / prompts」 │
│ → 决定客户端会不会来问 │
└──────────────────────────────────────────────────────────┘
打个比方,这就是一家餐厅的菜单:
- Tool = 菜名 + 配料表
- Description = 菜品介绍(顾客靠它决定点不点)
- Handler = 后厨
- Capabilities = 门口那块「本店供应炒菜」的招牌
2.1 其中 Description 比你想的重要
// 好的写法
.description("计算两个整数之和。当用户询问加法运算时使用。")
// 差的写法
.description("加法工具")
在这个 Demo 里,两者的区别还不明显——因为是你自己决定调什么,描述写不写无所谓。
但到了下一篇,模型完全靠这句话判断「什么时候该调这个工具」。它看不到你的代码,只有这一句话。
所以写描述有个固定公式:
description = 「做什么」+ 「什么时候用」
现在养成习惯,下一篇能省很多调试时间。
三、写一个 Server
完整代码在配套工程里,这里挑关键的结构讲。
3.1 定义工具
Map<String, Object> addSchema = Map.of(
"type", "object",
"properties", Map.of(
"a", Map.of("type", "integer", "description", "第一个加数"),
"b", Map.of("type", "integer", "description", "第二个加数")
),
"required", List.of("a", "b")
);
McpSchema.Tool addTool = McpSchema.Tool.builder("add", addSchema)
.description("计算两个整数之和。当用户询问加法运算时使用。")
.build();
注意这里是一份普普通通的 JSON Schema。 MCP 没有发明新的 schema 语言,直接复用了 JSON Schema 标准。
这一点值得记一下——下一篇把 MCP 工具转成 Claude 工具之所以只要 30 行,就是因为两边用的是同一个东西。
3.2 组装并启动
McpSyncServer server = McpServer.sync(lifecycle.provider())
.serverInfo("calc-server", "1.0.0")
.capabilities(McpServerCapabilities.builder().tools(true).build())
.toolCall(addTool, (exchange, request) -> {
int a = ((Number) request.arguments().get("a")).intValue();
int b = ((Number) request.arguments().get("b")).intValue();
return McpSchema.CallToolResult.builder()
.addTextContent(a + " + " + b + " = " + (a + b))
.build();
})
.build();
三个值得停下来看的点:
① capabilities 必须声明 tools(true)
不声明的话,客户端根本不会来调 tools/list。这是能力声明的直接后果。
② handler 的 lambda 有两个参数
.toolCall(addTool, (exchange, request) -> { ... })
request—— 含name()和arguments(),就是调用方填的参数exchange—— 用来跟客户端做请求范围内的交互(比如发notifications/progress汇报进度)。这个 Demo 用不到,但它是 MCP 比「普通函数注册」强的地方
【2026 规范变更】这里原本举的例子是 sampling(Server 反向请求 Client 调 LLM),已经不适用了。
从 2026-07-28 版规范起,MCP 明确 Servers MUST NOT initiate JSON-RPC requests —— 服务端不能主动发请求。需要用户补充输入时,走的是多轮往返:服务端返回
InputRequiredResult,客户端带上inputResponses重试同一个请求。而 sampling 本身也已在同版本规范中废弃(SEP-2577)。详见第一篇 4.1 节。
③ 参数取出来是 Number 不是 int
int a = ((Number) request.arguments().get("a")).intValue();
因为 arguments() 返回 Map<String, Object>,JSON 数字反序列化后是 Integer 或 Long。直接强转 (int) 会 ClassCastException。这个小坑第一次写必踩。
3.3 一个不能省的收尾
lifecycle.awaitClientDisconnect();
server.closeGracefully();
不要写成 new CountDownLatch(1).await()。
官方示例大多这么写,但它有个后果:stdio server 在 stdin 关闭后不会自己退出。客户端崩了、被 kill 了、忘了调 closeGracefully(),server 进程就会一直赖在任务管理器里。跑几十次就攒一堆。
正确做法是自己感知 stdin EOF——把 System.in 包一层,读到 -1 就释放 latch。完整实现在配套工程的 core/StdioLifecycle.java,第五篇会详细讲。
四、写一个 Client
Client 就更短了,五步:
// ① 描述「怎么启动 server」
ServerParameters serverParams = ServerParameters.builder("java")
.args("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8",
"-classpath", System.getProperty("java.class.path"),
"com.example.mcp.step1.CalculatorServer")
.build();
// ② 建立连接
McpSyncClient client = McpClient.sync(new StdioClientTransport(serverParams, jsonMapper))
.requestTimeout(Duration.ofSeconds(30))
.build();
// ③ 初始化
// ⚠️ 这个调用遵循的是「有握手」的那版规范。见下面的说明。
client.initialize();
// ④ 发现工具
McpSchema.ListToolsResult tools = client.listTools();
// ⑤ 调用工具
McpSchema.CallToolResult r = client.callTool(
new McpSchema.CallToolRequest("add", Map.of("a", 3, "b", 4)));
// 别忘了
client.closeGracefully();
⚠️ 关于
client.initialize():规范已经删了它,但 SDK 还没跟上。
2026-07-28版规范删除了initialize握手,改成每个请求在_meta里自带协议版本和能力(原因见《MCP 实战(一)》的 4.1 节)。但官方 Java SDK 目前仍然要求先调
initialize()——它的实现还停在「有握手」的那版规范上。所以这段代码现在是对的、能跑的,只是它反映的是 SDK 的现状,不是规范的最新状态。等 SDK 跟进之后,这一步会变成
server/discover或者干脆消失。这个时间差本身值得记住:规范领先、实现滞后是常态。 读到「规范这么说」时,先确认你手上的 SDK 是不是也这么做了。
4.1 第 ① 步里藏着 MCP 最值钱的设计
注意 ServerParameters 描述的是**「怎么启动一个进程」**,而不是「调用哪个类」:
ServerParameters.builder("java").args(..., "com.example.mcp.step1.CalculatorServer")
换成 Python 写的 server:
ServerParameters.builder("python").args("-m", "my_server")
换成 Node:
ServerParameters.builder("npx").args("-y", "@some/mcp-server")
客户端完全不需要知道对面是什么语言写的。 这是整个协议最值钱的地方,也是第一篇那张「Python / Java / Node 三种 Server」架构图落到代码上的样子。
4.2 第 ④ 步返回的东西很关键
client.listTools() 返回的 name / description / inputSchema,和 Server 端声明的那份一模一样,原样传过来的。
现在它的用途是「让调用方知道能干什么」。
下一篇你会看到,它恰好就是喂给 Claude 的工具清单——同一个返回值,两个用途。
五、跑起来看输出
mvn -q compile exec:exec -DmainClass=com.example.mcp.step2.ManualClient
已连接: calc-server v1.0.0
server 能力: ServerCapabilities[..., tools=ToolCapabilities[listChanged=true]]
=== tools/list ===
• add
描述 : 计算两个整数之和。当用户询问加法运算时使用。
schema: {required=[a, b], type=object, properties={...}}
• get_weather
描述 : 查询指定城市的当前天气。当用户询问天气时使用。
schema: {required=[city], type=object, properties={...}}
=== tools/call ===
add(3, 4) -> 3 + 4 = 7
get_weather(北京) -> 北京:多云,11°C
=== 故意传错参数(a 传字符串)===
isError = true
content = Tool (add) input validation failed: ... [/a: 已找到 string,必须是 integer]
看到 3 + 4 = 7 就说明整条链路通了。
注意这里面没有一处调用大模型。
六、那个意外的彩蛋:参数校验
最后一段是我故意加的。调用 add 时把 a 传成字符串:
client.callTool(new McpSchema.CallToolRequest("add", Map.of("a", "不是数字", "b", 1)));
结果是:
isError = true
content = Tool (add) input validation failed: Validation failed:
JSON schema validation errors: [/a: 已找到 string,必须是 integer]
说明 SDK 默认会按 inputSchema 校验入参,不合法直接返回 isError=true,根本不进 handler。
这是一道免费的安全网,我一开始没意识到它存在。想关掉(不推荐)可以 .validateToolInputs(false)。
为什么说它「免费」且重要? 因为下一篇接上模型之后,参数就变成模型生成的了,可能是任何东西——多余字段、类型不对、缺必填项。有这道校验,你至少不用自己写参数校验逻辑。
这个设计其实很符合 MCP 的整体思路:约定好的东西,协议层帮你兜住。
七、常见误区
误区一:以为 MCP 就是「让 AI 调工具」。
不是。这个 Demo 就是反例——没有任何 AI,照样跑通了完整的 MCP 链路。
准确的说法是:MCP 定义了工具怎么被发现和调用,至于「谁来调用」是另一回事。可以是模型,也可以是人写的程序。
误区二:直连数据库、直接 new 一个对象,绕过协议。
在单个进程里,你确实可以直接调方法,没必要绕 MCP。MCP 的价值出现在边界上:跨语言、跨进程、跨团队、跨模型的时候。
如果你的工具和 AI 应用永远在同一个 Java 进程里,且永远只用 Claude,那 MCP 对你收益不大。但只要有一项会变,它就值了。
误区三:把 ServerParameters 当成「类名配置」。
它描述的是启动命令。这是有意的设计——为了让协议语言无关。理解这一点,你才能想明白「为什么 Python 写的 server 能被 Java 客户端调用」。
误区四:System.out.println 打日志。
在普通 Java 程序里这是最自然的调试手段。在 stdio MCP Server 里,这是最容易毁掉整个程序的写法。stdout 是协议通道,不是你的输出通道。
八、自检清单
- 能说清
Tool/Handler/Capabilities各自负责什么吗? -
description的写法公式是什么?为什么它比想象中重要? -
request.arguments()取出来为什么要先转Number? -
ServerParameters为什么描述的是「启动命令」而不是「类名」? - 为什么 Server 端要用
awaitClientDisconnect()而不是永久阻塞? - 这个 Demo 里,SDK 帮我们做了哪件我们没写的事?(提示:参数校验)
附:延伸阅读
相关资料
| 资源 | 说明 |
|---|---|
| modelcontextprotocol/java-sdk | 本系列用的 SDK,docs/server.md 和 docs/client.md 值得通读 |
| JSON-RPC 2.0 规范 | 一页纸,读完你对整条链路就没有盲区了 |
| JSON Schema 入门 | MCP 工具入参用的就是它,不用另学一套 |
| MCP Inspector | 官方可视化调试工具,npx @modelcontextprotocol/inspector 就能起,比自己写 Client 调试快得多 |
关联笔记
- MCP 实战(一):MCP 是什么 —— 概念基础,尤其是和 function calling 的区别
- RAG 实战(一):RAG 是什么 —— 同样在给模型「补东西」,一个补知识,一个补能力
最后更新:2026-10-06