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..7898780d52 --- /dev/null +++ b/backend/management/services/agent/mcp_passthrough.py @@ -0,0 +1,351 @@ +# 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] = [] + + 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) + effective_headers = merge_static_priority(resolved, dynamic) + if dynamic: + 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: + 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: + 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 3cc9f875e3..e88017d4b6 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,9 @@ async def create_agent_run_info( language=language, agent_config=agent_config, mcp_host=mcp_host, + # 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 a44aff0185..7636831aad 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -151,6 +151,8 @@ async def prepare_agent_run( "context_policy": agent_request.context_policy, "enable_planning": agent_request.enable_plan, } + 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/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/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 new file mode 100644 index 0000000000..8aed4bf510 --- /dev/null +++ b/test/backend/management/test_mcp_header_passthrough.py @@ -0,0 +1,402 @@ +# 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 == [] + + +# --------------------------------------------------------------------------- +# 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/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', 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/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..8210b02841 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,96 @@ 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 + + +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 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