MCP 实战(一):MCP 是什么,以及它和 Function Calling 到底是什么关系

目录


一、先说一个所有人都遇到过的问题

假设你手上有一套内部系统:数据库、文件服务、几个内部 API。现在想让 AI 能用它们。

你写了一套工具定义,接到 Claude 上。跑通了。

然后老板说,我们也要接 GPT。于是你把那套工具定义改一改,又接了一遍。

再然后说本地也要跑一个开源模型。第三遍。

三个模型,五个工具,十五份胶水代码。 而且每加一个新工具,三个地方都要改。

这不是什么新鲜困境。它长得和十几年前的编辑器插件问题一模一样:VS Code 要为每个语言写一套补全逻辑,Eclipse 也要写一套,Vim 还要写一套。

后来 LSP(Language Server Protocol)解决了它——语言方实现一个 Server,编辑器方实现一个 Client,中间用统一协议说话。

MCP 是同一套思路,只不过对象从「编辑器 × 语言」换成了「模型 × 工具」。


二、MCP 的答案

MCP 把那个矩阵拆成两半:

        工具1   工具2   工具3   工具4   工具5
Claude   ✍️     ✍️      ✍️      ✍️      ✍️
GPT      ✍️     ✍️      ✍️      ✍️      ✍️
本地模型  ✍️     ✍️      ✍️      ✍️      ✍️

乘法变加法:工具方实现一次 MCP Server,模型方实现一次 MCP Client。3 + 5 = 8,而且新增工具只影响工具侧。

顺带解决了一个更隐蔽的问题:语言绑定。

一个 MCP Server 是 Python 写的还是 Java 写的,Client 完全不需要知道。它只关心两件事——用什么命令能把这个进程拉起来,以及怎么在它的标准输入输出上说话。

┌──────────────────────────────────────────────────────┐
│                    Host(你的应用)                    │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐          │
│  │ Client A │   │ Client B │   │ Client C │          │
│  └────┬─────┘   └────┬─────┘   └────┬─────┘          │
└───────┼──────────────┼──────────────┼────────────────┘
        │              │              │
   ┌────▼────┐    ┌────▼────┐    ┌────▼────┐
   │Server 1 │    │Server 2 │    │Server 3 │
   │ Python  │    │  Java   │    │  Node   │
   └─────────┘    └─────────┘    └─────────┘

⚠️ 有个细节新手经常画错:Client 和 Server 是 1:1 的。 是「一个 Host 持有多个 Client」,不是「一个 Client 连多个 Server」。


三、MCP 和 Function Calling 是什么关系

这是我见过被问得最多、也答错最多的一个问题。

先说结论:它们不是竞品,是两层,互相正交。

┌─────────────────────────────────────────────────────────┐
│  Function Calling(模型侧)                              │
│  模型怎么表达「我想调用某个工具」                          │
│  比如:Claude 返回 stop_reason="tool_use" + 一个 tool_use 块 │
└─────────────────────────────────────────────────────────┘
                          ↕ 正交
┌─────────────────────────────────────────────────────────┐
│  MCP(工具侧)                                           │
│  工具怎么被发现、怎么被调用                                │
│  比如:tools/list 列出有什么,tools/call 执行             │
└─────────────────────────────────────────────────────────┘

为什么容易混? 因为两者经常一起出现,而且都用 JSON Schema 描述参数。看起来就像同一个东西的两种叫法。

三个能帮你记牢的事实:

事实说明
MCP Server 可以完全不用 LLM任何普通程序都能调它。本系列第二篇就故意这么写
Function calling 可以调非 MCP 的工具你手写几十行 JSON Schema 就是,根本不涉及 MCP
两者需要一层薄胶水才能连起来把 MCP 工具转成 Claude 工具格式。第三篇会写,只有 30 行

一个精准的区分方法:

如果你在写提示词、工具定义、解析 tool_use —— 那是 function calling。 如果你在写启动子进程、收发 JSON-RPC、列工具清单 —— 那是 MCP。

本系列的 Demo 1 故意不接任何大模型,就是为了让这条分界线变得显而易见。等你亲眼看到「一个没有任何 AI 的程序也能调用 MCP 工具」,这个概念就再也混不了了。


四、协议长什么样

MCP 没有重新发明 RPC。底层就是 JSON-RPC 2.0,加一套约定好的方法名。

一次工具调用在线上是这样:

// 客户端 → 服务端
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": { "a": 3, "b": 4 }
  }
}

// 服务端 → 客户端
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [{ "type": "text", "text": "3 + 4 = 7" }]
  }
}

常用的方法名就四个:

方法作用
server/discover问服务端支持哪些版本、有哪些能力、它是谁
tools/list列出所有工具
tools/call调用某个工具
subscriptions/listen订阅变更通知(长活响应流)

