← 工程跃迁 / 第 13 章
架构定位

能力扩展

通过能力包、外部证据、持久状态和标准协议扩展 Agent 能做的事情。

如何为 Agent 增加 Skill、检索、记忆和外部协议能力?

概念校准

MCP

它是什么
连接 Tool、Resource 和 Prompt 的标准协议,可用于本地或远程传输。
它不是什么
不等于远程 API,不等于 Agent,也不是 Agent 获得工具的唯一方式。

工具

它是什么
Agent 可通过明确参数、权限和结果合同调用的受约束能力。
它不是什么
工具不等于 MCP;本地函数、命令或 HTTP 客户端也可以是工具。

Harness

它是什么
承载模型、工具、状态、权限、日志、预算、人工确认和恢复的运行控制环境。
它不是什么
它能限制风险并保留证据,但不能直接阻止模型产生幻觉。

MCP Server、SaaS 与流式协议#

实现 Tool、Resource、状态、Streamable HTTP、鉴权和客户端联调。

本章只做一件事Forge 通过 MCP 为三个场景接入不同外部能力。
开始前,Forge 已经具备已完成 Module 12 的“多 Agent 协作与任务编排”,其通过验收的 solution 是本章起点。
完成后,Forge 将能够让 Forge 通过 MCP 访问外部服务,三个场景分别接入不同工具。
卡住时的最小恢复点先用本机无鉴权 MCP Mock 验证 Schema 和错误合同。
下载本章实验仓库Python 3.12 · pytest · Pydantic · SQLite · deterministic Mock
阶段能力链
01定义可验证的工具参数合同,并拒绝缺失字段和未知参数02注册带 MIME 类型的资源,并把读取限制在明确允许的 URI 前缀内03声明模板变量并在渲染前验证缺失值和多余值04按序组装流式响应,并拒绝重复、乱序和终止后的事件05验证令牌有效期和权限作用域,并只构造最小授权请求头06只对可安全重试的请求执行退避,并为写请求绑定幂等键07协商客户端与服务端能力,并只从已确认游标恢复兼容会话
每一步只增加一种可验证能力;后一步建立在前一步已经通过的代码和测试上。

概念校准\n\n在开始操作代码前,先把本章涉及的概念放回正确的工程边界。\n\n
\n\n### MCP mcp\n\n- 它是什么: 用于连接 Tool、Resource 和 Prompt 的标准协议,可运行在本地或远程传输之上,并用明确 Schema 描述能力。\n- 它不是什么: 它不等于远程 API,不等于 Agent,也不是接入工具的唯一方式;协议连接成功后仍需治理身份、权限和副作用。\n- 与相邻概念的关系: Tool 是可执行能力,Resource 是可读取信息,Prompt 是模板;MCP 规范发现与调用,Harness 控制实际运行。\n- 在 HeatStack Forge 中的位置: Forge 验证 MCP 服务的能力清单、Schema 与身份范围,再把允许的能力注册进现有工具系统,而不是绕过治理。\n- 典型误用与修正: 误用是连接服务器后把全部能力交给模型。修正方法是允许列表、最小权限、超时、审计、高风险确认和断线测试。\n\n### 工具 tool\n\n- 它是什么: Agent 可通过明确参数、权限、执行边界和结果合同调用的受约束能力,例如函数、命令、数据库查询或 HTTP 客户端。\n- 它不是什么: 工具不等于 MCP,也不等于任意脚本。协议可以暴露能力,但 Harness 仍决定是否允许调用及如何处理副作用。\n- 与相邻概念的关系: Agent Loop 选择工具,Schema 约束参数,Harness 执行权限检查和隔离,Verification 检查结果是否推进目标。\n- 在 HeatStack Forge 中的位置: Forge 的 tool registry 保存合同与风险等级;执行前生成变更计划,高风险写操作等待确认,执行后记录结果与恢复信息。\n- 典型误用与修正: 误用是让模型根据工具名称自由拼参数。修正方法是严格 Schema、确定性校验、超时、幂等键和拒绝分支测试。\n\n### Harness harness\n\n- 它是什么: 承载模型、工具、状态、权限、日志、预算、人工确认、隔离和恢复的运行控制环境。\n- 它不是什么: 它可以缩小风险、拒绝动作并保留证据,但不能保证模型输出永远正确,也不能代替领域验收。\n- 与相邻概念的关系: Agent Loop 在 Harness 中运行;Tool、Memory 和 MCP 连接受其策略约束;Observability 记录过程,Evaluation 判断行为。\n- 在 HeatStack Forge 中的位置: Forge Harness 管理工作目录、工具、确认队列、预算、Checkpoint、审计日志和回滚,把模型建议与真实副作用分开。\n- 典型误用与修正: 误用是把系统提示词当成安全边界。修正方法是把权限、路径、网络、密钥、预算和回滚落实在模型之外的控制中。\n\n
#

