MCP 实战(二):手写一个 MCP Server,故意不接大模型

目录


一、为什么这一步故意不接 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 调试快得多

关联笔记


最后更新:2026-10-06