⚠️ 2026-07-28 规范变更:早期版本的 initialize 握手和 notifications/initialized 通知都已删除;resources/subscribe 也并入了 subscriptions/listen。原因见 4.1。

4.1 从「握手」到「每个请求自带」

早先的模型是一次握手:客户端发 initialize,双方各自告诉对方「我支持什么」,谈定一次,之后都按这次谈的结果来。

2026-07-28 把这个握手删掉了,但”交换能力声明”的需求还在。 它只是换了个地方——每个请求都自带。

删掉握手的理由不是”握手不好”,而是握手要求服务端记住客户端是谁,这就引入了状态。而状态会挡住水平扩展:客户端一旦绑在某个服务端实例上,普通负载均衡就没法随便转发了。

换个角度说,规范现在要求:

all the information needed to process a request is contained in the request itself.

每个请求都是自包含的,服务端不需要、也不允许从上一个请求推断任何东西。

这里先纠正一个流传很广的说法 —— 很多人(包括本专栏早期版本)会说「MCP 是双向协议,Server 也能反过来请求 Client」。这句话在现行规范里已经不成立了。

【2026 规范变更】 从 2026-07-28 版规范起,MCP 明确了所有交互必须由客户端发起:

Servers MUST NOT initiate JSON-RPC requests, and clients do not send JSON-RPC responses.

也就是说,MCP 现在是客户端单向发起、双方声明能力的模型,不是双向协议。

那服务端需要「问用户一个问题」怎么办

用多轮往返(Multi Round-Trip Requests)把一次双向交互拆成两次单向:

Client  →  Server    request (id: 1)
Client  ←  Server    InputRequiredResult(我还需要这些输入)
Client  →  Server    request (id: 2, 原参数 + inputResponses)
Client  ←  Server    response

服务端不是「主动发起一个请求」,而是「回答:这个请求我还完不成」,然后客户端带着补充信息重试。需要用户输入(Elicitation)和需要客户端提供根目录(Roots)都走这条路。

为什么非要这么绕?因为传输层不支持。

stdin/stdout 是单向管道,HTTP 是请求-响应。想让服务端主动发起请求,就得让传输层支持双向或者长期保持连接。MCP 选择不这么做,而是用协议层的约定去绕开传输层的限制。

这个思路你会在很多地方见到:

HTTP 想服务器主动推送   →  长轮询 → SSE → WebSocket
MCP  想服务端主动提问   →  多轮往返(客户端重试)

同一个约束,不同的解法。 记住这一点,后面理解 MCP 的各种设计取舍会顺很多。

关于 Sampling:它被废弃了

Sampling 曾经是「服务端主动发起请求」最典型的应用 —— Server 说「帮我调一次 LLM」,这样 Server 不用内置模型和 API Key。

这个能力已在 2026-07-28 版规范中废弃(SEP-2577),官方建议新实现不要采用,已有实现迁移到直连 LLM Provider API。按规范的生命周期政策,它会在规范里至少保留 12 个月再考虑移除。

这里保留这段说明不是让你去用它,而是它被废弃的原因很值得理解:Sampling 是「服务端发起请求」这个模型最典型的应用,而这个模型本身被取消了。

那能力声明现在长什么样

需求没变,载体变了——从”握一次手”变成”每次都带”。

客户端在每个请求的 _meta 里带上:自己按哪个版本说话、有什么能力。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": { "a": 3, "b": 4 },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

少了必填字段,服务端会直接把它判成畸形请求(-32602 Invalid params)。这就是”每次都带”的代价——多写一点,换来请求自包含。

服务端那边,能力声明同样要显式写:

.capabilities(McpServerCapabilities.builder().tools(true).build())

不声明的话,客户端依然不会来调 tools/list——这条没变。

一句话总结:握手没了,能力声明的纪律还在,只是从”一次谈定”变成了”每次重申”。

4.2 传输方式怎么选

传输场景怎么工作
STDIO本地子进程客户端 fork 出 server,用 stdin/stdout 通信
Streamable HTTP远程服务单个 HTTP endpoint,用 POST 发消息;需要流式返回时可选开启 SSE

判断标准很简单:server 和 host 在同一台机器上 → STDIO;server 独立部署 → Streamable HTTP。

关于 Streamable HTTP 有两个常被搞混的点:

  1. 它不是双向的 —— 客户端 POST 一个请求,服务端可以立刻回一个 JSON,也可以回一条 request 作用域的 SSE 流(用来先推进度通知、最后推结果)。「流」是挂在这一个请求上的,不是一条常驻连接。
  2. SSE 是可选的 —— 简单场景直接回 JSON 就行,只有需要流式才用 SSE。

(历史上的 HTTP+SSE 传输已废弃,它用的是「GET 开一条长连接 + POST 发消息」的双端点模型,新项目不要用。)

本系列用 STDIO,因为它零网络配置、天然安全、部署简单。


五、环境准备

