Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/contracts/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions backend/contracts/errors/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions backend/contracts/errors/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions backend/gateway/api/northbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
136 changes: 136 additions & 0 deletions backend/infrastructure/serialization/runtime_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions backend/management/db/agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading