Skip to content

feat(agent): support northbound MCP header passthrough (Phase 1) - #4329

Merged
yunshuang-hw merged 13 commits into
developfrom
t30057956-header-passthrough
Nov 12, 2026
Merged

yunshuang-hw merged 13 commits into
developfrom
t30057956-header-passthrough

Conversation

@yunshuang-hw

@yunshuang-hw yunshuang-hw commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

背景

北向调用方(外部 CRM/工单等系统)在调用 Nexent 智能体时,往往需要把自身的身份类 header(Cookie、Authorization、X-User-Id 等)透传到该智能体所使用的远程 MCP 服务,才能通过 MCP 后端的鉴权。

Phase 1 提供受控透传 + 占位符引擎:调用方通过 runtime metadata 的保留字段传入 header,由 runtime 在构造 MCP 会话时注入;同时支持管理员在 MCP 静态 header 里用 ${HeaderName} 占位符引用这些值。

变更内容

开关与迁移

  • ag_tenant_agent_t 新增两列 allow_mcp_header_passthrough、strict_mcp_placeholder_resolution,默认 false,存量 agent 行为完全不变(deploy/sql/migrations/v2.9.0_009_allow_mcp_header_passthrough.sql)
  • 同步更新 ORM 模型与 AgentInfoRequest 契约

请求侧准入(infrastructure/serialization/runtime_metadata.py)

  • 保留字段 metadata._mcp_headers(全局)与 _mcp_headers_by_server(按 MCP server URL 路由,优先于全局)
  • 白名单放行:authorization / cookie / x-tenant-id / x-user-id / x-request-id / x-trace-id
  • 拒绝 hop-by-hop 头(host、content-length、connection、transfer-encoding 等)
  • 单值上限 4KB,累计上限 16KB

注入引擎(management/services/agent/mcp_passthrough.py)

  • 提取 → 路由 → 合并 → 占位符解析四段式,全部收敛在单一模块
  • 静态优先:MCP server 级预设 header(含平台内部 token)不会被调用方覆盖;比较按 HTTP 语义做大小写不敏感处理,避免 Cookie/cookie 重复出站
  • 占位符:${HeaderName} 大小写不敏感解析;$${Name} 转义为字面量;开关关闭时不解析真实占位符并丢弃该条 header
  • fail-closed:任一占位符无法解析则整条 header 丢弃并告警;严格模式下直接返回 400
  • 审计日志只记录 header 名与占位符名,不记录任何值

错误码

码 含义 HTTP
010109 header key 被禁止(白名单外 / hop-by-hop) 400
010110 header 值超出大小限制 400
010111 _mcp_headers_by_server 的 URL 未匹配任何 MCP server 400
010112 严格模式下占位符未解析 400

prompt 隔离(sdk/nexent/core/agents/core_agent.py)

  • 序列化 runtime metadata 到 prompt 前递归剔除所有 _ 前缀键,保留字段不会泄漏进模型上下文

错误码透传修复

  • gateway/api/northbound.py 的兜底 except Exception 会吞掉 AppException 导致 500,已放行给全局处理器
  • services/northbound_service.py 原先把所有 metadata 校验错误统一降级为 010106,改为按校验码精确映射

测试

单元测试

  • 新增 test/backend/management/test_mcp_header_passthrough.py(覆盖提取、路由、静态优先、占位符、fail-closed、审计日志脱敏等)
  • 增补 test/backend/utils/test_runtime_metadata_utils.py(白名单、hop-by-hop、结构非法、4KB/16KB 边界)
  • 增补 test/sdk/core/agents/test_core_agent.py(保留字段剔除、不修改入参)
  • 增补 test/backend/services/test_northbound_service.py(错误码映射桩)

三套新增/增补用例 241 passed;回归 test_northbound_app.py 112 passed、test_northbound_service.py 118 passed。