JDK 17+            MCP Java SDK 要求 17 以上,推荐 21
Maven 3.9+
IDEA
ANTHROPIC_API_KEY  第三篇开始需要,前两篇不需要

依赖就三块:

<properties>
  <mcp.sdk.version>2.0.0</mcp.sdk.version>
  <anthropic.sdk.version>2.34.0</anthropic.sdk.version>
</properties>

<dependencyManagement>
  <dependency>
    <groupId>io.modelcontextprotocol.sdk</groupId>
    <artifactId>mcp-bom</artifactId>
    <version>${mcp.sdk.version}</version>
    <type>pom</type><scope>import</scope>
  </dependency>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>io.modelcontextprotocol.sdk</groupId>
    <artifactId>mcp</artifactId>
  </dependency>
  <dependency>
    <groupId>com.anthropic</groupId>
    <artifactId>anthropic-java</artifactId>
    <version>${anthropic.sdk.version}</version>
  </dependency>
</dependencies>

mcp 是个便捷包,等于 mcp-core + Jackson3 JSON 实现 + 传输实现。想精细控制可以只引 mcp-core。

验证环境:

mvn -q clean compile

编译过了就行。真正能不能跑,第二篇跑通第一个 Demo 就知道了。


六、这个专栏怎么读

四步递进,每一步解决一个具体的认知问题:

篇做什么要建立的认知
二手写最小 MCP 链路(不接 LLM)MCP 是「程序调工具」的协议
三把 Claude 接进来agent 本质就是个 while 循环
四做一个只读数据库 MCP模型看不见你的库,描述决定它的表现
五加三道安全墙安全必须做在模型管不到的地方

建议的顺序是:读一篇,写一遍,跑通,再读下一篇。

代码我放在了配套工程里,src/ 下的文件是空的,需要照着文档自己敲。这不是折腾——这套东西的坑很具体,抄一遍会全错过。


七、五个会浪费你几小时的坑

先列出来,后面每一步踩到时能对得上号。

① stdout 被污染 —— 头号杀手

症状:客户端报 JSON 解析错误,或者干脆卡住不动。

原因:STDIO 传输把 stdout 当成独占的 JSON-RPC 通道。任何打到 stdout 的东西都会污染协议流:

  • System.out.println("debug") —— 最常见
  • 日志框架默认输出到 stdout(Logback 默认就是)
  • 某个依赖库自己 print 东西

解法:日志统一走 stderr。

② Windows 中文乱码

症状:文件读写正常,但 System.out.println 出来的中文全是乱码。

原因:Java 18+ 已经把 file.encoding 默认成 UTF-8 了,但 System.out 走的是 stdout.encoding,它在 Windows 上仍然跟随控制台代码页(中文系统是 GBK)。

解法:三个参数缺一不可。

-Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8

注意:fork 子进程时也要带上,否则子进程的日志照样乱码。

③ 孤儿进程

症状:程序跑完,任务管理器里一堆 java 进程不走。

原因:官方 SDK 的 stdio server 在 stdin 关闭后不会自己退出。官方示例通常写成 new CountDownLatch(1).await()(永久阻塞),客户端一崩 server 就变孤儿。

解法:Server 端自己感知 stdin EOF。第四篇会给出完整实现。

④ -Dexec.args 不能用

症状:参数被当成主类名,报 ClassNotFoundException。

原因:exec-maven-plugin 会把 exec.args 插到主类名之前。

解法:定义一个自定义属性,在 <arguments> 里显式放在主类名后面:

<argument>${mainClass}</argument>
<argument>${appArgs}</argument>

用法就从 -Dexec.args="..." 变成 -DappArgs="..."。

⑤ 忘了 closeGracefully()

症状:同 ③,但原因在客户端侧。

解法:try/finally 兜底,别指望自己记得手动调。


八、自检清单

读到这里,你应该能回答:

  • MCP 解决的核心问题是什么?(提示:想想 N×M)
  • MCP 和 function calling 的区别,能用一句话说清吗?
  • 一个 Host 可以连几个 Server?Client 和 Server 是什么比例?
  • MCP 底层用的是什么通信协议?
  • STDIO 和 Streamable HTTP 分别适合什么场景?
  • initialize 握手被删除后,双方「交换能力声明」这件事改在哪里做了?
  • stdio server 最容易踩的坑是什么?

附:延伸阅读

相关资料

资源说明
Model Context Protocol 官方文档协议规范、概念说明、各语言 SDK 索引
modelcontextprotocol/java-sdk本系列使用的 Java SDK,README 里有完整的 API 示例
JSON-RPC 2.0 规范MCP 的底层协议,只有一页,十分钟能读完
Claude Tool Use 文档Function calling 侧的定义与循环模式
Language Server Protocol同一套「标准化接口」思路在编辑器领域的先例,对照着看很有意思

关联笔记


最后更新:2026-10-05