Repository navigation
feat(agent): support northbound MCP header passthrough (Phase 1) - #4329
Merged
Merged
Conversation
yunshuang-hw
requested review from
Dallas98,
Jasonxia007,
WMC001,
YehongPan and
jeffwu-1999
as code owners
November 11, 2026 07:01
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
WMC001
reviewed
Nov 12, 2026
Jasonxia007
requested changes
Nov 12, 2026
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
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
force-pushed
the
t30057956-header-passthrough
branch
from
November 12, 2026 07:08
35905b7 to
89ccd0e
Compare
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
Jasonxia007
approved these changes
Nov 12, 2026
jeffwu-1999
approved these changes
Nov 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
北向调用方(外部 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)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-idhost、content-length、connection、transfer-encoding等)注入引擎(
management/services/agent/mcp_passthrough.py)Cookie/cookie重复出站${HeaderName}大小写不敏感解析;$${Name}转义为字面量;开关关闭时不解析真实占位符并丢弃该条 header错误码
_mcp_headers_by_server的 URL 未匹配任何 MCP serverprompt 隔离(
sdk/nexent/core/agents/core_agent.py)_前缀键,保留字段不会泄漏进模型上下文错误码透传修复
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.py112 passed、test_northbound_service.py118 passed。集成验证
${Cookie}解析、动态 header 注入、无字面量${残留event=passthrough_injected→ MCP 侧收到全部 header 且对话历史正常落库兼容性
allow_chat_metadata的 agent 仍在准入层被010105拦截v2.9.0_009