服务旧版客户端
MCP 有两个协议时代:initialize 握手时代(到规范版本 2025-11-25 为止)和现代时代(2026-07-28)。协议版本 专门讲这一划分本身。
本页讲的是这一划分的服务器端,答案一句话就能说完:你已经部署的 streamable_http_app() 同时服务两者。
SDK 按 MCP-Protocol-Version 头路由每个请求。声明 2026-07-28 的请求交给现代一侧处理。声明握手时代版本的请求,或者根本不带这个头的请求(2026 之前的客户端的 initialize 就是这样到达的),则走这些客户端期望的传输方式:initialize 握手、会话,一应俱全。这一切按请求发生,在你的代码之前,就在这一个应用上。
所以旧版客户端不是你要专门为之构建什么的对象,而是会连接到你已经写好的服务器的东西。什么都不用配置。
Note
真的什么都没有。没有 legacy= 选项,没有版本白名单,也没有办法拒绝或禁用某个时代:streamable_http_app() 上没有,run() 上没有,会话管理器上也没有。两个时代始终开启。那个签名里最接近按时代开关的东西是 stateless_http,本页大部分内容都在讲它。
一个处理函数,两个时代
下面是一个需要向用户提问的工具:
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
"""Resolver: ask the user how many copies to put aside."""
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
"""Reserve copies of a book, asking the user how many."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
reserve 需要一样模型没有提供的东西:要几本。工具用 Annotated[..., Resolve(ask_quantity)] 来声明这一点(详见 依赖)。reserve 里没有任何地方提到版本、检查能力或做分支。
通过 HTTP 提供服务,下面是两个时代的客户端分别调用它:
uv run mcp run server.py --transport streamable-http
import anyio
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"copies": 2})
async def main() -> None:
async with (
Client("http://localhost:8000/mcp", mode="legacy", elicitation_callback=answer) as legacy,
Client("http://localhost:8000/mcp", elicitation_callback=answer) as modern,
):
for client in (legacy, modern):
result = await client.call_tool("reserve", {"title": "Dune"})
print(client.protocol_version, result.structured_content)
if __name__ == "__main__":
anyio.run(main)
两个客户端同时打开,连的是同一个正在运行的服务器。mode="legacy" 会执行 initialize 握手:这正是 2026 之前的客户端打开的那种连接。另一个取默认值,落在 2026-07-28 上。在第二个终端运行 python client.py:
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
同一个服务器,同一个处理函数,同一个答案。整个功能就是这样。
值得停下来看看它是怎么做到的,因为这两个客户端是在两条完全不同的线路上被问到同一个问题的。2026-07-28 连接没有供服务器发送请求的通道,所以 Resolve 把问题放在工具结果里返回,客户端带着答案重试了这次调用(多轮往返(multi-round-trip)请求)。2025-11-25 连接没有这种机制;在那里,Resolve 在调用中途发出一个实时的 elicitation/create 请求并等待。两种你都没写。Resolve 读取连接协商出的版本并做选择;无论哪种,工具函数体看到的都是一个 AcceptedElicitation。
Tip
这种跨时代可移植性正是应该基于 Resolve 这个 API 来构建的原因。它的前辈 ctx.elicit()(征询(elicitation))永远只发送 elicitation/create,所以永远只在旧版连接上有效。在 2026-07-28 连接上这个调用会失败。如果某个工具还在用它,修复办法就是上面看到的那样,而不是加版本检查。
旧版会话的代价
路由是免费的,会话不是。
2026-07-28 连接是无会话的:每个请求各自独立,现代一侧从不签发 Mcp-Session-Id。旧版连接正好相反。2026 之前的客户端一发送 initialize,SDK 就会生成一个 Mcp-Session-Id,在响应头里返回,并在它背后保留一条活的记录,供该客户端之后的请求查找:协商出的版本、打开的流、一个驱动会话的后台任务。
这条记录就是一个普通的进程内 dict。没有分布式会话存储,也没办法接入一个。
只有一个 worker 时这一点看不出来。有两个时,它就是全部问题所在:一个带着 Mcp-Session-Id 的请求落到没有生成它的 worker 上,在那个 dict 里什么也找不到,得到的回答是 404(Session not found),而不是工具结果。所以一旦运行多于一个 worker,旧版客户端就需要粘性路由:会话里的每个请求都必须到达发起这个会话的那个进程。现代客户端从不需要;它们没有会话可粘。部署与扩展 讲了粘性以及运行多个实例的其他一切。
Warning
event_store= 看起来像是解决办法,其实不是。它是可恢复性(向重连到同一个会话的客户端重放错过的 SSE 事件),不是会话存储。它永远不会让一个会话能从另一个进程访问到。
会话生存期与上限
旧版会话不会永远存活,一个进程也不会持有无限多个会话。有两个设置控制这一点。两者都是 run()、streamable_http_app() 和 Server.streamable_http_app() 上的关键字参数。现代(2026-07-28)连接和 stateless_http=True 没有会话,所以这两个设置对它们都不适用。
| 设置 | 默认值 | 作用 | 客户端看到什么 | 关闭方式 |
|---|---|---|---|---|
session_idle_timeout |
1800(30 分钟) |
关闭在这么长时间里没有任何进行中事务的会话。 | 404 Session not found。它必须重新 initialize。 |
None |
max_sessions |
10_000 |
超过这个数量就拒绝再打开会话。现有会话不受影响,也不会驱逐任何会话。 | 503 Too many open sessions,JSON-RPC 代码为 -32603。 |
None |
什么算“进行中”:
- 一个打开的
GET流。SDK 客户端会保持一个打开,所以已连接客户端的会话永远不会过期。 - 一个仍在应答中的请求。运行时间超过超时的工具调用不会被中断,倒计时在它完成之后才开始。
- 没有别的了。请求之间时钟照走。会话上的任何请求都会重置它,
ping也算。会话一旦过期,什么都救不回来。
用 DELETE 结束会话的客户端会立即释放它。开场请求被拒绝的客户端也是如此。
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)
两种事件都会出现在服务器日志里。过期是 INFO 级别的 Session <id> idle timeout。拒绝打开是 WARNING 级别的 Refusing to open a new session: <n> sessions are already open。
这些上限按进程计。有四个 worker 时上限是 max_sessions 的四倍,每个 worker 各自让自己的会话过期。
唯一的开关:stateless_http
如果粘性是你不愿付的代价,那么恰好有一样东西可以改。
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
"""Resolver: ask the user how many copies to put aside."""
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
"""Reserve copies of a book, asking the user how many."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
app = mcp.streamable_http_app(stateless_http=True)
这就是页面开头的那个服务器,加上一个关键字参数。stateless_http=True 让旧版这一路改为每个请求建一个用完即弃的会话:不签发 Mcp-Session-Id,请求之间什么都不记,所以任何 worker 都能服务任何请求,负载均衡器想怎么分就怎么分。
关于它,有两点比它做了什么更重要。
它只影响旧版这一路。请求在读取 stateless_http 之前就已经按版本头路由了,所以现代路径根本看不到它。2026-07-28 连接本来就是无会话的,两种取值下完全一样。
它会让这一路失去两条服务器到客户端的通道。只活一个 POST 的会话,没有供服务器推送请求的流,也没有供它推送通知的独立流。每个服务器发起的请求都会抛出 NoBackChannelError:ctx.elicit()、已退役的采样(sampling)和根目录(roots)调用(已弃用的功能),以及——没错——Resolve 向旧版客户端提问。通知连错误都没有;它们被悄悄丢弃。
Note
json_response=True 不是那个开关,但它在每一个旧版会话上都要付一半同样的代价:用一个 JSON 正文回答的 POST 没有供请求范围通道使用的流,所以请求中途的 ctx.elicit() 会抛出同样的 NoBackChannelError,与该请求绑定的通知会被丢弃。会话的独立流不受影响:无关的通知仍然能到达。
Check
故意做错一次。reserve 就是刚才同时服务两个客户端的那个工具。用 stateless_http=True 部署它,连上同样的两个客户端,分别调用它。
现代客户端仍然收到 Reserved 2 of 'Dune'.,现代这一路没变。
旧版客户端的调用不会以模型能读到的 is_error 结果返回。整个请求失败了,是一个顶层协议错误:
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
Resolve 没能救你。在 2025-11-25 连接上它必须发送 elicitation/create,而它需要的通道正是 stateless_http=True 放弃掉的东西。跨时代可移植的代码不等于不需要反向通道(back-channel)的代码。
所以这是一个实实在在的取舍,而且只存在于旧版这一路:有会话且粘性,或者无状态且单向。如果你的工具从不回调客户端,stateless_http=True 就是免费的,应该用它。如果会回调,就保留会话,保持路由粘性。
你的代码真正分叉的地方
几乎没有。
工具、资源、提示词、结构化输出、进度、错误:它们都不在乎是哪个时代调用的。initialize 握手、Mcp-Session-Id、独立流、结束会话的 DELETE:全归 SDK 管,处理函数一个都看不到。交互式输入是两个时代在线路上真正不同的地方,而 Resolve 的存在就是为了让它不成为你的问题:你刚刚看过一个工具同时服务两者。
只剩下恰好一件事,就是变更通知,因为两个时代在不同的管道上监听:
2026-07-28客户端打开一个subscriptions/listen流并读取订阅总线。ctx.notify_resource_updated()(以及notify_tools_changed()、notify_prompts_changed()、notify_resources_changed())发布到那里,而且只发布到那里。详见 订阅。- 旧版客户端读取它的会话保持打开的独立流。
ctx.session.send_resource_updated()(以及send_tool_list_changed()等)写入承载这次请求的连接:对旧版会话来说,就是它的独立流。现代连接没有地方放它:通过 HTTP 没有这样的通道,通过 stdio 这四类变更通知只走subscriptions/listen流,所以在现代连接上这条通知会被悄悄丢弃。
通过 HTTP,两个调用都到不了另一个时代的客户端。要通知所有人,两个都调用:
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bookshop")
STOCK = {"Dune": 3}
@mcp.resource("stock://{title}")
def stock(title: str) -> str:
"""How many copies of one book are on the shelf."""
return f"{STOCK[title]} in stock"
@mcp.tool()
async def restock(title: str, copies: int, ctx: Context) -> str:
"""Put copies of a book back on the shelf."""
STOCK[title] = STOCK.get(title, 0) + copies
await ctx.notify_resource_updated(f"stock://{title}")
await ctx.session.send_resource_updated(f"stock://{title}")
return f"{STOCK[title]} in stock"
两行,没有 if,没有版本检查,就完事了。因为旧版客户端存在而让处理函数做法不同的事情,全部清单就这些。
回顾
- 一个
streamable_http_app()服务两个协议时代。SDK 按MCP-Protocol-Version头路由每个请求;没有什么要配置,也没有什么时代开关可找。 - 旧版客户端的代价是一个会话:一条进程内的
Mcp-Session-Id记录,背后没有分布式存储。多于一个 worker 就意味着粘性路由,否则错的 worker 会回答404 Session not found。多 worker 的情况详见 部署与扩展。 stateless_http=True是唯一的开关,而且只作用于旧版这一路。它为旧版客户端换来自由的负载均衡,代价是这一路上两条服务器到客户端的通道:服务器发起的请求抛出NoBackChannelError(在客户端是顶层错误,不是is_error结果),通知被丢弃。2026-07-28连接无论如何都是无会话的。stateless_http永远碰不到它。- 处理函数代码只在恰好一个地方按时代分叉:变更通知。
ctx.notify_*送达subscriptions/listen客户端;ctx.session.send_*送达旧版会话。两个都调用。 - 其他一切(包括通过
Resolve向用户要输入)在构造上就是跨时代可移植的。把现代的写法写一次就够了。