🔌 第2章 MCP核心原理
📄 一句话概括

三个角色:Host 管会话,Client 管连接,Server 管能力。 最容易记错的一条:Client 不是进程,是连接。一个宿主里能开五个客户端,各连各的。 所以握手是每条连接各做一次,不是整个宿主做一次。

📝 本章和前面几章的关系

上一节讲清了一个 Server 能给什么,这一节讲这些东西怎么被接管。 三个角色里,Client 最容易被想成「一个程序」,其实它是「一条连接」。 想错这一层,后面所有关于多 Server、撞名、故障隔离的推理都会错。

一、三个角色,三种职责

Host 是你打开的那个应用——编辑器、桌面客户端、聊天框。 它管会话、管用户、管把好几个 Server 的工具合并成一张表交给模型。 Client 是 Host 内部的一个连接器。一条连接一个 Client,负责握手、收发、把回来的结果交回 Host。 Server 是能力提供方。它只管两件事:我有哪些工具、调用时怎么算。 它不知道用户是谁,也不知道这次对话在聊什么。

这三者的关系可以画成三层:一个 Host 里挂 N 个 Client,每个 Client 连一个 Server。

二、Client 不是进程,是连接

这一条值得单独说,因为几乎所有人都先想错一次。 「我配了三个 MCP Server」——不是三个程序在跟你说话,是三条连接。 三个都连着的时候,Host 里有三个 Client。 理解这一点,下面三件事立刻顺了:握手要做三次(每条连接一次)、工具表要合并(三张表并成一张)、 一个 Server 挂了另外两个照常。

演示里三个 Server 连上,输出里就是三次 initialize。【不是握手一次然后复用,是各握各的。】 顺带解释一个常见困惑:为什么改了某个 Server 的配置要「重启宿主」? 因为配置是在建连接时读的,连接已经建好,不会自己去读第二遍。

三、合并工具表时会撞名

三条连接各给一张工具表,Host 把它们并成一张交给模型。合并时最容易出的事是重名。 演示里两个 Server 各有一个 search:一个查天气、一个搜地点,参数一个要 city 一个要 keyword, 完全不是一回事。 模型看到两个同名工具会怎么办?它会挑一个,而且经常挑错。【这不是模型的错,是宿主没把名字整理好。】

三种改法:加服务名前缀、加序号、改服务端自己的名字。 演示里选第一种,合成 weather__search 和 poi__search。 两个下划线是约定俗成的分隔符,别用一个——一个下划线可能本来就是工具名的一部分,拆的时候分不清。 自己写 Server 时的对策更简单:给工具名带上领域前缀,叫 weather_search 而不是 search。 别人的宿主未必替你整理。

四、故障被关在一条连接里

演示第四段让一个 Server 故意抛错,回的是 -32603 Internal error,data 写上游超时。 另外两条连接照常返回。故障被关在一条连接里,没有蔓延。 这是「一个 Client 一个 Server」的实际好处,不是设计上的洁癖。

反过来看:如果三个工具都写在一个 Server 里,它一挂,三个全没。 所以拆 Server 的依据不只是「功能像不像」,还有「坏了要不要一起坏」。 要注意的是,Host 收到 -32603 之后做什么,协议没规定。 好的宿主会把这一段错误原样告诉模型,让它换个办法;差的宿主直接不吭声。

五、Server 那边其实只有一张函数表

从 Server 的角度看,它要做的事少得可怜:维护一张表(名字、描述、参数结构),收到调用就去查表、 校验参数、执行函数、把结果包成 content。 它不解析用户意图,不维护对话历史,不决定该调哪个工具。这些全在 Host 那边。 所以第 6 章那个自己写的 Server 只有一百多行,其中一半还是注释和常量。 不是因为写得简陋,是因为它本来就只需要做这些。

反过来,这也解释了为什么 Server 很难做「聪明」:它拿不到上下文。 想让工具选得准,只能靠描述写清楚,不能靠它猜。

六、动手 Demo:一个宿主,三个客户端,三条连接

起三个 Server,让一个宿主把它们都连上,看三件事:握手做了几次、工具表怎么合并、 其中一个挂了会怎样。 三个 Server 都是真进程,连接都是真管道。

cons = []
for name, script in [('trave-weather', 'weather.py'),
                     ('trave-poi', 'poi.py'),
                     ('trave-note', 'note.py')]:
    c = Stdio(script)
    ver = c.init()['serverInfo']['version']    # 每条连接各握一次
    tools = c.tools()
    cons.append((name, c, tools))
    print(name, ver, len(tools))

# 合并工具表:先数一遍谁跟谁撞名
names = {}
for name, c, tools in cons:
    for t in tools:
        names.setdefault(t['name'], []).append(name)
for tname, owners in names.items():
    flag = '重名' if len(owners) > 1 else '    '
    print(flag, tname, owners)