STAGE 01

定义可验证的工具参数合同,并拒绝缺失字段和未知参数

先看一个具体问题#

为什么只检查必填字段还不够?#

在 Module 12 的验收方案中,Forge 已经能够编排多个 Agent 完成任务,但还没有通过 MCP 连接外部工具。

本阶段要创建 forge/mcp/schema.py,定义 ToolSchema 数据类,并实现 validate 方法来检查参数是否符合合同。

如果只检查必填字段,攻击者或意外调用可能传入未声明的参数,例如 shell,从而注入额外能力。

测试 test_unknown_arguments_are_rejected 要求当传入 shell 参数时抛出包含 unknown 的 ValueError。

先做判断#

在实现 ToolSchema.validate 时,如果只检查 required 字段而忽略 allowed 字段,会发生什么?

  • 所有合法参数都能通过验证,未知参数也会被接受
  • 未知参数会被自动忽略
  • 验证会抛出 TypeError
  • 只有缺失必填字段时才会报错

判断依据: 正确答案是第一个选项:只检查必填字段会让未知参数也被接受,因为验证逻辑没有拒绝不在 allowed 元组中的键。

它是什么#

工具 Schema 是参数形状的合同#

ToolSchema 用 required 元组声明必须出现的参数名,用 allowed 元组声明所有可接受的参数名。

validate 方法先检查缺失的必填参数,再检查未知参数,最后返回参数的副本。

这个合同只负责参数形状验证,不涉及执行权限或副作用控制。

它不是什么#

工具 Schema 不是安全边界#

Schema 验证通过并不意味着工具调用是安全的,因为执行环境、权限和副作用仍由 Harness 控制。

它不能防止模型产生错误参数值,只能拒绝形状不合法的输入。

它也不是 MCP 协议本身,而是 MCP 工具定义的一部分。

它与相邻概念的关系#

Schema 与 Harness、Agent Loop 的关系#

Agent Loop 选择工具后,Harness 使用 ToolSchema.validate 检查参数,再决定是否允许调用。

Schema 的 allowed 元组限定了可接受的参数集合,防止意外能力注入。

验证通过后,Harness 仍可基于策略拒绝执行,例如检测到危险参数值。

本阶段的边界决定: 工具 Schema 只负责参数形状验证,不负责执行权限或副作用控制;因此,即使验证通过,Harness 仍必须独立检查权限和副作用。

STAGE 02

注册带 MIME 类型的资源,并把读取限制在明确允许的 URI 前缀内

先看一个具体问题#

如何防止资源注册越界,确保只能访问允许的 URI 前缀?#

当前 forge/mcp/schema.py 已经存在,但还没有资源注册表。测试文件 tests/test_stage.py 中导入了 forge.mcp.resources 模块,但该模块尚未创建,导致测试收集阶段直接报错 ModuleNotFoundError: No module named 'forge.mcp.resources'。

你需要创建 forge/mcp/resources.py,实现 Resource 数据类和 ResourceRegistry 类。ResourceRegistry 在构造时接收一个允许的 URI 前缀元组,并在注册资源时强制检查 URI 是否以这些前缀开头。

如果注册的 URI 不在允许前缀内,必须抛出 ValueError,且错误消息中包含 "allowed" 字样。同时,注册表还需要支持读取已注册的资源,并保留其 MIME 类型。

先做判断#

在 ResourceRegistry.register 中,如果传入的 resource.uri 不以 self.allowed_prefixes 中的任何一个前缀开头,应该如何处理?

  • 直接存储资源,因为注册表只负责存储,不负责校验。
  • 抛出 ValueError,并提示 URI 不在允许前缀内。
  • 静默忽略该资源,不存储也不报错。
  • 将 URI 自动转换为允许前缀下的路径再存储。

判断依据: 正确选项是抛出 ValueError。注册表必须执行前缀策略,否则越界资源会被接受,导致后续读取时可能泄露不应访问的内容。静默忽略或自动转换都会掩盖错误,使调用方无法感知配置问题。

它是什么#

ResourceRegistry 是什么#

ResourceRegistry 是一个内存中的资源存储容器,它维护一个从 URI 到 Resource 对象的映射,并在注册时强制实施 URI 前缀策略。

它通过 allowed_prefixes 元组定义可接受的 URI 命名空间,例如 ("forge://workspace/",) 表示只允许以 forge://workspace/ 开头的 URI。

注册方法 register 会检查资源 URI 是否以任一允许前缀开头,如果不符合则抛出 ValueError,从而防止越界资源进入注册表。

它不是什么#

ResourceRegistry 不是什么#

它不是内容解析器或传输层:它不关心资源内容的具体格式,也不负责将资源发送给客户端。

它不是安全边界本身:它只执行前缀检查,但调用方仍需确保 allowed_prefixes 的配置正确,且后续读取时也要再次校验 URI。

它不是全局资源管理器:每个注册表实例独立维护自己的映射,不同实例之间不共享数据。

它与相邻概念的关系#

与其他组件的关系#

Resource 数据类是不可变对象,包含 uri、mime_type 和 content 三个字段,注册表存储这些对象的引用。

ResourceRegistry 依赖 allowed_prefixes 来定义边界,而 register 和 read 方法都使用这个边界进行校验。

测试 test_registered_resource_preserves_media_type 验证注册后读取的资源 MIME 类型保持不变,而 test_resource_outside_allowed_prefix_is_rejected 验证越界资源被拒绝。

本阶段的边界决定: 注册表只负责存储和前缀策略,不负责内容解析或传输。因此,在 register 中必须检查 URI 前缀,否则越界资源会被接受;在 read 中也要检查 URI 前缀,防止调用方直接读取未注册的越界 URI。

STAGE 03

声明模板变量并在渲染前验证缺失值和多余值

先看一个具体问题#

缺失变量被静默替换为空字符串,导致提示不完整#

在上一阶段,我们实现了 ResourceRegistry,但还没有提示模板。现在需要创建 forge/mcp/prompts.py 并实现 PromptTemplate 类。

测试 test_missing_prompt_variable_is_rejected 要求:当渲染提示模板时,如果缺少声明的变量,必须抛出 ValueError,而不是静默地用空字符串填充。

当前起始代码中 forge/mcp/prompts.py 不存在,运行测试会报 ModuleNotFoundError。我们需要从零实现 PromptTemplate,并确保变量验证逻辑正确。

先做判断#

如果 PromptTemplate 的 render 方法在缺少变量时用空字符串填充,会发生什么?

  • 渲染结果不完整,但不会报错,测试会失败
  • 渲染结果完整,测试通过
  • 抛出 ValueError,测试通过
  • 渲染结果不完整,但测试通过

判断依据: 正确答案是第一个选项。如果缺少变量时用空字符串填充,渲染结果会缺少必要信息,测试 test_missing_prompt_variable_is_rejected 会失败,因为它期望抛出 ValueError。

它是什么#

PromptTemplate 是变量合同与渲染器#

PromptTemplate 声明了渲染所需的变量集合,并在渲染前验证提供的值是否完全匹配。

它通过 variables 元组明确列出所有必需变量,render 方法检查缺失和多余变量,确保合同被遵守。

渲染使用 Python 的 str.format 机制,将模板中的占位符替换为提供的值。

它不是什么#

PromptTemplate 不负责提示内容质量或模型调用#

PromptTemplate 不评估提示文本是否有效或高质量,它只保证变量替换的完整性。

它不涉及模型调用、API 请求或任何外部副作用,仅是一个纯数据转换组件。

它不处理变量值的类型转换或默认值,所有变量必须显式提供。

它与相邻概念的关系#

PromptTemplate 与 ResourceRegistry 的关系#

ResourceRegistry 管理可读取的资源,而 PromptTemplate 管理可渲染的提示模板,两者都是 MCP 能力的一部分。

PromptTemplate 可以引用 ResourceRegistry 中的资源内容作为变量值,但变量验证独立于资源存在性。

两者都遵循明确的 Schema 约束,但 PromptTemplate 专注于变量合同,ResourceRegistry 专注于资源发现。

本阶段的边界决定: PromptTemplate 只负责变量合同和渲染,不负责提示内容质量或模型调用;缺失或多余变量必须抛出 ValueError,而不是静默处理。

STAGE 04

按序组装流式响应,并拒绝重复、乱序和终止后的事件

先看一个具体问题#

流式事件必须按序到达且终止后不可继续#

在流式协议中,事件可能因为网络重传、并发推送或客户端错误而乱序到达,但最终拼接结果必须与发送顺序一致。

如果组装器盲目接受任何序列号,就会把缺失事件后的数据错误拼接,导致输出不可验证。

本阶段需要实现 StreamAssembler,它只接受连续递增的序列号,并在收到 done 事件后拒绝任何后续事件。

当前起始代码中 forge/mcp/streaming.py 文件不存在,测试 test_sequence_gap_is_rejected 会因导入错误而失败。

先做判断#

如果 StreamAssembler.push 收到序列号 3 而当前期望序列号是 2,应该发生什么?

  • 接受事件并更新期望序列号为 4
  • 抛出 ValueError,因为序列不连续
  • 忽略该事件并等待序列号 2
  • 自动补一个空事件再接受

判断依据: 正确行为是抛出 ValueError,因为流式协议要求事件严格按序到达,任何间隙都意味着数据丢失或乱序,必须显式拒绝而不是静默修复。

它是什么#

StreamAssembler 是什么#

StreamAssembler 是一个有状态对象,维护 next_sequence(下一个期望的序列号)、chunks(已接收的文本块列表)和 finished(是否已收到终止事件)。

它的 push 方法负责验证事件序列连续性、事件类型合法性以及终止状态,然后才应用事件数据。

text 方法只有在 finished 为真时才返回拼接结果,否则抛出异常,防止返回不完整内容。

它不是什么#

StreamAssembler 不是什么#

它不负责网络传输、重传或缓存乱序事件,这些属于传输层或更高层协议。

它不解析事件内容或验证业务语义,只关注序列号和终止状态。

它不是线程安全的,如果多个线程同时推送事件,需要外部同步机制。

它与相邻概念的关系#

与其他组件的关系#

StreamAssembler 使用 next_sequence 作为游标,与 push 中的序列号比较,确保每次只接受期望的下一个事件。

finished 状态由 done 事件设置,一旦为真,后续任何 push 都会立即抛出异常。

chunks 列表按接收顺序存储文本块,text 方法在终止后将其拼接为最终字符串。

本阶段的边界决定: StreamAssembler 只负责顺序和终止状态,不负责网络传输或重传;任何乱序或终止后的事件都必须被拒绝,而不是尝试修复。

STAGE 05

验证令牌有效期和权限作用域,并只构造最小授权请求头

先看一个具体问题#

只读令牌被误用于写操作#

在 SaaS 集成中,我们经常拿到一个访问令牌(AccessToken),它带有有效期和一组作用域(scopes)。

当前代码中,AccessToken.headers_for 只检查了令牌是否过期,却没有检查请求的操作是否在令牌允许的作用域内。

这导致一个只有 issues:read 权限的令牌,也能为 issues:write 操作生成 Authorization 头,从而越权调用写接口。

我们需要在生成授权头之前,同时验证令牌未过期且具备所需作用域,否则抛出 PermissionError。

先做判断#

在 AccessToken.headers_for 中,如果令牌未过期但缺少所需作用域,当前代码会怎样?

  • 抛出 PermissionError,因为作用域检查已经存在
  • 返回 Authorization 头,因为只检查了过期时间
  • 返回空字典,因为缺少作用域
  • 抛出 ValueError,因为作用域格式错误

判断依据: 当前代码只检查了 now >= self.expires_at,没有检查 required_scope 是否在 self.scopes 中。因此,只要令牌未过期,就会返回 Authorization 头,即使作用域不匹配。

它是什么#

AccessToken 是一个最小授权决策单元#

AccessToken 是一个不可变数据类,封装了令牌值、作用域集合和过期时间。

它的 headers_for 方法在生成授权头之前执行两个检查:先检查过期,再检查作用域。

这种顺序确保我们不会为一个已经无效的令牌浪费作用域检查,也避免泄露令牌值。

它不是什么#

AccessToken 不负责获取或刷新令牌#

AccessToken 只负责验证和头部生成,不负责从 OAuth 服务器获取新令牌或刷新过期令牌。

它也不负责实际的 HTTP 调用或权限策略的全局管理;它只针对单个请求的所需作用域做局部判断。

它与相邻概念的关系#

过期检查与作用域检查的顺序关系#

过期检查是前提:如果令牌已过期,无论作用域如何都不能使用,因此先抛出过期错误。

作用域检查是授权:只有令牌未过期且包含所需作用域时,才生成 Authorization 头。

sanitized_headers 独立于 AccessToken,用于在日志中脱敏授权头,防止令牌值泄露。

本阶段的边界决定: 在生成 Authorization 头之前,必须同时满足令牌未过期且包含所需作用域;任何一项不满足都应抛出 PermissionError,而不是返回部分头部或空头部。

STAGE 06

只对可安全重试的请求执行退避,并为写请求绑定幂等键

先看一个具体问题#

为什么不能对所有请求都重试?#

在接入外部 SaaS 服务时,网络抖动或服务端瞬时错误很常见,因此我们可能想自动重试失败的请求。

但并非所有请求都适合重试:例如一个创建订单的 POST 请求,如果第一次请求已经成功但响应丢失,重试就会创建两个订单。

本阶段我们要实现一个 plan_request 函数,它根据 HTTP 方法和是否提供幂等键来决定重试策略,从而避免重复副作用。

当前代码中还没有 forge/mcp/saas.py 文件,测试 test_get_retries_are_bounded 和 test_post_retry_requires_idempotency_key 会因导入错误而失败。

先做判断#

在实现 plan_request 之前,请预测:对于 plan_request("POST", 1)(没有提供幂等键),函数应该返回什么?

  • 返回一个 RequestPlan,其中 attempts 为 2
  • 抛出 ValueError,提示需要幂等键
  • 返回一个 RequestPlan,其中 attempts 为 1
  • 返回 None

判断依据: 正确答案是抛出 ValueError。因为 POST 请求可能产生副作用,在没有幂等键的情况下重试是不安全的,所以函数必须拒绝规划重试。

它是什么#

重试策略规划器#

plan_request 是一个纯函数,它根据 HTTP 方法、瞬时失败次数和可选的幂等键,计算出一个 RequestPlan 数据类。

该函数只负责决定“是否重试”以及“重试多少次”,不执行实际的网络请求,也不进行重试操作。

对于 GET 请求,因为它是幂等的,可以安全地根据瞬时失败次数增加尝试次数,但尝试次数有上限。

对于 POST 请求,只有在提供了幂等键时才允许重试,否则抛出 ValueError。

