From 3a554bc35693234f60992973e4088975b21c8d18 Mon Sep 17 00:00:00 2001 From: yunshuang-hw Date: Tue, 10 Nov 2026 19:30:51 +0800 Subject: [PATCH 1/3] feat(agent): support northbound MCP header passthrough (Phase 1) 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 Generated-by: trae --- backend/contracts/agent.py | 2 + backend/contracts/errors/codes.py | 14 + backend/contracts/errors/messages.py | 4 + backend/gateway/api/northbound.py | 4 + .../serialization/runtime_metadata.py | 136 +++++++ backend/management/db/agent/models.py | 14 + .../services/agent/mcp_passthrough.py | 350 +++++++++++++++++ .../services/agent/preparation/run.py | 14 + backend/management/services/agent/run.py | 1 + backend/services/northbound_service.py | 17 +- ...2.9.0_009_allow_mcp_header_passthrough.sql | 16 + sdk/nexent/core/agents/core_agent.py | 24 +- .../management/test_mcp_header_passthrough.py | 368 ++++++++++++++++++ .../services/test_northbound_service.py | 9 + .../utils/test_runtime_metadata_utils.py | 73 ++++ test/sdk/core/agents/test_core_agent.py | 42 ++ 16 files changed, 1083 insertions(+), 5 deletions(-) create mode 100644 backend/management/services/agent/mcp_passthrough.py create mode 100644 deploy/sql/migrations/v2.9.0_009_allow_mcp_header_passthrough.sql create mode 100644 test/backend/management/test_mcp_header_passthrough.py diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index ff8b1de9a4..7e0a5b785e 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -453,6 +453,8 @@ class ExportAndImportAgentInfo(BaseModel): is_main_agent: bool = True provide_run_summary: bool allow_chat_metadata: bool = False + allow_mcp_header_passthrough: bool = False + strict_mcp_placeholder_resolution: bool = False enable_protocol_repair_retry: bool = False verification_config: Optional[Dict[str, Any]] = None context_policy: Optional[Dict[str, Any]] = None diff --git a/backend/contracts/errors/codes.py b/backend/contracts/errors/codes.py index fcdce0865d..c6cb78b413 100644 --- a/backend/contracts/errors/codes.py +++ b/backend/contracts/errors/codes.py @@ -35,6 +35,12 @@ class RuntimeMetadataValidationCode(str, Enum): METADATA_TOO_DEEP = "METADATA_TOO_DEEP" METADATA_TOO_MANY_ITEMS = "METADATA_TOO_MANY_ITEMS" METADATA_TOO_LARGE = "METADATA_TOO_LARGE" + # Phase 1: MCP header passthrough + MCP_HEADER_TYPE_INVALID = "MCP_HEADER_TYPE_INVALID" + MCP_HEADER_KEY_FORBIDDEN = "MCP_HEADER_KEY_FORBIDDEN" + MCP_HEADER_VALUE_TOO_LARGE = "MCP_HEADER_VALUE_TOO_LARGE" + MCP_HEADER_ROUTE_NOT_FOUND = "MCP_HEADER_ROUTE_NOT_FOUND" + MCP_PLACEHOLDER_UNRESOLVED = "MCP_PLACEHOLDER_UNRESOLVED" class ErrorCode(Enum): @@ -78,6 +84,10 @@ class ErrorCode(Enum): CHAT_METADATA_INVALID = "010106" # Runtime metadata is invalid CHAT_METADATA_TOO_LARGE = "010107" # Runtime metadata is too large CHAT_METADATA_VERSION_CONFLICT = "010108" # Runtime metadata version conflict + CHAT_MCP_HEADER_KEY_FORBIDDEN = "010109" # Reserved _mcp_headers field contains disallowed header keys + CHAT_MCP_HEADER_VALUE_TOO_LARGE = "010110" # Reserved _mcp_headers field contains oversized values + CHAT_MCP_HEADER_ROUTE_NOT_FOUND = "010111" # _mcp_headers_by_server URL does not match any configured MCP server + CHAT_MCP_PLACEHOLDER_UNRESOLVED = "010112" # Strict mode: unresolved ${HeaderName} placeholders in MCP static headers # ==================== 02 QuickConfig / 快速配置 ==================== # 01 - Configuration @@ -325,6 +335,10 @@ class ErrorCode(Enum): ErrorCode.CHAT_METADATA_INVALID: 422, ErrorCode.CHAT_METADATA_TOO_LARGE: 413, ErrorCode.CHAT_METADATA_VERSION_CONFLICT: 409, + ErrorCode.CHAT_MCP_HEADER_KEY_FORBIDDEN: 400, + ErrorCode.CHAT_MCP_HEADER_VALUE_TOO_LARGE: 400, + ErrorCode.CHAT_MCP_HEADER_ROUTE_NOT_FOUND: 400, + ErrorCode.CHAT_MCP_PLACEHOLDER_UNRESOLVED: 400, ErrorCode.TENANT_PERSONAL_KB_QUOTA_EXCEEDED: 403, ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE: 503, ErrorCode.TENANT_PERSONAL_KB_QUOTA_BELOW_USAGE: 400, diff --git a/backend/contracts/errors/messages.py b/backend/contracts/errors/messages.py index 1292a78497..a075701c9f 100644 --- a/backend/contracts/errors/messages.py +++ b/backend/contracts/errors/messages.py @@ -47,6 +47,10 @@ class ErrorMessage: ErrorCode.CHAT_METADATA_INVALID: "Runtime metadata is invalid.", ErrorCode.CHAT_METADATA_TOO_LARGE: "Runtime metadata exceeds the maximum allowed size.", ErrorCode.CHAT_METADATA_VERSION_CONFLICT: "Runtime metadata was updated by another request.", + ErrorCode.CHAT_MCP_HEADER_KEY_FORBIDDEN: "Reserved _mcp_headers field contains disallowed header keys (hop-by-hop or non-whitelisted).", + ErrorCode.CHAT_MCP_HEADER_VALUE_TOO_LARGE: "Reserved _mcp_headers field contains oversized values (>4KB per header or >16KB total).", + ErrorCode.CHAT_MCP_HEADER_ROUTE_NOT_FOUND: "_mcp_headers_by_server URLs do not match any configured MCP server.", + ErrorCode.CHAT_MCP_PLACEHOLDER_UNRESOLVED: "MCP static headers contain unresolved ${HeaderName} placeholders (strict mode).", # ==================== 02 QuickConfig / 快速配置 ==================== ErrorCode.QUICK_CONFIG_INVALID: "Invalid configuration.", ErrorCode.QUICK_CONFIG_SYNC_FAILED: "Sync configuration failed.", diff --git a/backend/gateway/api/northbound.py b/backend/gateway/api/northbound.py index 4a618dea08..c6bbaa1100 100644 --- a/backend/gateway/api/northbound.py +++ b/backend/gateway/api/northbound.py @@ -441,6 +441,10 @@ async def run_chat( raise HTTPException(status_code=HTTPStatus.GATEWAY_TIMEOUT, detail=str(e)) from e except RuntimeServiceUnavailableError as e: raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=str(e)) from e + except AppException: + # Let the global AppException handler map error codes to HTTP status + # (runtime metadata / MCP header passthrough failures rely on this). + raise except HTTPException as e: raise e except Exception as e: diff --git a/backend/infrastructure/serialization/runtime_metadata.py b/backend/infrastructure/serialization/runtime_metadata.py index 44c9c63e5c..420a4e5c0d 100644 --- a/backend/infrastructure/serialization/runtime_metadata.py +++ b/backend/infrastructure/serialization/runtime_metadata.py @@ -19,6 +19,137 @@ from contracts.constants.constants import MAX_RUNTIME_METADATA_KEY_LENGTH +# --------------------------------------------------------------------------- +# Phase 1: northbound MCP header passthrough — reserved-field whitelist rules +# --------------------------------------------------------------------------- + +# Max size of one MCP header value (4 KB as UTF-8 bytes). +MAX_MCP_HEADER_VALUE_BYTES = 4 * 1024 + +# Max cumulative size of all injected MCP header values across one run (16 KB). +MAX_MCP_HEADERS_TOTAL_BYTES = 16 * 1024 + +# Header name whitelist. Lowercased on input so the comparison is +# case-insensitive and HTTP-correct. +ALLOWED_MCP_HEADER_KEYS = frozenset({ + "authorization", + "cookie", + "x-tenant-id", + "x-user-id", + "x-request-id", + "x-trace-id", +}) + +# Hop-by-hop headers that MUST NEVER be injected through metadata. +# See RFC 7230 §6.1 and RFC 8620 §13.5.1. +FORBIDDEN_HOP_BY_HOP_KEYS = frozenset({ + "host", + "content-length", + "connection", + "keep-alive", + "transfer-encoding", + "upgrade", + "te", + "trailer", + "proxy-connection", + "proxy-authorization", + "proxy-authenticate", +}) + + +def _is_passthrough_header_allowed(key: str) -> bool: + """Return True iff ``key`` is a header name we will accept for passthrough. + + - ``key`` is lowercased before every comparison. + - Hop-by-hop headers are rejected even when they would otherwise match + a broader pattern (defense-in-depth). + - Whitelist entries and any ``x-*`` custom header are accepted. + """ + normalized = key.strip().lower() + if not normalized: + return False + if normalized in FORBIDDEN_HOP_BY_HOP_KEYS: + return False + if normalized in ALLOWED_MCP_HEADER_KEYS: + return True + return normalized.startswith("x-") + + +def _validate_single_mcp_header_dict(headers: Any) -> int: + """Validate one ``Dict[str, str]`` header block. + + Returns the cumulative UTF-8 byte count of all header values. Raises + :class:`RuntimeMetadataValidationError` on any structural failure. + """ + if not isinstance(headers, dict): + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_TYPE_INVALID, + "Reserved _mcp_headers* fields must be objects of string -> string", + ) + + total_bytes = 0 + for raw_key, value in headers.items(): + if not isinstance(raw_key, str) or not raw_key.strip(): + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_TYPE_INVALID, + "Header keys must be non-empty strings", + ) + if not isinstance(value, str): + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_TYPE_INVALID, + f"Header value for '{raw_key}' must be a string", + ) + if not _is_passthrough_header_allowed(raw_key): + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_KEY_FORBIDDEN, + f"Header key '{raw_key}' is not whitelisted for MCP passthrough", + ) + byte_len = len(value.encode("utf-8")) + if byte_len > MAX_MCP_HEADER_VALUE_BYTES: + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_VALUE_TOO_LARGE, + f"Header value for '{raw_key}' exceeds {MAX_MCP_HEADER_VALUE_BYTES} bytes", + ) + total_bytes += byte_len + return total_bytes + + +def _validate_mcp_headers_reserved_fields(metadata: Dict[str, Any]) -> None: + """Validate the ``_mcp_headers`` / ``_mcp_headers_by_server`` reserved fields. + + Raises :class:`RuntimeMetadataValidationError` for any structural, size, + or whitelist failure. When both reserved forms are present the + ``_mcp_headers_by_server`` shape wins (``_mcp_headers`` is ignored, but + its structural validity is still checked so the caller is not + surprised by a half-valid payload). + """ + total_bytes = 0 + seen = False + + if "_mcp_headers" in metadata and metadata["_mcp_headers"] is not None: + seen = True + total_bytes += _validate_single_mcp_header_dict(metadata["_mcp_headers"]) + + if "_mcp_headers_by_server" in metadata and metadata["_mcp_headers_by_server"] is not None: + if not isinstance(metadata["_mcp_headers_by_server"], dict): + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_TYPE_INVALID, + "_mcp_headers_by_server must be an object of server URL -> headers", + ) + seen = True + for url, block in metadata["_mcp_headers_by_server"].items(): + if not isinstance(url, str) or not url.strip(): + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_TYPE_INVALID, + "_mcp_headers_by_server keys must be non-empty MCP server URLs", + ) + total_bytes += _validate_single_mcp_header_dict(block) + + if seen and total_bytes > MAX_MCP_HEADERS_TOTAL_BYTES: + raise RuntimeMetadataValidationError( + RuntimeMetadataValidationCode.MCP_HEADER_VALUE_TOO_LARGE, + f"Total size of injected MCP headers exceeds {MAX_MCP_HEADERS_TOTAL_BYTES} bytes", + ) def canonical_runtime_metadata_json(metadata: Mapping[str, Any]) -> str: @@ -125,4 +256,9 @@ def visit(node: Any, depth: int) -> None: f"Runtime metadata exceeds {MAX_RUNTIME_METADATA_BYTES} bytes", ) + # Phase 1: northbound MCP header passthrough reserved-field validation. + # Runs AFTER the generic structural checks so callers get a single, + # layered stack of errors rather than two competing reasons. + _validate_mcp_headers_reserved_fields(value) + return value diff --git a/backend/management/db/agent/models.py b/backend/management/db/agent/models.py index eef9d8b6cb..1ad4d11abf 100644 --- a/backend/management/db/agent/models.py +++ b/backend/management/db/agent/models.py @@ -107,6 +107,20 @@ class AgentInfo(TableBase): server_default=text("false"), doc="Whether Native Chat and Debug users may submit runtime metadata", ) + allow_mcp_header_passthrough = Column( + Boolean, + default=False, + nullable=False, + server_default=text("false"), + doc="Whether northbound callers may inject per-request HTTP headers into the MCP session via metadata._mcp_headers*", + ) + strict_mcp_placeholder_resolution = Column( + Boolean, + default=False, + nullable=False, + server_default=text("false"), + doc="Whether unresolved ${HeaderName} placeholders in MCP static headers abort the run with 400 (enabled) or fail-closed by dropping the header and continuing (disabled, default)", + ) greeting_message = Column(Text, doc="Agent greeting message displayed on chat initial screen") example_questions = Column(JSONB, doc="List of example questions for starting a conversation with this agent") icon_url = Column(String(1024), doc="Object storage key for the agent icon") diff --git a/backend/management/services/agent/mcp_passthrough.py b/backend/management/services/agent/mcp_passthrough.py new file mode 100644 index 0000000000..ab7972a5c9 --- /dev/null +++ b/backend/management/services/agent/mcp_passthrough.py @@ -0,0 +1,350 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Northbound MCP header passthrough — Phase 1 helpers. + +This module owns every piece of logic that bridges a northbound ``metadata`` +reserved field (``_mcp_headers`` / ``_mcp_headers_by_server``) into the MCP +HTTP session, plus the ``${HeaderName}`` placeholder resolution engine that +lets admin-authored MCP static headers defer identity fields to the request. + +Design contract in ``nexent-doc/nexent/northbound-mcp-header-passthrough/``. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, List, Optional, Tuple + +from contracts.errors.codes import ErrorCode +from contracts.errors.exceptions import AppException + + +logger = logging.getLogger("northbound-mcp-passthrough") + + +# --------------------------------------------------------------------------- +# 1. Reserved-field extraction +# --------------------------------------------------------------------------- + +def _lowercase_keys(d: Dict[str, Any]) -> Dict[str, Any]: + return {str(k).lower(): v for k, v in d.items()} + + +def extract_passthrough_headers( + metadata: Optional[Dict[str, Any]], +) -> Tuple[Optional[str], Dict[str, Any]]: + """Return ``(mode, headers_map)`` from ``metadata`` reserved fields. + + ``mode`` is one of ``"by_server"`` (URL-routed) / ``"global"`` (one + header block for every MCP server) / ``None`` (no reserved fields). + ``by_server`` always wins when both forms are present — ``_mcp_headers`` + is silently ignored so callers do not get surprise double-injection. + All key names in the returned map are lowercased; values are kept as-is. + """ + if not metadata: + return None, {} + + by_server = metadata.get("_mcp_headers_by_server") + if isinstance(by_server, dict): + return "by_server", { + str(url): _lowercase_keys(block) + for url, block in by_server.items() + if isinstance(block, dict) + } + + global_headers = metadata.get("_mcp_headers") + if isinstance(global_headers, dict): + return "global", _lowercase_keys(global_headers) + + return None, {} + + +# --------------------------------------------------------------------------- +# 2. Routing + static-priority merge +# --------------------------------------------------------------------------- + +def route_passthrough_headers( + mode: Optional[str], + headers_map: Dict[str, Any], + url: str, +) -> Dict[str, str]: + """Resolve per-server passthrough headers from the extracted map.""" + if not mode: + return {} + if mode == "by_server": + block = headers_map.get(url) or {} + return dict(block) # shallow copy so callers may mutate + return dict(headers_map) + + +def merge_static_priority( + existing: Optional[Dict[str, Any]], + dynamic: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Merge dynamic headers into the static preset with STATIC priority. + + Any key already present in ``existing`` is preserved; dynamic values + only fill gaps. Comparison is **case-insensitive** because HTTP header + names are case-insensitive — otherwise a static ``Cookie`` and a dynamic + ``cookie`` would both be emitted as duplicate headers. + + This keeps MCP-server-level auth tokens (e.g. the + ``X-Nexent-Internal-Token`` header injected for the local tenant MCP) + from being overwritten by a caller-supplied ``Authorization``. + """ + result: Dict[str, Any] = dict(existing or {}) + occupied = {str(k).lower() for k in result} + for k, v in (dynamic or {}).items(): + if str(k).lower() not in occupied: + result[k] = v + occupied.add(str(k).lower()) + return result + + +# --------------------------------------------------------------------------- +# 3. Placeholder engine (${Name} with $${ escape + fail-closed semantics) +# --------------------------------------------------------------------------- + +# Matches either $${Name} (escape → literal ${Name}) or ${Name} (resolve). +_PLACEHOLDER_RE = re.compile(r"\$\$\{([A-Za-z0-9_-]+)\}|\$\{([A-Za-z0-9_-]+)\}") + + +def resolve_placeholders( + static_headers: Optional[Dict[str, Any]], + context_headers: Dict[str, str], + *, + passthrough_allowed: bool, + run_id: str, + agent_id: int, + server_url: str, +) -> Tuple[Dict[str, Any], List[str], List[str]]: + """Substitute ``${Name}`` placeholders inside ``static_headers`` values. + + Returns a 3-tuple: + (resolved_headers, resolved_names, unresolved_names) + + Rules baked in here (never compromised by callers): + + * **Switch-gated.** ``passthrough_allowed=False`` discards every header + whose value contains a real ``${...}`` placeholder — we never ship a + literal ``${Cookie}`` to an MCP server, even when the admin config got + pasted in by accident. ``$${Name}`` escapes survive as literal text. + * **Case-insensitive.** Context headers are matched after lowercasing + the placeholder name, matching HTTP header semantics. + * **Fail-closed.** Any placeholder that cannot be resolved causes the + whole header entry to be dropped (we log which names were missing so + ops can fix the admin config), not just unset. + * **No placeholders → no-op.** Plain literal values are returned as-is. + """ + if not static_headers: + return {}, [], [] + + resolved: Dict[str, Any] = {} + resolved_names: List[str] = [] + unresolved_names: List[str] = [] + + for name, raw_value in static_headers.items(): + if not isinstance(raw_value, str) or "${" not in raw_value: + # No placeholder at all — keep as-is (includes non-string values). + resolved[name] = raw_value + continue + + if not passthrough_allowed: + # Agent switch is OFF: drop every header that still contains a + # REAL ${...} placeholder. Fully-escaped $${...} entries are + # converted to literal ${...} and KEPT — the admin explicitly + # typed them as literals and we must not interpret them. + has_real_placeholder = False + for m in _PLACEHOLDER_RE.finditer(raw_value): + if m.group(2) is not None: + has_real_placeholder = True + break + + if has_real_placeholder: + logger.warning( + "agent_id=%s run_id=%s server_url=%s " + "event=placeholder_dropped_switch_off header=%s", + agent_id, run_id, server_url, name, + ) + continue + + processed = _PLACEHOLDER_RE.sub( + lambda m: "${" + m.group(1) + "}" if m.group(1) is not None else m.group(0), + raw_value, + ) + resolved[name] = processed + continue + + # Switch ON — attempt real resolution. + seen_placeholders: List[str] = [] + + def _sub(match: "re.Match[str]") -> str: + escaped = match.group(1) # $${Name} case + real = match.group(2) # ${Name} case + if escaped is not None: + return "${" + escaped + "}" + # Real placeholder — remember for resolution tracking. + key = real.lower() + seen_placeholders.append(key) + return context_headers.get(key, match.group(0)) + + processed = _PLACEHOLDER_RE.sub(_sub, raw_value) + + if seen_placeholders: + missing = [n for n in seen_placeholders if context_headers.get(n) is None] + if missing: + # Fail-closed: drop the whole header entry. + unresolved_names.extend(missing) + logger.warning( + "agent_id=%s run_id=%s server_url=%s " + "event=placeholder_unresolved header=%s unresolved=%s", + agent_id, run_id, server_url, name, sorted(set(missing)), + ) + continue + resolved_names.extend(seen_placeholders) + + resolved[name] = processed + + return resolved, sorted(set(resolved_names)), sorted(set(unresolved_names)) + + +# --------------------------------------------------------------------------- +# 4. Top-level orchestration — modifies mcp_host in place +# --------------------------------------------------------------------------- + +def apply_mcp_header_passthrough( + mcp_host: List[Any], + metadata: Optional[Dict[str, Any]], + *, + agent_id: int, + tenant_id: str, + version_no: int, + run_id: str, +) -> None: + """Run every Phase 1 step against ``mcp_host`` in place. + + Call this AFTER the MCP host list has been assembled (static auth + + custom headers from DB are already present) but BEFORE the list is + handed to ``build_run_info``. The function is deliberately defensive: + a malformed ``mcp_host`` list leaves no trace, and a missing agent + record is treated as "passthrough disabled" — never open the flood + gate on a failed lookup. + """ + if not isinstance(mcp_host, list) or not mcp_host: + return + + from management.db.agent.repository import search_agent_info_by_agent_id + + try: + agent_record = search_agent_info_by_agent_id( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) or {} + except Exception: + # Never open the flood gate on a failed lookup. + logger.warning( + "agent_id=%s run_id=%s event=agent_lookup_failed passthrough_disabled", + agent_id, run_id, + ) + agent_record = {} + + passthrough_allowed = bool( + agent_record.get("allow_mcp_header_passthrough", False) + ) + strict_mode = bool( + agent_record.get("strict_mcp_placeholder_resolution", False) + ) + + mode, passthrough_headers = extract_passthrough_headers(metadata) + + # by_server routing: every URL key must match a configured MCP server, + # otherwise the caller thinks they injected a header that silently went + # nowhere. Surface it as a dedicated 400 instead of a silent no-op. + if mode == "by_server" and passthrough_headers: + configured_urls = { + str(item["url"]) + for item in mcp_host + if isinstance(item, dict) and "url" in item + } + unmatched = sorted(set(passthrough_headers) - configured_urls) + if unmatched: + raise AppException( + ErrorCode.CHAT_MCP_HEADER_ROUTE_NOT_FOUND, + details={"unmatched_server_urls": unmatched}, + ) + + total_unresolved: List[str] = [] + had_injection = False + had_resolution = False + + for item in mcp_host: + if not isinstance(item, dict) or "url" not in item: + # Fallback string URLs carry no headers — skip silently. + continue + + server_url = str(item["url"]) + current_headers = item.get("headers") or {} + + # ---- Placeholder resolution (uses metadata headers as context) ---- + context_block = ( + passthrough_headers.get(server_url) + if mode == "by_server" + else passthrough_headers + ) or {} + + resolved, resolved_names, unresolved = resolve_placeholders( + current_headers, + context_block, + passthrough_allowed=passthrough_allowed, + run_id=run_id, + agent_id=agent_id, + server_url=server_url, + ) + total_unresolved.extend(unresolved) + + # ---- Dynamic injection (static priority merge) ---- + if passthrough_allowed and mode is not None: + dynamic = route_passthrough_headers(mode, passthrough_headers, server_url) + item["headers"] = merge_static_priority(resolved, dynamic) + if dynamic: + had_injection = True + logger.info( + "agent_id=%s run_id=%s server_url=%s " + "event=passthrough_injected keys=%s", + agent_id, run_id, server_url, sorted(dynamic.keys()), + ) + else: + item["headers"] = resolved + + if resolved_names: + had_resolution = True + logger.info( + "agent_id=%s run_id=%s server_url=%s " + "event=placeholder_resolved placeholders=%s", + agent_id, run_id, server_url, sorted(set(resolved_names)), + ) + + # Strict mode: unresolved placeholders abort the run with 400. + if strict_mode and total_unresolved: + raise AppException( + ErrorCode.CHAT_MCP_PLACEHOLDER_UNRESOLVED, + details={"unresolved_placeholders": sorted(set(total_unresolved))}, + ) + + # Audit: caller supplied reserved fields but the agent switch is OFF. + if not passthrough_allowed and mode is not None: + raw_keys: List[str] + if mode == "global": + raw_keys = sorted(passthrough_headers.keys()) + else: + raw_keys = sorted({ + k for block in passthrough_headers.values() + for k in block.keys() + }) + logger.warning( + "agent_id=%s run_id=%s reason=agent_switch_off " + "mode=%s raw_keys=%s", + agent_id, run_id, mode, raw_keys, + ) diff --git a/backend/management/services/agent/preparation/run.py b/backend/management/services/agent/preparation/run.py index 12a1df5332..9bf4835c26 100644 --- a/backend/management/services/agent/preparation/run.py +++ b/backend/management/services/agent/preparation/run.py @@ -37,6 +37,7 @@ join_minio_file_description_to_query, ) from management.services.agent.preparation.tools import filter_mcp_servers_and_tools +from management.services.agent.mcp_passthrough import apply_mcp_header_passthrough from management.services.agent.tool_identity import resolve_tool_user_context from management.services.file.service import validate_urls_access from management.services.mcp.addressing import get_tenant_local_mcp_server @@ -113,6 +114,7 @@ async def create_agent_run_info( runtime_knowledge_tools: Optional[List[Dict[str, Any]]] = None, runtime_sub_agent_mounts: Optional[List[Dict[str, Any]]] = None, model_parameter_preferences: Optional[Dict[str, Dict[str, Any]]] = None, + metadata: Optional[Dict[str, Any]] = None, ): if override_model_id is not None: from management.services.model.run_controls import validate_selected_record @@ -363,6 +365,17 @@ async def create_agent_run_info( # Fallback to string format if record not found mcp_host.append(url) + # Phase 1: northbound MCP header passthrough — placeholder resolution + # + dynamic header injection, both gated by the per-agent switch. + apply_mcp_header_passthrough( + mcp_host, + metadata, + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + run_id=workspace_run_id, + ) + # Convert HistoryItem (from API) to AgentHistory (expected by SDK) converted_history = _convert_history_with_minio_files(history, language=language) @@ -417,6 +430,7 @@ async def create_agent_run_info( language=language, agent_config=agent_config, mcp_host=mcp_host, + runtime_metadata=metadata or {}, mcp_request_timeout_seconds=MCP_REQUEST_TIMEOUT_SECONDS, history=converted_history, mcp_tool_timeout_seconds=RUNTIME_MCP_TOOL_TIMEOUT_SECONDS, diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index a44aff0185..4c95c86ece 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -150,6 +150,7 @@ async def prepare_agent_run( "conversation_id": agent_request.conversation_id, "context_policy": agent_request.context_policy, "enable_planning": agent_request.enable_plan, + "metadata": agent_request.metadata, } runtime_knowledge_context = getattr( agent_request, "_runtime_knowledge_context", None diff --git a/backend/services/northbound_service.py b/backend/services/northbound_service.py index 147a7c6320..009c53bb93 100644 --- a/backend/services/northbound_service.py +++ b/backend/services/northbound_service.py @@ -55,6 +55,17 @@ logger = logging.getLogger("northbound_service") +# Runtime metadata validation reason -> northbound API error code. +# MCP header passthrough failures (Phase 1) map to their dedicated codes so +# callers can distinguish "bad reserved field" from generic metadata errors. +_RUNTIME_METADATA_ERROR_CODE_MAP = { + RuntimeMetadataValidationCode.METADATA_TOO_LARGE: ErrorCode.CHAT_METADATA_TOO_LARGE, + RuntimeMetadataValidationCode.MCP_HEADER_KEY_FORBIDDEN: ErrorCode.CHAT_MCP_HEADER_KEY_FORBIDDEN, + RuntimeMetadataValidationCode.MCP_HEADER_VALUE_TOO_LARGE: ErrorCode.CHAT_MCP_HEADER_VALUE_TOO_LARGE, + RuntimeMetadataValidationCode.MCP_HEADER_ROUTE_NOT_FOUND: ErrorCode.CHAT_MCP_HEADER_ROUTE_NOT_FOUND, + RuntimeMetadataValidationCode.MCP_PLACEHOLDER_UNRESOLVED: ErrorCode.CHAT_MCP_PLACEHOLDER_UNRESOLVED, +} + def _build_northbound_file_descriptor( @@ -373,10 +384,8 @@ async def start_streaming_chat( try: validate_runtime_metadata(metadata) except RuntimeMetadataValidationError as exc: - error_code = ( - ErrorCode.CHAT_METADATA_TOO_LARGE - if exc.code == RuntimeMetadataValidationCode.METADATA_TOO_LARGE - else ErrorCode.CHAT_METADATA_INVALID + error_code = _RUNTIME_METADATA_ERROR_CODE_MAP.get( + exc.code, ErrorCode.CHAT_METADATA_INVALID ) raise AppException( error_code, diff --git a/deploy/sql/migrations/v2.9.0_009_allow_mcp_header_passthrough.sql b/deploy/sql/migrations/v2.9.0_009_allow_mcp_header_passthrough.sql new file mode 100644 index 0000000000..9cf856b388 --- /dev/null +++ b/deploy/sql/migrations/v2.9.0_009_allow_mcp_header_passthrough.sql @@ -0,0 +1,16 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +-- Phase 1 of northbound MCP header passthrough feature: per-agent opt-in switches. +-- Defaults are FALSE so existing agents are not affected. + +ALTER TABLE nexent.ag_tenant_agent_t + ADD COLUMN IF NOT EXISTS allow_mcp_header_passthrough BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN nexent.ag_tenant_agent_t.allow_mcp_header_passthrough IS + 'Whether this agent allows northbound callers to inject per-request HTTP headers into the MCP session via metadata._mcp_headers*; disabled by default to prevent silent cross-tenant header leakage'; + +ALTER TABLE nexent.ag_tenant_agent_t + ADD COLUMN IF NOT EXISTS strict_mcp_placeholder_resolution BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN nexent.ag_tenant_agent_t.strict_mcp_placeholder_resolution IS + 'Whether unresolved ${HeaderName} placeholders in MCP static headers abort the run with 400 (enabled) or fail-closed by dropping the header and continuing (disabled, default)'; diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index 5f1b3b5388..d4053f55b6 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -87,6 +87,27 @@ def _extract_final_answer_body(output: Any) -> str | None: return re.sub(r"", "", text[opening.end():], flags=re.IGNORECASE) if opening else None +def _strip_reserved_metadata_keys(node: Any) -> Any: + """Recursively remove every top-level and nested dict entry whose key + starts with an underscore. This keeps Phase-1 reserved fields like + ``_mcp_headers`` / ``_mcp_headers_by_server`` out of the LLM prompt + while leaving the rest of the metadata intact. + + The filter runs on a shallow copy of every dict so the caller-owned + runtime metadata is never mutated in place. + """ + if isinstance(node, dict): + cleaned: Dict[str, Any] = {} + for key, value in node.items(): + if isinstance(key, str) and key.startswith("_"): + continue + cleaned[key] = _strip_reserved_metadata_keys(value) + return cleaned + if isinstance(node, list): + return [_strip_reserved_metadata_keys(item) for item in node] + return node + + class NonterminalThoughtTurn(Exception): """A model turn produced visible text without an executable action.""" @@ -1874,8 +1895,9 @@ def run(self, task: str, stream: bool = False, reset: bool = True, images: Optio if other_args: self.task += render_user_context(getattr(self, "lang", "en"), "additional_arguments", {"arguments": str(other_args)}) if runtime_metadata is not None: + runtime_metadata_for_prompt = _strip_reserved_metadata_keys(runtime_metadata) serialized_metadata = json.dumps( - runtime_metadata, + runtime_metadata_for_prompt, ensure_ascii=False, sort_keys=True, separators=(",", ":"), diff --git a/test/backend/management/test_mcp_header_passthrough.py b/test/backend/management/test_mcp_header_passthrough.py new file mode 100644 index 0000000000..286d34268b --- /dev/null +++ b/test/backend/management/test_mcp_header_passthrough.py @@ -0,0 +1,368 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Unit tests for Phase 1 northbound MCP header passthrough. + +Covers the ``mcp_passthrough`` engine and the reserved-field validation +contract documented in +``nexent-doc/nexent/northbound-mcp-header-passthrough``. + +Case map: UT-BE-PASS-001/002/003/006/008/009/010/011/012. +UT-BE-PASS-004/005 (validation whitelist + size limits) live next to the +validator in ``test_runtime_metadata_utils.py``; UT-BE-PASS-007 (prompt +filter) lives with the SDK agent tests. +""" + +import json +import logging + +import pytest + +from contracts.errors.codes import ErrorCode +from contracts.errors.exceptions import AppException +from management.services.agent.mcp_passthrough import ( + apply_mcp_header_passthrough, + extract_passthrough_headers, + merge_static_priority, + resolve_placeholders, + route_passthrough_headers, +) + +TENANT = "tenant-1" +RUN = "run-1" + + +def _agent_record(**overrides): + record = { + "allow_mcp_header_passthrough": True, + "strict_mcp_placeholder_resolution": False, + } + record.update(overrides) + return record + + +@pytest.fixture +def agent_switch(monkeypatch): + """Control what the per-agent switch lookup returns for one test.""" + state = {"record": _agent_record()} + + def _lookup(**_kwargs): + record = state["record"] + if isinstance(record, Exception): + raise record + return record + + from management.db.agent import repository + + monkeypatch.setattr(repository, "search_agent_info_by_agent_id", _lookup) + return state + + +def _apply(hosts, metadata, *, version_no=4): + apply_mcp_header_passthrough( + hosts, + metadata, + agent_id=1, + tenant_id=TENANT, + version_no=version_no, + run_id=RUN, + ) + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-001 — global headers fan out to every MCP server +# --------------------------------------------------------------------------- + +def test_ut_be_pass_001_global_headers_inject_into_every_server(agent_switch): + hosts = [ + {"url": "http://a/mcp", "transport": "streamable-http", + "headers": {"X-Tenant-ID": "t-1"}}, + {"url": "http://b/mcp", "transport": "streamable-http", + "headers": {"Authorization": "Bearer server-token"}}, + ] + + _apply(hosts, {"_mcp_headers": {"Cookie": "sid=1", "X-User-Id": "u1"}}) + + assert hosts[0]["headers"] == { + "X-Tenant-ID": "t-1", + "cookie": "sid=1", + "x-user-id": "u1", + } + assert hosts[1]["headers"] == { + "Authorization": "Bearer server-token", + "cookie": "sid=1", + "x-user-id": "u1", + } + + +def test_ut_be_pass_001_extra_metadata_keys_do_not_leak_into_headers(agent_switch): + hosts = [{"url": "http://a/mcp", "headers": {}}] + + _apply(hosts, {"project_id": "P001", "_mcp_headers": {"Cookie": "sid=1"}}) + + assert hosts[0]["headers"] == {"cookie": "sid=1"} + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-002 — per-server URL routing +# --------------------------------------------------------------------------- + +def test_ut_be_pass_002_by_server_headers_route_by_exact_url(agent_switch): + hosts = [{"url": "http://a/mcp", "headers": {}}, {"url": "http://b/mcp", "headers": {}}] + + _apply(hosts, { + "_mcp_headers_by_server": { + "http://a/mcp": {"Cookie": "sid=a"}, + "http://b/mcp": {"X-Trace-Id": "trace-9"}, + } + }) + + assert hosts[0]["headers"] == {"cookie": "sid=a"} + assert hosts[1]["headers"] == {"x-trace-id": "trace-9"} + + +def test_ut_be_pass_002_by_server_wins_over_global_form(agent_switch): + hosts = [{"url": "http://a/mcp", "headers": {}}] + + _apply(hosts, { + "_mcp_headers": {"Cookie": "global"}, + "_mcp_headers_by_server": {"http://a/mcp": {"Cookie": "routed"}}, + }) + + assert hosts[0]["headers"] == {"cookie": "routed"} + + +def test_ut_be_pass_002_unmatched_server_url_raises_route_not_found(agent_switch): + hosts = [{"url": "http://a/mcp", "headers": {}}] + + with pytest.raises(AppException) as exc_info: + _apply(hosts, {"_mcp_headers_by_server": {"http://other/mcp": {"Cookie": "s"}}}) + + assert exc_info.value.error_code == ErrorCode.CHAT_MCP_HEADER_ROUTE_NOT_FOUND + assert exc_info.value.details == {"unmatched_server_urls": ["http://other/mcp"]} + assert exc_info.value.http_status == 400 + + +def test_extract_and_route_helpers_normalize_header_keys(): + mode, headers = extract_passthrough_headers({"_mcp_headers": {"Cookie": "sid=1"}}) + assert mode == "global" + assert route_passthrough_headers(mode, headers, "http://a/mcp") == {"cookie": "sid=1"} + + mode, headers = extract_passthrough_headers( + {"_mcp_headers_by_server": {"http://a/mcp": {"X-User-Id": "u1"}}} + ) + assert mode == "by_server" + assert route_passthrough_headers(mode, headers, "http://b/mcp") == {} + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-003 — agent switch OFF is a silent no-op +# --------------------------------------------------------------------------- + +def test_ut_be_pass_003_switch_off_ignores_supplied_headers(agent_switch): + agent_switch["record"] = _agent_record(allow_mcp_header_passthrough=False) + hosts = [{"url": "http://a/mcp", "headers": {"X-Tenant-ID": "t-1"}}] + + _apply(hosts, {"_mcp_headers": {"Cookie": "sid=1"}}) + + assert hosts[0]["headers"] == {"X-Tenant-ID": "t-1"} + + +def test_ut_be_pass_003_agent_lookup_failure_disables_passthrough(agent_switch): + agent_switch["record"] = RuntimeError("database unavailable") + hosts = [{"url": "http://a/mcp", "headers": {"X-Tenant-ID": "t-1"}}] + + _apply(hosts, {"_mcp_headers": {"Cookie": "sid=1"}}) + + assert hosts[0]["headers"] == {"X-Tenant-ID": "t-1"} + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-006 — static preset headers always win +# --------------------------------------------------------------------------- + +def test_ut_be_pass_006_static_headers_win_case_insensitively(agent_switch): + hosts = [{ + "url": "http://a/mcp", + "headers": {"Cookie": "static-cookie", "Authorization": "Bearer static"}, + }] + + _apply(hosts, {"_mcp_headers": { + "cookie": "dynamic-cookie", + "Authorization": "Bearer dynamic", + "X-User-Id": "u1", + }}) + + assert hosts[0]["headers"] == { + "Cookie": "static-cookie", + "Authorization": "Bearer static", + "x-user-id": "u1", + } + + +def test_merge_static_priority_never_emits_duplicate_case_variants(): + merged = merge_static_priority({"Cookie": "static"}, {"cookie": "dynamic", "X-New": "v"}) + + assert merged == {"Cookie": "static", "X-New": "v"} + assert len({key.lower() for key in merged}) == len(merged) + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-008 — audit logs carry key names only, never values +# --------------------------------------------------------------------------- + +def test_ut_be_pass_008_audit_logs_keys_without_values(agent_switch, caplog): + caplog.set_level(logging.INFO, logger="northbound-mcp-passthrough") + hosts = [{"url": "http://a/mcp", "headers": {"Cookie": "sid=${Cookie}"}}] + + _apply(hosts, {"_mcp_headers": { + "Cookie": "sid=super-secret-value", + "X-User-Id": "user-secret-value", + }}) + + text = caplog.text + assert "event=passthrough_injected" in text + assert "event=placeholder_resolved" in text + assert "cookie" in text and "x-user-id" in text + assert "super-secret-value" not in text + assert "user-secret-value" not in text + + +def test_ut_be_pass_008_switch_off_audit_lists_raw_keys(agent_switch, caplog): + caplog.set_level(logging.WARNING, logger="northbound-mcp-passthrough") + agent_switch["record"] = _agent_record(allow_mcp_header_passthrough=False) + hosts = [{"url": "http://a/mcp", "headers": {}}] + + _apply(hosts, {"_mcp_headers": {"Cookie": "sid=secret-cookie-value"}}) + + text = caplog.text + assert "reason=agent_switch_off" in text + assert "cookie" in text + assert "secret-cookie-value" not in text + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-009 — backward compatibility when no reserved fields are sent +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("metadata", [None, {}, {"project_id": "P001"}]) +def test_ut_be_pass_009_no_reserved_fields_leaves_mcp_host_untouched(agent_switch, metadata): + hosts = [{"url": "http://a/mcp", "headers": {"X-Tenant-ID": "t-1"}}] + + _apply(hosts, metadata) + + assert hosts[0]["headers"] == {"X-Tenant-ID": "t-1"} + + +@pytest.mark.parametrize("mcp_host", [[], None, ["http://a/mcp"]]) +def test_ut_be_pass_009_non_dict_or_empty_mcp_host_is_ignored(agent_switch, mcp_host): + # Fallback string URLs carry no headers and must not raise. + _apply(mcp_host, {"_mcp_headers": {"Cookie": "sid=1"}}) + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-010 — placeholder resolution (mixed literals + case folding) +# --------------------------------------------------------------------------- + +def test_ut_be_pass_010_resolves_placeholders_and_keeps_literal_values(): + resolved, names, unresolved = resolve_placeholders( + { + "Cookie": "sid=${COOKIE}", + "X-Mix": "Bearer ${X-User-Id}-v2", + "X-Plain": "plain", + }, + {"cookie": "abc", "x-user-id": "u1"}, + passthrough_allowed=True, + run_id=RUN, + agent_id=1, + server_url="http://a/mcp", + ) + + assert resolved == { + "Cookie": "sid=abc", + "X-Mix": "Bearer u1-v2", + "X-Plain": "plain", + } + assert names == ["cookie", "x-user-id"] + assert unresolved == [] + + +def test_ut_be_pass_010_passthrough_headers_feed_placeholder_context(agent_switch): + hosts = [{"url": "http://a/mcp", "headers": {"Authorization": "Bearer ${X-User-Id}"}}] + + _apply(hosts, {"_mcp_headers": {"X-User-Id": "u1"}}) + + assert hosts[0]["headers"]["Authorization"] == "Bearer u1" + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-011 — unresolved placeholders fail closed +# --------------------------------------------------------------------------- + +def test_ut_be_pass_011_unresolved_placeholder_drops_the_whole_header(): + resolved, names, unresolved = resolve_placeholders( + {"Cookie": "sid=${Cookie}", "X-Ok": "keep"}, + {}, + passthrough_allowed=True, + run_id=RUN, + agent_id=1, + server_url="http://a/mcp", + ) + + assert resolved == {"X-Ok": "keep"} + assert names == [] + assert unresolved == ["cookie"] + assert "${" not in json.dumps(resolved) + + +def test_ut_be_pass_011_strict_mode_raises_placeholder_unresolved(agent_switch): + agent_switch["record"] = _agent_record(strict_mcp_placeholder_resolution=True) + hosts = [{"url": "http://a/mcp", "headers": {"Cookie": "sid=${Cookie}"}}] + + with pytest.raises(AppException) as exc_info: + _apply(hosts, {"_mcp_headers": {"X-Request-Id": "req-1"}}) + + assert exc_info.value.error_code == ErrorCode.CHAT_MCP_PLACEHOLDER_UNRESOLVED + assert exc_info.value.details == {"unresolved_placeholders": ["cookie"]} + assert exc_info.value.http_status == 400 + + +def test_ut_be_pass_011_lenient_mode_keeps_running_without_the_header(agent_switch): + hosts = [{"url": "http://a/mcp", "headers": {"Cookie": "sid=${Cookie}", "X-Keep": "v"}}] + + _apply(hosts, {}) + + assert hosts[0]["headers"] == {"X-Keep": "v"} + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-012 — $${Name} escapes to a literal, never resolves +# --------------------------------------------------------------------------- + +def test_ut_be_pass_012_double_dollar_escapes_to_literal(): + resolved, names, unresolved = resolve_placeholders( + {"X-Literal": "$${Cookie}"}, + {"cookie": "abc"}, + passthrough_allowed=True, + run_id=RUN, + agent_id=1, + server_url="http://a/mcp", + ) + + assert resolved == {"X-Literal": "${Cookie}"} + assert names == [] + assert unresolved == [] + + +def test_ut_be_pass_012_switch_off_drops_real_placeholder_but_keeps_escapes(): + resolved, names, unresolved = resolve_placeholders( + {"Cookie": "sid=${Cookie}", "X-Literal": "$${Cookie}", "X-Plain": "plain"}, + {"cookie": "abc"}, + passthrough_allowed=False, + run_id=RUN, + agent_id=1, + server_url="http://a/mcp", + ) + + assert resolved == {"X-Literal": "${Cookie}", "X-Plain": "plain"} + assert names == [] + assert unresolved == [] diff --git a/test/backend/services/test_northbound_service.py b/test/backend/services/test_northbound_service.py index 32d798a61e..79bf95c4f6 100644 --- a/test/backend/services/test_northbound_service.py +++ b/test/backend/services/test_northbound_service.py @@ -29,11 +29,20 @@ class ErrorCode: CHAT_METADATA_INVALID = "010106" CHAT_METADATA_TOO_LARGE = "010107" + CHAT_MCP_HEADER_KEY_FORBIDDEN = "010109" + CHAT_MCP_HEADER_VALUE_TOO_LARGE = "010110" + CHAT_MCP_HEADER_ROUTE_NOT_FOUND = "010111" + CHAT_MCP_PLACEHOLDER_UNRESOLVED = "010112" class RuntimeMetadataValidationCode(str, Enum): INVALID_METADATA_TYPE = "INVALID_METADATA_TYPE" METADATA_TOO_LARGE = "METADATA_TOO_LARGE" + MCP_HEADER_TYPE_INVALID = "MCP_HEADER_TYPE_INVALID" + MCP_HEADER_KEY_FORBIDDEN = "MCP_HEADER_KEY_FORBIDDEN" + MCP_HEADER_VALUE_TOO_LARGE = "MCP_HEADER_VALUE_TOO_LARGE" + MCP_HEADER_ROUTE_NOT_FOUND = "MCP_HEADER_ROUTE_NOT_FOUND" + MCP_PLACEHOLDER_UNRESOLVED = "MCP_PLACEHOLDER_UNRESOLVED" class AppException(Exception): diff --git a/test/backend/utils/test_runtime_metadata_utils.py b/test/backend/utils/test_runtime_metadata_utils.py index 1e2b438344..38074f3d7d 100644 --- a/test/backend/utils/test_runtime_metadata_utils.py +++ b/test/backend/utils/test_runtime_metadata_utils.py @@ -7,6 +7,8 @@ from contracts.errors.codes import RuntimeMetadataValidationCode from infrastructure.serialization.runtime_metadata import ( + MAX_MCP_HEADERS_TOTAL_BYTES, + MAX_MCP_HEADER_VALUE_BYTES, MAX_RUNTIME_METADATA_ARRAY_ITEMS, MAX_RUNTIME_METADATA_BYTES, MAX_RUNTIME_METADATA_DEPTH, @@ -104,3 +106,74 @@ def test_validate_runtime_metadata_rejects_overlong_key(): validate_runtime_metadata({long_key: "value"}) assert exc_info.value.code == RuntimeMetadataValidationCode.METADATA_TOO_MANY_ITEMS + + +# --------------------------------------------------------------------------- +# Phase 1: northbound MCP header passthrough reserved fields +# UT-BE-PASS-004 (key whitelist) / UT-BE-PASS-005 (size limits) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("key", ["authorization", "Cookie", "X-User-Id", "x-trace-id"]) +def test_ut_be_pass_004_accepts_whitelisted_and_x_prefixed_keys(key): + value = {"_mcp_headers": {key: "value"}} + + assert validate_runtime_metadata(value) is value + + +@pytest.mark.parametrize("key", ["Host", "content-length", "Connection", "TE"]) +def test_ut_be_pass_004_rejects_hop_by_hop_headers(key): + with pytest.raises(RuntimeMetadataValidationError) as exc_info: + validate_runtime_metadata({"_mcp_headers": {key: "value"}}) + + assert exc_info.value.code == RuntimeMetadataValidationCode.MCP_HEADER_KEY_FORBIDDEN + + +@pytest.mark.parametrize("key", ["Referer", "User-Agent", "Set-Cookie"]) +def test_ut_be_pass_004_rejects_non_whitelisted_headers(key): + with pytest.raises(RuntimeMetadataValidationError) as exc_info: + validate_runtime_metadata({"_mcp_headers": {key: "value"}}) + + assert exc_info.value.code == RuntimeMetadataValidationCode.MCP_HEADER_KEY_FORBIDDEN + + +def test_ut_be_pass_004_rejects_non_object_header_block(): + for metadata in ( + {"_mcp_headers": ["Cookie"]}, + {"_mcp_headers": {"Cookie": 1}}, + {"_mcp_headers": {"": "value"}}, + {"_mcp_headers_by_server": ["http://a/mcp"]}, + {"_mcp_headers_by_server": {"": {"Cookie": "value"}}}, + ): + with pytest.raises(RuntimeMetadataValidationError) as exc_info: + validate_runtime_metadata(metadata) + + assert exc_info.value.code == RuntimeMetadataValidationCode.MCP_HEADER_TYPE_INVALID + + +def test_ut_be_pass_005_rejects_oversized_single_header_value(): + oversized = "a" * (MAX_MCP_HEADER_VALUE_BYTES + 1) + + with pytest.raises(RuntimeMetadataValidationError) as exc_info: + validate_runtime_metadata({"_mcp_headers": {"Cookie": oversized}}) + + assert exc_info.value.code == RuntimeMetadataValidationCode.MCP_HEADER_VALUE_TOO_LARGE + + +def test_ut_be_pass_005_rejects_oversized_cumulative_header_values(): + per_value = "a" * (MAX_MCP_HEADER_VALUE_BYTES - 1) + # Each block stays under the per-header limit; together they blow the cap. + blocks = { + f"http://server-{index}/mcp": {f"X-Header-{index}": per_value} + for index in range(MAX_MCP_HEADERS_TOTAL_BYTES // MAX_MCP_HEADER_VALUE_BYTES + 2) + } + + with pytest.raises(RuntimeMetadataValidationError) as exc_info: + validate_runtime_metadata({"_mcp_headers_by_server": blocks}) + + assert exc_info.value.code == RuntimeMetadataValidationCode.MCP_HEADER_VALUE_TOO_LARGE + + +def test_ut_be_pass_005_accepts_values_at_the_size_limit(): + value = {"_mcp_headers": {"Cookie": "a" * MAX_MCP_HEADER_VALUE_BYTES}} + + assert validate_runtime_metadata(value) is value diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py index cf72e7e841..a01939b98a 100644 --- a/test/sdk/core/agents/test_core_agent.py +++ b/test/sdk/core/agents/test_core_agent.py @@ -4551,3 +4551,45 @@ def fake_run(full_task, **kwargs): assert calls["full_task"] == "summarize" assert calls["kwargs"]["additional_args"] == {"metadata": {"session": "abc"}} assert answer == "sub-agent-output" + +# --------------------------------------------------------------------------- +# UT-BE-PASS-007 — reserved underscore keys never reach the LLM prompt +# --------------------------------------------------------------------------- + +def test_ut_be_pass_007_strips_top_level_reserved_metadata_keys(): + metadata = { + "_mcp_headers": {"Cookie": "sid=secret"}, + "project_id": "P001", + } + + cleaned = core_agent_module._strip_reserved_metadata_keys(metadata) + + assert cleaned == {"project_id": "P001"} + assert metadata["_mcp_headers"] == {"Cookie": "sid=secret"} + + +def test_ut_be_pass_007_strips_nested_reserved_metadata_keys(): + metadata = { + "nested": { + "_mcp_headers_by_server": {"http://a/mcp": {"Cookie": "sid=secret"}}, + "keep": "v", + }, + "items": [{"_secret": 1, "ok": 2}], + } + + cleaned = core_agent_module._strip_reserved_metadata_keys(metadata) + + assert cleaned == {"nested": {"keep": "v"}, "items": [{"ok": 2}]} + + +def test_ut_be_pass_007_passthrough_keys_absent_from_serialized_prompt_metadata(): + metadata = { + "project_id": "P001", + "_mcp_headers": {"Cookie": "sid=secret"}, + "_mcp_headers_by_server": {"http://a/mcp": {"X-User-Id": "u1"}}, + } + + serialized = json.dumps(core_agent_module._strip_reserved_metadata_keys(metadata)) + + assert "_mcp_headers" not in serialized + assert "sid=secret" not in serialized From ff48a102b0e0372a41ebb9ad1e3dd705139e38e2 Mon Sep 17 00:00:00 2001 From: yunshuang-hw Date: Wed, 11 Nov 2026 15:34:40 +0800 Subject: [PATCH 2/3] fix(agent): keep mcp_host payload shape when passthrough is inactive 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 Generated-by: trae --- .../management/services/agent/mcp_passthrough.py | 13 +++++++------ .../management/services/agent/preparation/run.py | 4 +++- backend/management/services/agent/run.py | 3 ++- .../test_service_directory_architecture.py | 1 + 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/backend/management/services/agent/mcp_passthrough.py b/backend/management/services/agent/mcp_passthrough.py index ab7972a5c9..7898780d52 100644 --- a/backend/management/services/agent/mcp_passthrough.py +++ b/backend/management/services/agent/mcp_passthrough.py @@ -276,8 +276,6 @@ def apply_mcp_header_passthrough( ) total_unresolved: List[str] = [] - had_injection = False - had_resolution = False for item in mcp_host: if not isinstance(item, dict) or "url" not in item: @@ -307,19 +305,22 @@ def apply_mcp_header_passthrough( # ---- Dynamic injection (static priority merge) ---- if passthrough_allowed and mode is not None: dynamic = route_passthrough_headers(mode, passthrough_headers, server_url) - item["headers"] = merge_static_priority(resolved, dynamic) + effective_headers = merge_static_priority(resolved, dynamic) if dynamic: - had_injection = True logger.info( "agent_id=%s run_id=%s server_url=%s " "event=passthrough_injected keys=%s", agent_id, run_id, server_url, sorted(dynamic.keys()), ) else: - item["headers"] = resolved + effective_headers = resolved + + # Keep the original mcp_host shape: entries that had no headers must + # stay header-less, otherwise every agent payload grows an empty dict. + if effective_headers or "headers" in item: + item["headers"] = effective_headers if resolved_names: - had_resolution = True logger.info( "agent_id=%s run_id=%s server_url=%s " "event=placeholder_resolved placeholders=%s", diff --git a/backend/management/services/agent/preparation/run.py b/backend/management/services/agent/preparation/run.py index 5dac079196..e88017d4b6 100644 --- a/backend/management/services/agent/preparation/run.py +++ b/backend/management/services/agent/preparation/run.py @@ -430,7 +430,9 @@ async def create_agent_run_info( language=language, agent_config=agent_config, mcp_host=mcp_host, - runtime_metadata=metadata or {}, + # Only surface the kwarg when the caller actually sent metadata, so + # agents without metadata keep the exact pre-existing payload shape. + **({"runtime_metadata": metadata} if metadata is not None else {}), mcp_request_timeout_seconds=MCP_REQUEST_TIMEOUT_SECONDS, history=converted_history, mcp_tool_timeout_seconds=RUNTIME_MCP_TOOL_TIMEOUT_SECONDS, diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 4c95c86ece..7636831aad 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -150,8 +150,9 @@ async def prepare_agent_run( "conversation_id": agent_request.conversation_id, "context_policy": agent_request.context_policy, "enable_planning": agent_request.enable_plan, - "metadata": agent_request.metadata, } + if agent_request.metadata is not None: + create_run_kwargs["metadata"] = agent_request.metadata runtime_knowledge_context = getattr( agent_request, "_runtime_knowledge_context", None ) diff --git a/test/backend/management/test_service_directory_architecture.py b/test/backend/management/test_service_directory_architecture.py index fecc510e2b..4e7606ca6e 100644 --- a/test/backend/management/test_service_directory_architecture.py +++ b/test/backend/management/test_service_directory_architecture.py @@ -44,6 +44,7 @@ 'agent/execution.py', 'agent/icon.py', 'agent/management.py', + 'agent/mcp_passthrough.py', 'agent/naming.py', 'agent/policy.py', 'agent/profile.py', From 77667b278b215b95f30a5d957202a368533b345e Mon Sep 17 00:00:00 2001 From: yunshuang-hw Date: Wed, 11 Nov 2026 16:03:45 +0800 Subject: [PATCH 3/3] test(agent): cover remaining passthrough branches for patch coverage 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 Generated-by: trae --- test/backend/app/test_northbound_app.py | 29 +++++++++++++- .../management/test_mcp_header_passthrough.py | 34 +++++++++++++++++ test/backend/services/test_agent_service.py | 38 +++++++++++++++++++ .../utils/test_runtime_metadata_utils.py | 22 +++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/test/backend/app/test_northbound_app.py b/test/backend/app/test_northbound_app.py index f0e6425d30..1e6f99d8c2 100644 --- a/test/backend/app/test_northbound_app.py +++ b/test/backend/app/test_northbound_app.py @@ -19,7 +19,8 @@ with pytest.MonkeyPatch.context() as import_mocks: import_mocks.setitem(sys.modules, "management.services.agent.service", MagicMock()) from gateway.api.northbound import router -from contracts.errors.exceptions import ConversationNotFoundError, ForbiddenError, LimitExceededError, RuntimeServiceTimeoutError, RuntimeServiceUnavailableError, RuntimeUpstreamError, TenantResourceLimitError, NotFoundException, UnauthorizedError, SignatureValidationError, ValidationError +from contracts.errors.exceptions import AppException, ConversationNotFoundError, ForbiddenError, LimitExceededError, RuntimeServiceTimeoutError, RuntimeServiceUnavailableError, RuntimeUpstreamError, TenantResourceLimitError, NotFoundException, UnauthorizedError, SignatureValidationError, ValidationError +from contracts.errors.codes import ErrorCode app = FastAPI() @@ -462,6 +463,32 @@ def test_run_chat_maps_runtime_transport_errors(error, expected_status): assert response.status_code == expected_status +def test_run_chat_propagates_app_exception_to_the_global_handler(): + """AppException must escape run_chat instead of becoming a generic 500.""" + error = AppException( + ErrorCode.CHAT_MCP_HEADER_KEY_FORBIDDEN, + details={"reason": "MCP_HEADER_KEY_FORBIDDEN"}, + ) + with patch( + "gateway.api.northbound._get_northbound_context", + new_callable=AsyncMock, + ) as mock_ctx, patch( + "gateway.api.northbound.start_streaming_chat", + new_callable=AsyncMock, + side_effect=error, + ): + mock_ctx.return_value = MagicMock() + + with pytest.raises(AppException) as exc_info: + client.post( + "/nb/v1/chat/run", + json={"agent_name": "general-assistant", "query": "Hello"}, + headers=_build_headers(), + ) + + assert exc_info.value is error + + # ============================================================================= # Stop Chat Tests # ============================================================================= diff --git a/test/backend/management/test_mcp_header_passthrough.py b/test/backend/management/test_mcp_header_passthrough.py index 286d34268b..8aed4bf510 100644 --- a/test/backend/management/test_mcp_header_passthrough.py +++ b/test/backend/management/test_mcp_header_passthrough.py @@ -366,3 +366,37 @@ def test_ut_be_pass_012_switch_off_drops_real_placeholder_but_keeps_escapes(): assert resolved == {"X-Literal": "${Cookie}", "X-Plain": "plain"} assert names == [] assert unresolved == [] + + +# --------------------------------------------------------------------------- +# UT-BE-PASS-013 — defensive routing / shape-preservation branches +# --------------------------------------------------------------------------- + +def test_ut_be_pass_013_route_without_mode_returns_empty(): + # Callers guard on mode, but the helper must stay safe when it is None. + assert route_passthrough_headers(None, {"cookie": "sid=1"}, "http://a/mcp") == {} + + +def test_ut_be_pass_013_switch_off_by_server_logs_routed_keys(agent_switch, caplog): + caplog.set_level(logging.WARNING, logger="northbound-mcp-passthrough") + agent_switch["record"] = _agent_record(allow_mcp_header_passthrough=False) + hosts = [{"url": "http://a/mcp", "headers": {}}] + + _apply(hosts, {"_mcp_headers_by_server": {"http://a/mcp": {"Cookie": "sid=raw"}}}) + + assert hosts[0]["headers"] == {} + text = caplog.text + assert "reason=agent_switch_off" in text + assert "mode=by_server" in text + assert "sid=raw" not in text + + +def test_ut_be_pass_013_by_server_leaves_unrouted_server_headerless(agent_switch): + # A server without a routed block must keep its original shape (no headers + # key at all), not grow an empty dict. + hosts = [{"url": "http://a/mcp", "headers": {}}, {"url": "http://b/mcp"}] + + _apply(hosts, {"_mcp_headers_by_server": {"http://a/mcp": {"Cookie": "sid=a"}}}) + + assert hosts[0]["headers"] == {"cookie": "sid=a"} + assert "headers" not in hosts[1] diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py index 17ef15c408..1ba2e2d88d 100644 --- a/test/backend/services/test_agent_service.py +++ b/test/backend/services/test_agent_service.py @@ -4956,6 +4956,44 @@ async def test_prepare_agent_run( assert mock_run_info.context_input.items == () +@pytest.mark.asyncio +@patch("management.services.agent.run.build_memory_context") +@patch("management.services.agent.run.create_agent_run_info", new_callable=AsyncMock) +@patch("management.services.agent.run.agent_run_manager") +async def test_prepare_agent_run_forwards_request_metadata( + mock_agent_run_manager, + mock_create_run_info, + mock_build_memory_context, +): + """Request metadata only reaches create_agent_run_info when it is set.""" + metadata = {"project_id": "P001", "_mcp_headers": {"Cookie": "sid=1"}} + mock_run_info = MagicMock() + mock_run_info.agent_config.context_items = [] + mock_run_info.agent_config.context_manager_config.policy_layers = { + "platform": {"processing_mode": "passthrough"} + } + mock_run_info.history = [] + mock_create_run_info.return_value = mock_run_info + mock_build_memory_context.return_value = MagicMock() + + await prepare_agent_run( + AgentRequest( + agent_id=1, + conversation_id=123, + query="test query", + history=[], + minio_files=[], + requested_output_tokens=4096, + is_debug=False, + metadata=metadata, + ), + user_id="test_user", + tenant_id="test_tenant", + ) + + assert mock_create_run_info.await_args.kwargs["metadata"] == metadata + + @pytest.mark.asyncio @patch("management.services.agent.run.build_memory_context") @patch("management.services.agent.run.create_agent_run_info", new_callable=AsyncMock) diff --git a/test/backend/utils/test_runtime_metadata_utils.py b/test/backend/utils/test_runtime_metadata_utils.py index 38074f3d7d..8210b02841 100644 --- a/test/backend/utils/test_runtime_metadata_utils.py +++ b/test/backend/utils/test_runtime_metadata_utils.py @@ -177,3 +177,25 @@ def test_ut_be_pass_005_accepts_values_at_the_size_limit(): value = {"_mcp_headers": {"Cookie": "a" * MAX_MCP_HEADER_VALUE_BYTES}} assert validate_runtime_metadata(value) is value + + +def test_ut_be_pass_004_blank_header_key_is_never_allowed(): + # The public validator rejects blank keys earlier; the predicate must stay + # safe on its own for any future caller. + from infrastructure.serialization import runtime_metadata as module + + assert module._is_passthrough_header_allowed(" ") is False + + +def test_validate_runtime_metadata_wraps_serialization_failures(monkeypatch): + from infrastructure.serialization import runtime_metadata as module + + def _raise_type_error(_value): + raise TypeError("not serializable") + + monkeypatch.setattr(module, "runtime_metadata_size_bytes", _raise_type_error) + + with pytest.raises(RuntimeMetadataValidationError) as exc_info: + validate_runtime_metadata({"project_id": "P001"}) + + assert exc_info.value.code == RuntimeMetadataValidationCode.INVALID_METADATA_TYPE