章节导航 第 8 章 / 共 21 章
ExecutionContext:依赖注入的核心
ContextVar 传播、async with 生命周期、多 Runtime 懒加载、Worker Venv 自动创建、MCP 动态加载
问题:如何在 Task → Agent → Tool 链中传递依赖
行业视角:2026 年 7 月,AI Engineer World’s Fair 将 Context Engineering 列为独立 Track,与 Evals、Sandbox & Platform Engineering、Memory 并列。上下文管理正在从 Agent 框架的内部实现细节升级为独立的基础设施学科。Nexus 的 ExecutionContext 设计(ContextVar + 懒加载 + 生命周期管理)可以放在这个学科框架下理解——它不是”一种注入方式”,而是”上下文工程在评测框架中的具体实现”。
Agent 需要 LLM Client 来推理,需要 Runtime 来执行工具。但 Agent 函数签名只有一个参数(AgentRunInput)。依赖从哪来?
传统做法是把 Runtime、Client 作为参数层层传递。Nexus 选了另一条路:ContextVar 依赖注入。
ExecutionContext 的结构
nexus/core/context.py,327 行。核心字段:
class ExecutionContext(BaseModel):
# Runtime
runtime_provider: RuntimeProvider | None
runtime_configs: dict[str, dict] # per-runtime 配置
# LLM
openai_clients: dict[str, OpenAICompatibleClient] # 多 client
# Task 元数据
task_name: str | None
task_config: dict
run_id: str | None
# Tool
tool_configs: dict # Tool 专属配置(如 API Key)
tool_state: dict # 跨 Tool 共享状态
# MCP
mcp_tools: dict # 动态加载的 MCP 工具
# Trace
trace_id: str | None
# Compaction
compaction_manager: Any
# 私有状态
_runtimes: dict = {} # 已获取的 Runtime 缓存
_mcp_manager: Any = None # MCP 管理器
_context_token: Token = None # ContextVar token
ContextVar 传播机制
_current_context: ContextVar[ExecutionContext | None] = ContextVar(
'execution_context', default=None
)
进入 async with ctx: 时,将自己设为当前 ContextVar:
async def __aenter__(self):
self._context_token = _current_context.set(self)
return self
async def __aexit__(self, ...):
# 清理 MCP、释放 Runtime
await self.close_mcp_tools()
if self.runtime_provider:
await self.runtime_provider.release_all()
# 恢复之前的 Context
_current_context.reset(self._context_token)
在 Task/Agent/Tool 内部,通过 require_context() 获取:
def require_context() -> ExecutionContext:
ctx = _current_context.get()
if ctx is None:
raise RuntimeError("No active ExecutionContext")
return ctx
这是 Python contextvars 的标准用法——每个 async 协程有自己的 context 副本,天然线程安全。
ContextVar 的注意事项
“天然线程安全”不意味着”没有坑”。以下是 Nexus 开发中需要注意的 ContextVar 边界:
-
asyncio.create_task()默认不继承上下文:需要context=copy_context()显式传递。Nexus 当前所有 Task → Agent → Tool 调用都在同一个协程链内,不涉及create_task(),但未来如果引入并行 Agent 执行,需要显式传递 context。 -
run_in_executor中 ContextVar 不自动传递到子线程:Nexus 的 RUNTIME Tool 在沙箱进程中执行,require_context()在那边拿不到值。这正是_is_inside_runtime_tool_function()检查存在的原因——不是”你不应该用”,而是”用了会拿到 Host 侧的值,这是 bug 源头”。正确做法是使用ToolContext参数。 -
子 Task 的
set()不影响父 Task:ExecutionContext 的修改是 Task 局部的——子协程修改了 context,父协程看不到变化。这是特性而非 bug,保证了 Task 间的隔离性。 -
纯同步代码路径:如果一段代码完全不涉及
await,用threading.local更轻量。Nexus 统一用 ContextVar 是因为混合场景(sync 函数被 async 调用)必须统一用 contextvars。
为什么不直接传参
一个常见的质疑:为什么不把 ctx 作为函数参数传递?
原因有三:
- 签名约束:Agent/Task/Tool 的函数签名是严格约定的(恰好一个业务参数)。如果加一个
ctx参数,签名就不统一了。 - 深度嵌套:Tool 可以调用 Runtime,Runtime 内部还需要 context。层层传参会让接口臃肿。
- 框架代码 vs 用户代码:
require_context()只在框架代码里用——用户写的 Agent 函数通过它获取 Client 和 Runtime,不需要关心 context 是怎么来的。
Runtime 懒加载
async def get_runtime(self, name='main', **params) -> Runtime:
if name in self._runtimes:
return self._runtimes[name] # 缓存命中
runtime = await self.runtime_provider.acquire(name, **params)
# 如果配置了 worker_venv,自动创建
venv_raw = self.runtime_configs.get(name, {}).get('worker_venv')
if venv_raw is not None and venv_raw is not False:
runtime = await self._setup_worker_venv(runtime, venv_raw)
self._runtimes[name] = runtime
return runtime
设计要点:
- 懒加载:Task 可能声明了多个 Runtime(
main、eval),但只有真正get_runtime()时才创建 - 缓存:同名 Runtime 只创建一次
- Worker Venv 自动化:检测配置中的
worker_venv,自动构建 artifact wheel、上传、创建 venv
Worker Venv 自动创建流程
当 runtime_configs.main.worker_venv 配置了值时:
1. build_artifact_cached(source='.')
→ 将当前项目打包为 .whl(含 extensions 代码)
→ 带 hash 缓存,源码没变不重新打包
2. base64 编码 whl → 发送到 Runtime
3. runtime.create_venv({
'python_version': '3.11',
'artifacts': [{whl_data}],
'extra_requirements': [...],
})
→ Runtime Server 在沙箱内创建隔离的 Python venv
→ 安装 whl + 额外依赖
4. runtime.default_venv_id = venv.venv_id
→ 后续所有 invoke() 自动路由到这个 venv
这个机制使得扩展代码(自定义 Tool、自定义 Executor)可以被打包并注入到 Runtime 中执行。
RUNTIME Tool 的安全检查
一个微妙的设计:require_context() 会检查调用栈,如果发现是在 RUNTIME 类型的 Tool 函数内部调用,会报错。
def _is_inside_runtime_tool_function() -> bool:
for frame_info in inspect.stack():
func = frame_info.frame.f_locals.get(frame_info.function)
if func and hasattr(func, 'nexus_meta'):
if func.nexus_meta.get('tool_type') == ToolType.RUNTIME:
return True
return False
原因:RUNTIME Tool 的代码在沙箱里执行,那边没有 ExecutionContext。如果你在 RUNTIME Tool 里调用 require_context(),你拿到的是 Host 侧的 context——这是一个 bug 源头。正确做法是使用 ToolContext 参数。
MCP 工具的动态加载
async def load_mcp_tools(self, extra_servers=None, clear_existing=False):
specs = [*default_servers, *extra_servers]
self.mcp_tools = await self._mcp_manager.load(specs)
MCP(Model Context Protocol)工具可以在运行时动态加载——不需要预先注册。Agent 的 ToolExecutor 会同时查找静态注册的 Tool 和动态加载的 MCP Tool。
✅ 2026-07-28 协议最终版确认:MCP 2026-07-28 最终版已正式发布,确认移除
initialize/initialized握手与Mcp-Session-Id,引入Mcp-Method/Mcp-Name头、server/discover能力发现、ttlMs/cacheScope标准化缓存。load_mcp_tools()的实现需从”先建立会话→获取 tools/list”重构为”直接调用server/discover→缓存全量能力列表”,可在首次调用后缓存结果直至ttlMs过期。
缓存的作用域不只是”多久”:
ttlMs只回答”缓存多久”,与它并列的cacheScope回答”缓存在哪一层”——"public"表示响应不含用户相关数据,任何客户端、共享网关或缓存代理都可以存储并复用;"private"表示响应含私有数据,缓存只能在同一授权上下文内复用,跨授权上下文(例如换了一个 access token)必须重新取。规范只对ttlMs规定了缺省值(缺省按0即立即过期处理),没有给cacheScope规定缺省值,因此实现上应按最保守的private处理。Nexus 的load_mcp_tools()缓存挂在ExecutionContext上(per-Task),天然是 private 语义,安全;但若为了省掉重复的server/discover而把缓存提到进程级共享,就必须显式检查cacheScope == "public",否则会把一个 Task 的 MCP 能力列表泄漏给另一个 Task。另有两条配套约束:分页列表的各页必须使用同一个cacheScope;cacheScope本身不是访问控制手段,服务端不得只靠它阻止未授权访问。
🔴 还有第四条时间轴,前三条规定都管不到它:服务端侧的 HTTP session idle 超时。 本章已经区分了三条时间/范围维度——
ttlMs(缓存多久)、cacheScope(缓存在哪一层)、12 个月协议迁移窗口(版本)。FastMCP 4.1.0(2026-10-08)的 “Check these before upgrading” 里有一行:HTTP sessions now expire after the MCP SDK’s 30-minute idle default instead of never.
对应 PR #5229(
fix(http): preserve the SDK's default session idle timeout,列在 Breaking Changes 段)。也就是说:此前 HTTP session 永不过期,此后按 30 分钟 idle 过期。三条实用含义:
- 它发生在服务端,客户端侧的
ttlMs/cacheScope完全够不着。 长跑评测里一个 Task 超过 30 分钟没有触碰 MCP 工具是完全可能的。此时可能出现一种最难排查的状态:客户端的能力列表缓存看起来仍然新鲜(ttlMs未过期),而服务端会话已经没了,第一次工具调用才失败。“缓存还有效”因此不能作为”连接还有效”的证据。- 别把它和协议无状态化读成同一件事。 2026-07-28 移除的是协议级 session(
Mcp-Session-Id握手),这条是传输层的 HTTP session 生命周期——协议层无状态不等于传输层无会话。本章在 MRTR 那条里强调过类似的”别把 A 读成 B”,这是同族的第二例,方向相反:那里是怕读者把无状态读成不能多轮,这里是怕读者把无状态读成没有会话。load_mcp_tools()的健壮写法要再加一条分支。 上面的降级路径解决的是”服务端不支持server/discover”,这条解决的是”会话过期”——两者失败时刻不同、症状也不同(前者在发现阶段,后者在调用阶段)。应当把”工具调用失败”与”能力列表缓存”分开处理,并为 MCP 工具调用准备一条重新建立会话后重试的路径,而不是假定 per-Task 期间连接一直有效。- 这条时间轴还在小步收敛中,不是已定稿的语义(2026-11-10 补)。4.1.0 之后的 patch 主线就是这条时间轴的边角清理——过期 client context 不再误关新会话(4.1.4「a stale client context no longer closes a newer session」)、结束的任务释放其遗留的 client context 占用(4.1.5「Drop client context holds left by finished tasks」)、组上下文退出后保留已解析的工具路由(4.1.6「
ClientGroupkeeps its known tool routes after the group context exits」,此后调用已解析工具只需连接它自己的 client)。对 Nexus 的推论:load_mcp_tools()的健壮性测试应固定一版 fastmcp 跑,跨版本比对会引入这族修复的噪声(此为编辑建议);另 4.1.4 起「挂载 server 遵守 session 可见性」(mounted servers honor session visibility)意味着服务端框架也开始实现 cacheScope 一族的语义——本章 §cacheScope 讨论的服务端侧对应物已经出现。⚠️ 本轮未核:该 30 分钟默认值在 SDK 侧是否为可配置项。本章只按官方 release notes 的口径写”默认 30 分钟 idle”,不写”可配置”。
同一版的四条安全修复(均为
fix!,官方列在 Security 段):
PR 内容 #5455 MultiAuth 按来源限定 client ID——既有 MultiAuth session 与 task 会进入新的 ownership scope,官方提示”升级前先完成 pending 的 task” #5457 OpenAPI path 参数拒绝 .与..段#5459 skill 文件必须在其配置目录内解析 #5467 tool search 改用 Pydantic 正则引擎——不支持 lookaround 与反向引用 本章视角的读法:中间两条(#5457 的 OpenAPI path 段校验、#5459 的 skill 文件必须解析到配置目录内)才是路径穿越类,与本章在别处讨论的”挂载语义 / 路径可达性”论证同族——服务端框架同样要按”所有可达路径”来校验,而不是按单个参数逐个校验。末条 #5467 换的是正则引擎(不支持 lookaround 与反向引用),属”收紧此前接受的输入”,不是路径问题,别把它读成第三条路径修复。第 1 条则说明”认证主体的作用域”在 4.1 里被收紧了,直接影响把 task 长期挂在 session 上的用法(与上面那条 session 超时是同一批改动的两个面)。
另有一条同版的 Breaking Change 未列在 Security 段但同样要留意:CodeMode 要求 Monty 1.1,其中
max_duration_secs已改名为max_feed_duration_secs。⚠️ 4.1.1 / 4.1.2 / 4.1.3 三个 patch 的内容仍未逐条读;但 4.1.4–4.1.7 已可判定主线是 session 与 client context 的生命周期清理(见上面第四条含义),无
load_mcp_tools()接口层变更。4.1.1–4.1.3 按 4.1.4 起的主题聚类推断同为小修,风险低——此推断为编辑判断,逐条读 release notes 前不得升级为事实。
降级路径不是可选项:
server/discover的义务分配是不对称的——服务端必须实现,客户端可以调用但不被要求(官方原话是客户端可以自由地”直接发起任意 RPC”)。所以load_mcp_tools()的健壮写法是”直接发tools/list→ 若返回版本错误再退回server/discover”,而不是假定server/discover一定可用。版本不支持时服务端必须返回-32022(UnsupportedProtocolVersionError),错误数据的supported字段列出服务端支持的版本清单,客户端应当从中挑一个双方都支持的版本重试,无兼容版本时才把错误抛给上层。12 个月迁移窗口内新旧两种服务端都会遇到,这条路径必须实现。
为什么这个不对称在当下特别重要:同一份规范,服务端读出来是 MUST、客户端读出来是 MAY,两边读出来的义务是相反的。后果是窗口期内必然同时存在”实现了 server/discover”与”没实现”的两类服务端,load_mcp_tools() 的两条路径一条都不能省。
✅ 复核留痕(核于 2026-10-23):PyPI
mcplatest 仍为 2.3.0(2026-10-02T22:06:52Z 上传),此后无新版本(fastmcp侧已走到 4.1.3,但那是服务端框架,不是 SDK)。因此本节基于 2.3.0 release notes 的全部论述——#3629 OTel ERROR span、#3620 静默丢工具、#3635 协商选错版本、空_meta不再发送、Mcp-Param-*校验变更——均未被推翻。 下轮修订者据此可跳过mcp的重查,只需查fastmcp。
✅ 2026-11-10 复核:
mcp仍 2.3.0、规范仍 2026-07-28——预判连续两轮成立;下轮继续只查fastmcp,重点:4.2.0 是否 GA(alpha 线 a1–a4 已跑通,见上方锚点表;4.2 主打 Skills 且已宣布废弃 4.1 skills API)。
这件事在 SDK 层面已经有可观测证据了。MCP Python SDK 2.3.0 的修复清单里有一条(#3629):客户端 OTel span 现在会记录 JSON-RPC 错误响应;并特别写明——以 mode="auto" 连上一个没有 server/discover 的服务端时,探测会产生一条 ERROR span。也就是说,SDK 自己就预期你会撞上没有 server/discover 的服务端,还专门给这种情况留了一条可观测信号。对 Nexus 的实用含义:不要只看 load_mcp_tools() 的返回,把这类 ERROR span 接进评测的故障归因通道——它正好回答”这次是协商失败还是工具真的没有”。
Tasks 扩展变更:Tasks 扩展正式化为 poll-based 生命周期——
tasks/get/tasks/update+ 协作式tasks/cancel。这影响基于 MCP 动态加载的上下文构建路径:长时间运行的 MCP 任务不再依赖持久连接,而是通过轮询状态推进;Roots/Sampling/Logging/DCR 弃用进入 12 个月窗口。
Tasks 的三个实现细节:① Tasks 是独立 Extension(扩展标识
io.modelcontextprotocol/tasks,见 SEP-2663),不是核心协议的一部分——客户端在 per-request capabilities 的extensions里声明它,服务端在server/discover的 capabilities 里同样声明,双方各声明一次即完成协商;② 是否把请求物化为任务由服务端逐请求决定(官方用词 “Server-directed”),客户端只能”一次性声明支持,然后两种返回形态都能处理”,既没有 per-tool 预热也没有 per-request 开关,不能主动要求服务端建任务;③CreateTaskResult(resultType: "task")会同时返回taskId、初始状态、TTL 和建议轮询间隔pollIntervalMs,客户端不需要自己猜轮询频率。状态机为working/input_required/completed/failed/cancelled,其中input_required表示服务端需要客户端补输入,由客户端通过tasks/update回传。
服务端生态迁移:FastMCP 4 基于 MCP Python SDK v2 重写,反映服务端生态向新协议的迁移节奏——Nexus 的 MCP 集成在对齐新协议时,可参考 FastMCP 4 的适配路径。
安装时注意装的是哪一个:核于 2026-10-23(回 PyPI 重取)——
fastmcp与fastmcp-slimlatest 均为 4.1.3;MCP Python SDKmcp为 2.3.0(mcp-types同为 2.3.0,均上传于 2026-10-02T22:06)。4.x 把核心拆到了fastmcp-slim,fastmcp退化为 extras 聚合包(其依赖直接写死fastmcp-slim[client,server]==4.1.3——本轮同样回 PyPI 重取requires_dist确认,这个串确实跟着升到了 4.1.3);fastmcp-slim对mcp的约束是>=2.0.0,<3.0.0,基础依赖则是mcp-types>=2.0.0,<3.0.0。也就是说:装fastmcp得到的是”开箱即用的聚合包”,装fastmcp-slim得到的是”自己挑 extras 的精简包”,两者版本号当前一致但依赖树不同,引用或锁定版本时要分清。
🔄
fastmcp版本锚点刷新(核于 2026-11-10):从 2026-10-03 快照的 4.0.10 到现在的 4.1.7,38 天里跨了 1 个 minor 加 8 个版本,且 4.2.0 alpha 线已开(a1 10-27 / a2 11-01 / a3 11-03 / a4 11-10)。完整序列(PyPIupload_time/ GitHub releases,两个包同步发版):
版本 上传时间 4.0.10 2026-09-25T17:19 4.0.11 2026-10-04T16:58 4.1.0 2026-10-08T22:57 4.1.1 2026-10-18T02:14 4.1.2 2026-10-20T14:01 4.1.3 2026-10-20T22:32 4.1.4 2026-10-26T06:23 4.1.5 2026-11-01T20:52 4.1.6 2026-11-03T02:52 4.1.7 2026-11-10T01:59 4.2.0a1–a3 2026-10-27 / 11-01 / 11-03 4.2.0a4 2026-11-10T01:59 4.1.7(11-10)从 4.2 线回移 42 个修复(OpenAPI schema/参数处理、OAuth redirect 与 consent 边角、proxy 与响应缓存、client 断连报错),并要求打过补丁的 joserfc 与 Authlib、收掉一个 raw-string redirect URI 的校验缺口(Security 段五条)——4.1.x 出现了专门维护分支(4.1.5 起官方自述「Twelve fixes backported from main onto the new
release/4.1line」)。4.2.0 线的大方向是 Skills:a4 已把 Skill 改为 storage-neutral 对象、SkillsDirectoryProvider、并废弃 4.1 skills API(4.3 移除)——下一轮扫描时 4.2 可能已 GA,届时”第四条时间轴”可能有新成员。注意 4.0.10 与 4.1.0 之间还有一个 4.0.11(2026-10-04),按版本序列补上,不要写成”4.0.10 直接跳到 4.1.0”。写死的依赖串上一轮已回
requires_dist重取——这条”写死的串会跟着升”的规律已两次被独立验证(2026-10-03 验过 4.0.10,10-23 验 4.1.3),可以当作一条稳定的包结构判据。仓库归属一并记下:
github.com/PrefectHQ/fastmcp(28173★,末次推送 2026-10-23T05:45Z,核于 2026-10-23)。4.1.0 的 release notes 里所有 PR 链接都指向这个 org——不要写成jlowin/fastmcp,那是作者个人账号。发版节奏值得记一笔:
mcp2.2.0 发布于 2026-09-07,2.3.0 发布于 2026-10-02;TypeScript SDK 也在同一天(2026-10-02)发出 v2.3.0,另有 1.x 维护线的 1.32.0 同日发出——跨语言 SDK 是协同发版的。对 Nexus 这种要同时对齐多语言 server 的框架,版本基线应按”协议修订 + SDK 代次”记录,而不是按单个包。
这条判据还要再加一层:也要记语言运行时代次。 FastMCP 4.1.0 的两条 Breaking Changes 里,除上面那条 session 超时外,另一条是 “Restore full Python 3.15 support”(#5558)。跨章对照很整齐:Ch02 记录 Python 3.15 于 2026-10-09 GA,而 MCP 服务端生态在 2026-10-08 的 4.1.0 里就已补齐支持——比 GA 早一天(0.15 GA 的日期与 4.1.0 发布日期几乎重合,说明该支持是跟随 RC 线推进的,不是 GA 之后才补)。
于是版本基线从”协议修订 + SDK 代次”扩为三项:协议修订 + SDK 代次 + 语言运行时代次。对 Nexus 这种要同时对齐多语言 server 的框架,缺了第三项就会出现”SDK 版本对得上、但某个 server 在目标 Python 上装不起来”的断链。
4.1.0 的自我定位也值得原样记一句:“a maintenance release at heart”,但因为 “a few security fixes do tighten inputs that FastMCP previously accepted”,按版本策略(breaking change 不进 patch 版)发成了 point-one。“维护版”这个自我描述与”含破坏性变更”这个事实并存——判断升级风险时不能只看前者。
⚠️ 一条会实际撞上的升级阻塞:2.3.0 起
httpx2>=2.10.0是硬要求(此前为>=2.5.0),原因是新的max_sse_event_size选项依赖它。而fastmcp-slim的 client / server extras 对 httpx2 的约束仍写的是>=2.5.0——两者不冲突(2.10 同时满足),但若 Nexus 的依赖里把 httpx2 锁在 2.5~2.9 区间,升到mcp2.3.0 会直接解析失败。属内部事实,需内部核实。
一个容易被忽略的可复现性要求:2026-07-28 规范要求服务端 SHOULD 以确定性顺序返回 tools/list(底层工具集未变时跨请求保持同一顺序),官方给出的理由是让客户端能可靠缓存工具列表,并提高工具进入模型上下文时的 LLM prompt cache 命中率。
对 Nexus 这种评测框架,这条约束的分量比协议作者设想的更大(以下为本书分析推论,非官方结论):工具列表顺序一旦不稳定,会同时污染三处——① 工具列表缓存能否命中;② LLM 侧的 prompt cache 命中率;③ 跨轮评测的可复现性。第三点是评测框架的生命线:同一 Task 两次运行若因工具顺序不同触发不同的 prompt cache 行为,得分差异就无法归因到被测 Agent 本身。因此 load_mcp_tools() 在拿到工具列表之后应显式排序(或至少记录原始顺序以备复现),而不是依赖 MCP server 的返回顺序。
静默失效:清单里少了一个工具,以及协商选错了版本
上面三条(顺序、作用域、降级路径)讲的都是”列表内容的不确定性”。还有一类更麻烦的失效本章此前没覆盖:服务端一切正常,但客户端拿到的列表就是不对,而且全程没有报错。MCP Python SDK 2.3.0(2026-10-02) 一次性修掉了两个这样的子类,二者都只在跨版本对比时才看得见——这正是最容易漏掉的一类。
其一:工具被静默从 tools/list 里丢掉。 2.3.0 的 release notes 原文(#3620):
A tool with an invalid
x-mcp-headerannotation fails at registration.@mcp.tool(),add_toolandTool.from_functionraiseInvalidSignature, naming the tool and the problem. Until now the server started, and 2026-07-28 clients silently dropped the tool from their listing.
被判为非法标注的情形比直觉宽:参数不是 plain str / int / bool 都算——所以 str | None、float、list、enum 全部非法;header 名不是合法 token;两个仅大小写不同的名字。可选 header 参数要改成直接给 schema:Annotated[str | None, WithJsonSchema({"type": "string", "x-mcp-header": "Region"})] = None。
在 2.3.0 之前,这条路径的后果是服务端照常启动、注册照常返回成功,而客户端的清单里就是没有这个工具。对 load_mcp_tools() 的直接推论:拿到工具列表之后,除了排序,还应该校验条数与预期是否一致——“少了一个工具”在旧版本上完全没有信号,而评测框架里少一个工具意味着被测 Agent 的能力面被静默削掉一块,得分下降却找不到原因。
其二:自动版本协商会静默选错版本。 2.3.0 的另一条(#3635):交互式的 OAuth 登录原先会被计入请求超时,导致
…
Client(mode="auto")settling on2025-11-25when the login took longer than 10 seconds.
含义比字面重:使用自动协商的客户端,可能因为一次慢登录而在整条会话上跑在旧协议上,且没有任何告警。你以为在用 2026-07-28,实际协商结果是 2025-11-25。
推论与本章 Trace 那条天然同路:若 load_mcp_tools() 采用自动协商,应把协商结果(实际使用的协议版本)显式记录进 Trace。一次完整评测链路(Nexus → MCP Server → 工具)里,除了 traceparent,也该能看到”这一跳用的是哪个协议版本”——否则跨轮评测的可复现性会在版本这一层再破一个洞。
两条都取自官方 release notes 原文(v2.3.0,2026-10-02T22:02:27Z 发布)。同批还有几条行为变更与本章相关,一并记下:空
_meta与params不再发送(见下文);initialize在未配置时不再发experimental;Mcp-Param-*校验改为按名查工具,MCPServer不再为每个tools/call跑一次tools/list,因此过滤或重写tools/list的中间件不再影响校验;Client.call_tool在收到HeaderMismatch(-32020)拒绝后会重新列工具并重试一次;新增max_sse_event_size=(默认仍是每 SSE 事件 1 MiB,可放大或传None)。
生命周期图
Runner 创建 ExecutionContext
│
├─ async with ctx:
│ ├─ _current_context.set(self)
│ │
│ ├─ Task 执行
│ │ ├─ ctx.get_runtime('main') → 创建 Runtime
│ │ ├─ run_agent(...)
│ │ │ ├─ ctx.openai_client → 获取 LLM Client
│ │ │ ├─ tool_executor.call(...)
│ │ │ │ ├─ LOCAL tool → 直接执行
│ │ │ │ └─ RUNTIME tool → runtime.call_tool(...)
│ │ │ └─ ... 迭代 ...
│ │ └─ evaluate(...)
│ │
│ ├─ __aexit__:
│ │ ├─ close_mcp_tools()
│ │ ├─ runtime_provider.release_all()
│ │ └─ _current_context.reset()
│ │
└─ 所有资源已释放