它不是什么#

不是网络客户端或执行器#

plan_request 不发送 HTTP 请求,也不处理响应,它只是生成一个计划对象。

它不负责生成幂等键,也不验证幂等键的格式,只检查其是否存在。

它不是一个通用的重试装饰器,不会自动包裹任意函数进行重试。

它与相邻概念的关系#

与其他组件的关系#

RequestPlan 数据类包含 method、attempts 和 headers 字段,其中 headers 会包含 Idempotency-Key(如果提供了幂等键)。

后续的 SaaS 客户端可以使用 RequestPlan 来实际执行请求,并按照 attempts 进行重试。

plan_request 依赖于调用者传入正确的参数,它本身不维护任何状态。

本阶段的边界决定: 边界决策:plan_request 只负责规划重试策略,不负责实际网络调用或重试执行;对于写请求,必须要求调用者提供幂等键,否则拒绝规划。

STAGE 07

协商客户端与服务端能力,并只从已确认游标恢复兼容会话

先看一个具体问题#

会话恢复时协议版本可能被悄悄改变#

当前 forge/mcp/session.py 文件尚未创建,测试 test_negotiation_uses_intersection 和 test_resume_rejects_protocol_change 在导入阶段就会失败,因为 forge.mcp.session 模块不存在。

即使补上模块,如果 resume 函数无条件信任调用方传入的协议版本,一个原本协商为 2026-01 的会话就可能被恢复成 2025-06,导致后续流处理使用错误的消息格式。

本阶段需要实现 negotiate 和 resume 两个函数,并确保恢复时协议版本必须与已协商会话一致,否则抛出 ValueError。

先做判断#

在实现 resume 之前,你认为以下哪种行为是正确的?

  • 恢复时可以重新协商协议版本,只要客户端支持即可。
  • 恢复时必须使用原会话的协议版本,任何不一致都应拒绝。
  • 恢复时只需检查游标是否大于已确认序列,版本无关紧要。
  • 恢复时应该自动选择客户端和服务端都支持的最新版本。

判断依据: 正确选项是第二个:恢复时必须使用原会话的协议版本。因为会话已经建立,协议版本是双方约定的契约,改变版本意味着消息格式可能不兼容,必须拒绝。

它是什么#

会话协商与恢复的本质#

会话协商(negotiate)是在客户端和服务端之间确定一个共同的协议版本和一组共同支持的能力,并生成一个不可变的 Session 对象。

会话恢复(resume)是在连接中断后,使用已确认的游标继续处理事件,但必须保证协议版本不变且游标不回退。

它不是什么#

会话恢复不是重新协商#

恢复不是重新协商:它不能改变协议版本或能力集合,否则会破坏已建立的通信契约。

恢复也不负责实际流传输或断线重连,它只负责状态检查并返回新的会话对象。

它与相邻概念的关系#

组件之间的关系#

negotiate 产生 Session,resume 消费 Session 并返回更新后的 Session。

Session 的 protocol_version 和 capabilities 在恢复时必须保持不变,只有 acknowledged_sequence 可以更新。

本阶段的边界决定: 恢复时如果协议版本不一致或游标小于已确认序列,必须抛出 ValueError,而不是静默接受或尝试重新协商。

完成本章#

让 Forge 通过 MCP 访问外部服务,三个场景分别接入不同工具。

FORGE / LOCAL CHECK

本地实验自检

  1. 未开始
  2. 2阅读中
  3. 3实验已下载
  4. 4测试结果已读取
  5. 5本地自检通过

verification.json 只在当前浏览器中解析,不会上传。这里验证的是实验合同,不是服务器认证或第三方背书。

概念校准与一周复习

三道题检查你是否掌握了本章边界、交付证据和恢复方法。答案只保存在当前浏览器。

  1. 1哪一项最能证明你真正完成了「MCP Server、SaaS 与流式协议」?
  2. 2关于「MCP」,哪一种理解最准确?
  3. 3实验卡住时,哪个动作是本章建议的最小恢复点?