# 一个挂了,另外两条照常
print(cons[0][1].call('forecast', {'city': '杭州', 'day': '明天'}))
print(cons[1][1].call('search', {'keyword': '博物馆'}))

实跑输出:

--------------------------------------------------------------------
一个宿主,三个客户端,三条连接
--------------------------------------------------------------------


  ① 三个角色各管什么
  --------------------------------------------------------------------

| 角色 | 是谁 | 管什么 | 有几个 |
|---|---|---|---|
| Host 宿主 | 你用的那个应用 | 会话、用户、把工具合并给模型 | 1 个 |
| Client 客户端 | Host 里的连接器 | 一条连接、一次握手、收发报文 | 每个 Server 一个 |
| Server 服务端 | 能力提供方 | 工具表、执行、返回 | N 个 |

  【Client 不是进程,是连接。】一个 Host 里可以有五个 Client,各连各的。

  ② 真连三个 Server
  --------------------------------------------------------------------

| Server | 版本 | 工具数 | 工具 |
|---|---|---:|---|
| trave-weather | 0.1.0 | 2 | search、forecast |
| trave-poi | 0.1.0 | 1 | search |
| trave-note | 0.1.0 | 1 | note.add |

  三个 Server,三次 initialize,三条连接。
  【握手是每条连接各做一次,不是整个宿主做一次。】

  ③ 合并之后撞名了
  --------------------------------------------------------------------
  合并后一共 4 个工具
  重名:search

trave-weather 和 trave-poi 各有一个 search,一个查天气、一个搜地点,
参数一个要 city 一个要 keyword,完全不是一回事。
  模型看到两个同名工具会怎么办?它会挑一个,然后经常挑错。
  【这不是模型的错,是宿主没把名字整理好。】

常见三种改法:

| 改法 | 合并后的名字 | 代价 |
|---|---|---|
| 加服务名前缀 | weather__search | 名字变长,占上下文 |
| 加序号 | search_2 | 没含义,模型分不清 |
| 改服务端自己 | search_by_keyword | 要动别人的 Server |

  选第一种。两个下划线是约定,别用一个——一个下划线可能是工具名自带的。

整理之后模型看到的是:
  weather__search        <- trave-weather
  forecast               <- trave-weather
  poi__search            <- trave-poi
  note.add               <- trave-note

四个名字互不相同,模型才选得对。

  ④ 一个 Server 挂了,别的还活
  --------------------------------------------------------------------
  挂掉的那个:code=-32603 Internal error,data=上游超时
  另一个 Server:{"text": "多云"}
  故障被关在一条连接里,另外两条照常。
  【这就是「一个 Client 一个 Server」的实际好处。】
  ! 反过来说:如果三个工具都写在一个 Server 里,它一挂,三个全没。

①第一张表只有三行,但它把三个角色的数量关系说清了:宿主 1 个、客户端每个 Server 一个、 Server N 个。 ②第二段是本节的关键证据:三个 Server,三条连接,三次握手。 如果 Client 是「宿主里的一个程序」,这里就该只握一次。 ③第三段撞名了。两个 search 撞在一起,模型无从选择。 演示里给的三种改法各有代价,加前缀最稳,因为不需要动别人的代码。 ④第四段是故障隔离。一个 Server 回 -32603,另外两个照常。 这也是「该不该拆 Server」的一个判据:会一起坏的东西放一起,不会的一律分开。 ⑤注意最后那句提醒:三个工具写在一个 Server 里,它一挂三个全没。拆分的依据不只是功能,还有故障域。

30 秒实验

  1. 把演示里的三个 Server 改成两个,其中一个带两个 search 工具。合并后名字会变成什么。
  2. 数一数你正在用的那个客户端里配了几个 MCP Server。想想它开了几条连接。
  3. 如果你要写一个 Server,工具名该叫什么?拿 search 举例,写出三个不撞名的名字。

常见误区

  1. 「Client 就是那个 Server 程序。」Client 是连接,Server 才是程序。 三条连接对应三个 Client,可能只对应两个 Server 进程。
  2. 「握手整个宿主做一次就行。」每条连接各握一次。所以新加一个 Server,只有那一条连接需要重新握手。
  3. 「工具名随便起,宿主会去重。」去重方式各家不同,有的加前缀有的加序号。自己带上领域前缀最稳。
自测三问(点开对答案) 1. 一个宿主连了三个 Server,握手要做几次? —— 三次,每条连接各一次。Client 是连接不是进程,连接是各自建立的。 2. 两个 Server 都有 search,会发生什么? —— 合并后重名,模型可能挑错。常见改法是加服务名前缀,合成 weather__search 这种形式。 3. 为什么说拆 Server 的依据不只是功能? —— 还看故障域。写在一个 Server 里的工具会一起挂,分开写就只挂一条连接。

角色和连接说清了,还剩最实的一层:这些报文到底长什么样、底下跑在什么上面。 下一节把两种传输、四种报文、五个错误码一次讲完。

相关笔记