集成验证

  • 数据层 14 条断言:开关门控、by_server 路由、URL 未匹配 400、严格模式 400、静态优先合并
  • 真实 MCP 出站抓包 5 条断言:静态 auth 保留、${Cookie} 解析、动态 header 注入、无字面量 ${ 残留
  • northbound 端到端:外部系统透传 5 个 header → runtime 记录 event=passthrough_injected → MCP 侧收到全部 header 且对话历史正常落库
a0d9ce26-0a29-469b-9031-782464b0b831

兼容性

  • 两个开关默认关闭,存量 agent 不传 metadata 时行为零变化
  • 未开启 allow_chat_metadata 的 agent 仍在准入层被 010105 拦截
  • 需要执行迁移 v2.9.0_009

@yunshuang-hw yunshuang-hw added the ai-assisted PR authored or co-authored with AI assistance label Nov 11, 2026
@codecov

codecov Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

@WMC001 WMC001 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有三处AI检视请处理,分析下实际影响看是否需要修改,分别涉及请求凭据的保存、Agent 开关的配置,以及北向接口的错误响应。

Comment thread backend/contracts/agent.py Resolved
yunshuang-hw added a commit that referenced this pull request Nov 12, 2026
1) repository.py:347 — MockAgent fixture has no allow_mcp_header_passthrough
   attr; switch direct access to getattr(default=False) same as the sibling
   context_policy / model_params_override fields.

2) subscription.py — inside the preflight exception branch we had a redundant
   rom contracts.errors.exceptions import AppException local import even
   though AppException is already imported at module level (line 31). Python
   treats any name bound in a function scope as local — so isinstance(exc,
   AppException) hit UnboundLocalError whenever preflight raised. Delete
   the local import, keep the module-level symbol.

3) subscription.py — move preflight_mcp_header_passthrough import to module
   level (tests can now monkeypatch with AsyncMock cleanly) AND wrap the
   call in an inspect.isawaitable guard so a plain MagicMock (returned when
   tests patch the whole preparation.run module) no longer raises
   'object MagicMock can't be used in await expression'.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: gpt-5-codex
yunshuang-hw and others added 9 commits November 12, 2026 15:06
Add per-agent switches (allow_mcp_header_passthrough,
strict_mcp_placeholder_resolution) and a ${HeaderName} placeholder engine
that lets northbound callers inject allowed HTTP headers into MCP sessions
via metadata._mcp_headers / _mcp_headers_by_server.

