三个角色: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 秒实验
- 把演示里的三个 Server 改成两个,其中一个带两个 search 工具。合并后名字会变成什么。
- 数一数你正在用的那个客户端里配了几个 MCP Server。想想它开了几条连接。
- 如果你要写一个 Server,工具名该叫什么?拿 search 举例,写出三个不撞名的名字。
常见误区
- 「Client 就是那个 Server 程序。」Client 是连接,Server 才是程序。 三条连接对应三个 Client,可能只对应两个 Server 进程。
- 「握手整个宿主做一次就行。」每条连接各握一次。所以新加一个 Server,只有那一条连接需要重新握手。
- 「工具名随便起,宿主会去重。」去重方式各家不同,有的加前缀有的加序号。自己带上领域前缀最稳。
自测三问(点开对答案)
1. 一个宿主连了三个 Server,握手要做几次? —— 三次,每条连接各一次。Client 是连接不是进程,连接是各自建立的。 2. 两个 Server 都有 search,会发生什么? —— 合并后重名,模型可能挑错。常见改法是加服务名前缀,合成 weather__search 这种形式。 3. 为什么说拆 Server 的依据不只是功能? —— 还看故障域。写在一个 Server 里的工具会一起挂,分开写就只挂一条连接。角色和连接说清了,还剩最实的一层:这些报文到底长什么样、底下跑在什么上面。 下一节把两种传输、四种报文、五个错误码一次讲完。
- ☐ (本篇完,下一篇 2.3 MCP 通信机制与协议)