- migration + ORM/Pydantic for the two agent-level switches
- runtime_metadata validation for reserved fields (key whitelist, size limits)
- mcp_passthrough module: extraction, URL routing, static-priority merge,
  placeholder resolution with $${ escape, fail-closed + strict 400 mode
- run pipeline injects passthrough headers into mcp_host and logs auditable
  key-only events
- SDK prompt serialization strips underscore-prefixed reserved keys
- northbound error mapping for the new metadata validation codes

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: trae
Only add the headers key to mcp_host entries and only forward
runtime_metadata / metadata to the run payload when passthrough actually
contributes or the caller supplied metadata. This restores the previous
payload for agents that do not use the feature and drops two unused
bookkeeping flags. Register mcp_passthrough in the management service
architecture inventory.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: trae
Add cases for mode-less routing, switch-off by_server audit logging, header-less server shape preservation, blank header key predicate, serialization-failure wrapping, AppException propagation in the northbound chat route, and request metadata forwarding into agent run creation. Brings mcp_passthrough and runtime_metadata to full line and branch coverage.

Co-authored-by: Trae <noreply@trae.ai>

Generated-by: trae
…me metadata

Transport-only reserved fields (_mcp_headers, _mcp_headers_by_server, and any future _-prefixed key) were flowing straight from agent_request.metadata into the conversation runtime_metadata column. That leaked Cookie / Authorization values into conversation history even when the per-agent passthrough switch was OFF, and the SDK's prompt-only stripping never reached it.

Add strip_reserved_runtime_metadata_fields to the serialization module that already owns reserved-field validation. In subscription.py, compute a clean copy once after validation and route every persistence site (new conversation, workbench metadata update, debug snapshot, resolve_conversation_runtime_metadata) through it. agent_request.metadata itself is never mutated so apply_mcp_header_passthrough downstream still reads the transport view it needs.

Add three regression tests covering the strip helper (top-level underscore keys removed, non-dict inputs defensive, nested underscore keys preserved).

Co-authored-by: Trae <noreply@trae.ai>

Generated-by: trae
…t-export

The two Phase-1 agent switches (allow_mcp_header_passthrough, strict_mcp_placeholder_resolution) had been added to the DB ORM model and runtime record reader, but were missing from every inbound/outbound contract. That meant admins setting either flag on create or edit got silently discarded because the request model did not accept them, and even had they reached the DB they would disappear on re-import because the transfer and list/version response serializers did not emit them.

Add Optional[bool] entries to AgentInfoRequest following the allow_chat_metadata precedent. Update the service create kwargs dictionary, the repository create_agent return dict, transfer.py import and export serializers, the agent-management list response, the versions history response, and the frontend AgentConfig / Agent / AgentCreateAndUpdate types. The update path continues to flow automatically through filter_property on AgentInfo columns, which already included them.

Co-authored-by: Trae <noreply@trae.ai>

Generated-by: trae
apply_mcp_header_passthrough (010111 route-not-found / 010112 strict placeholder-unresolved)
ran inside generate_stream async generator body — by the time it raised AppException
the northbound endpoint had already committed to HTTP 200 via StreamingResponse,
so callers only ever saw an SSE error event, not the HTTP 400 + business codes
the Phase-1 spec promised.

Extract validate_mcp_header_passthrough as a pure pre-check from apply_mcp_header_
passthrough; apply calls it as its first step so the error semantics stay
identical everywhere apply is used. Add a new async preflight helper in
preparation/run.py that reuses the same AgentConfig + MCP-server lookup chain
the full run uses, builds a minimal mcp_host shape (URL + static headers), and
invokes the pure validator. subscription.run_agent_stream awaits this preflight
BEFORE building the StreamingResponse, so AppException 010111/010112 propagates
all the way up to FastAPI as HTTP 400. A narrow try/except around the preflight
only lets AppException propagate — any other exception logs and reraises as
500 so we surface code bugs, not hide them behind a generic SSE error.

Co-authored-by: Trae <noreply@trae.ai>

Generated-by: trae
…r-unresolved error code

Per current requirements the strict-mode contract for unresolved  placeholders
(fail-fast vs fail-closed) is not needed.  Remove the agent-level boolean switch
strict_mcp_placeholder_resolution, its dedicated business code 010112 /
CHAT_MCP_PLACEHOLDER_UNRESOLVED (and the matching RuntimeMetadataValidationCode),
and every read/write site that was introduced for it:

  * DB ORM + repository create_agent return shape
  * AgentInfoRequest + ExportAndImportAgentInfo contracts
  * service/transfer/management/versions serializers
  * northbound_service error-code mapping table
  * mcp_passthrough.py strict_mode branch inside validate_mcp_header_
    passthrough and the end-of-apply raise
  * preflight_mcp_header_passthrough docstring references to 010112
  * migration v2.9.0_009 dropped the strict ADD COLUMN / COMMENT entirely
    (dev-stage migration, no live DB to back out of)
  * frontend types AgentConfig / Agent / AgentCreateAndUpdate
  * tests: removed strict_mode fixture field and two strict-specific UT-011
    cases; retained the fail-closed default behaviour test

The remaining placeholder resolution still operates in fail-closed mode
(drops the unresolved header and continues) as the only supported contract.
allow_mcp_header_passthrough stays as the sole per-agent opt-in switch.

Co-authored-by: Trae <noreply@trae.ai>

Generated-by: trae
- preflight_mcp_header_passthrough was missing the required user_id
  argument; create_agent_config() demands it, so northbound run hit
  TypeError and surfaced as 500 instead of streaming.
- Remove the bogus local rom management.db.remote.mcp_server
  import shadow — those symbols are already imported at the top of
  run.py from management.services.mcp.* (the real module).
- subscription.run_agent_stream now passes user_id from its own
  function signature (AgentRequest has no user_id attribute).

Verified end-to-end with echo-headers MCP: external headers arrive
at every MCP HTTP call, runtime_metadata is stripped of reserved
fields, and preflight 400 still surfaces for unmatched by_server URLs.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: gpt-5-codex
1) repository.py:347 — MockAgent fixture has no allow_mcp_header_passthrough
   attr; switch direct access to getattr(default=False) same as the sibling
   context_policy / model_params_override fields.

2) subscription.py — inside the preflight exception branch we had a redundant
   rom contracts.errors.exceptions import AppException local import even
   though AppException is already imported at module level (line 31). Python
   treats any name bound in a function scope as local — so isinstance(exc,
   AppException) hit UnboundLocalError whenever preflight raised. Delete
   the local import, keep the module-level symbol.

3) subscription.py — move preflight_mcp_header_passthrough import to module
   level (tests can now monkeypatch with AsyncMock cleanly) AND wrap the
   call in an inspect.isawaitable guard so a plain MagicMock (returned when
   tests patch the whole preparation.run module) no longer raises
   'object MagicMock can't be used in await expression'.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: gpt-5-codex
@yunshuang-hw
yunshuang-hw force-pushed the t30057956-header-passthrough branch from 35905b7 to 89ccd0e Compare November 12, 2026 07:08
yunshuang-hw and others added 4 commits November 12, 2026 15:42
Codecov on PR #4329 landed at 80.77% patch coverage vs the 90% gate
target.  The gap was concentrated in three places:

* backend/management/services/agent/preparation/run.py — 38 missed lines
  (preflight_mcp_header_passthrough early-return + happy path + validate
  call; create_agent_run_info metadata kwarg conditional)
* backend/management/services/agent/subscription.py — 6 missed lines
  (the new try/except block wrapping the preflight call)
* backend/management/services/agent/mcp_passthrough.py — 3 missed lines

Add a new dedicated test module targeting run.py's preflight function,
which exercises:
  - early-return when metadata is None / has no reserved fields
  - switch-off still flows through validate (apply handles silent no-op)
  - happy path: AgentConfig + MCP list + local URL injection +
    static-header merge for Authorization + custom_headers
  - validate raises AppException -> propagates (covers subscription
    AppException arm)
  - isawaitable guard shape (subscription.py plain-MagicMock safety net)

Also covers the metadata-kwarg dict-spread pattern used in
create_agent_run_info so both branches (None vs present) have hits.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: gpt-5-codex
Two more unit tests to hit the previously-uncovered agent_record=None /
version-mismatch branch inside preflight_mcp_header_passthrough.

- run_blocking returns {} → agent_record empty → bail before AgentConfig
- run_blocking returns record with switch ON → proceeds to validate

With these the preflight function body (135 new lines) should be fully
covered end-to-end; run.py's missed-line count in the diff should drop
from 38 to near zero.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: gpt-5-codex
…rtion

Two fixes for CI failures on PR #4329:

1) 4 tests were hitting PostgreSQL at localhost:5432 because preflight's
   function-local 'from compiler import create_agent_config' bypasses the
   run_mod namespace entirely. Patching run_mod.create_agent_config was a
   silent no-op; the real create_agent_config ran and queried the DB.

   Verified in container: 'After run_mod patch, compiler.create_agent_config
   is sentinel: False' — the local import binds to the source module, so
   we must patch compiler/tools/mcp_passthrough directly.

   Also confirmed: get_remote_mcp_server_list and get_tenant_local_mcp_server
   are NOT re-imported inside preflight (module-level bindings only), so
   run_mod remains the correct patch target for those two.

2) test_preflight_call_site_isawaitable_guard asserted isawaitable(AsyncMock())
   which is always False — an AsyncMock object is not itself awaitable; the
   awaitable is the coroutine returned by *calling* it. Changed to
   isawaitable(async_mock()) which is True.

   Same for the MagicMock negative case: check isawaitable(plain_mock())
   (the call result the guard actually inspects), not the mock itself.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: gpt-5-codex
Two assertion fixes for CI failures on PR #4329:

1) test_preflight_happy_path asserted MCP URLs were direct keys of
   remote_dict. In fact run.py:191-195 builds it keyed by
   remote_mcp_server_name, with the URL inside each record. Now assert
   keys == {'echo-headers', 'outer-apis'} (disabled-one filtered out by
   status, outer-apis appended by preflight) and check URLs via the
   records' remote_mcp_server field.

2) test_preflight_validates_raises_appexception read exc_info.value.code,
   but AppException exposes .error_code (an ErrorCode enum) and takes the
   enum as its first constructor arg. Switched construction to
   AppException(ErrorCode.CHAT_MCP_HEADER_ROUTE_NOT_FOUND, ...) and assert
   on .error_code.

Both verified live in the runtime container:
  error_code == enum: True; has .code attr: False
  dict keys == {'echo-headers', 'outer-apis'}; urls match expected set.

Co-authored-by: Trae <noreply@trae.ai>
Generated-by: gpt-5-codex
@yunshuang-hw
yunshuang-hw merged commit 3de8384 into develop Nov 12, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted PR authored or co-authored with AI assistance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants