"""Single authoritative source for the bound MCP transport identifier (#931). Before this module the transport was a literal, passed once at the bottom of ``gitea_mcp_server`` as ``bind_native_mcp_transport(transport="stdio")``. Every guard that later asks "is this a trusted native session" resolves that question through the value bound there, so the literal was effectively a constant in the authorization chain rather than configuration. This module is the seam. It owns three things and nothing else: - the permitted set of transport identifiers, - the default used when deployment configuration says nothing, - the resolution of the configured value into a validated identifier. It deliberately holds no state. The *bound* transport is pinned once, at bind time, into the process-local native-runtime record owned by :mod:`mcp_daemon_guard`, and is read back through ``mcp_daemon_guard.bound_transport()``. That split matters: configuration is read exactly once, before any tool can dispatch, so a later environment change cannot move the value a guard observes — the same pinning rule already applied to the session-state root under #695 AC2. Nothing here consumes tool arguments, request bodies, or provenance fields. The only input is the deployment environment, read at bind time. Standing up a listener for a non-stdio transport is #938; this module only makes the identifier expressible and validated. """ from __future__ import annotations import os from typing import Any, Mapping # Deployment configuration key. Read once, at bind time, and never again. TRANSPORT_ENV = "GITEA_MCP_TRANSPORT" # The local, client-spawned transport. Unset configuration resolves to this, # which is what keeps every existing stdio deployment byte-identical. DEFAULT_TRANSPORT = "stdio" # The sanctioned remote transport identifier. Accepting it here is what makes # the bind pluggable; the endpoint that serves it belongs to #938. The name # matches the MCP transport name so no second vocabulary has to be mapped. REMOTE_TRANSPORT = "streamable-http" # The permitted set. This is the only place transport identifiers are # enumerated; guards consult it rather than restating any member. # # ``sse`` is a real MCP transport and is deliberately absent: it is the # superseded remote transport, and admitting it would give the deployment two # remote paths to reason about. An unregistered identifier must fail closed at # bind time, and ``sse`` is held to that rule like any other. SUPPORTED_TRANSPORTS = frozenset({DEFAULT_TRANSPORT, REMOTE_TRANSPORT}) SOURCE_CONFIGURED = "deployment_configuration" SOURCE_DEFAULT = "default" class TransportConfigurationError(ValueError): """Raised when configured transport is outside :data:`SUPPORTED_TRANSPORTS`.""" def normalize_transport(value: Any) -> str: """Canonical form of a transport identifier; ``""`` when there is none. Non-string values normalize to ``""`` rather than being coerced, so a structured object smuggled in from a caller can never match a member of the permitted set. """ if not isinstance(value, str): return "" return value.strip().lower() def supported_transports() -> tuple[str, ...]: """Permitted identifiers, sorted, for messages and status payloads.""" return tuple(sorted(SUPPORTED_TRANSPORTS)) def is_supported_transport(value: Any) -> bool: """True when *value* normalizes to a member of the permitted set.""" return normalize_transport(value) in SUPPORTED_TRANSPORTS def is_remote_transport(value: Any) -> bool: """True when *value* is a permitted transport that is not the local one.""" name = normalize_transport(value) return name in SUPPORTED_TRANSPORTS and name != DEFAULT_TRANSPORT def resolve_configured_transport( env: Mapping[str, str] | None = None, ) -> dict[str, Any]: """Resolve the deployment-configured transport without raising. Returns the resolution rather than a bare string so a caller can tell an unset value (which legitimately yields :data:`DEFAULT_TRANSPORT`) from a configured value that is not permitted (which must fail closed, never silently degrade to the default). Args: env: Environment mapping to read; defaults to ``os.environ``. Returns: dict with ``transport`` (normalized; the default when unset), ``configured``, ``source``, ``raw``, ``supported``, and ``reasons``. """ source_env = os.environ if env is None else env raw = source_env.get(TRANSPORT_ENV) normalized = normalize_transport(raw) configured = bool(normalized) if not configured: return { "transport": DEFAULT_TRANSPORT, "configured": False, "source": SOURCE_DEFAULT, "raw": raw, "supported": True, "supported_transports": list(supported_transports()), "env_key": TRANSPORT_ENV, "reasons": [], } supported = normalized in SUPPORTED_TRANSPORTS reasons: list[str] = [] if not supported: reasons.append( f"{TRANSPORT_ENV}={normalized!r} is not a registered MCP transport " f"(#931). Registered: {list(supported_transports())}." ) return { "transport": normalized, "configured": True, "source": SOURCE_CONFIGURED, "raw": raw, "supported": supported, "supported_transports": list(supported_transports()), "env_key": TRANSPORT_ENV, "reasons": reasons, } def require_configured_transport(env: Mapping[str, str] | None = None) -> str: """Resolved transport identifier, or raise when it is not permitted. Raises: TransportConfigurationError: the configured identifier is unregistered. """ resolution = resolve_configured_transport(env) if not resolution["supported"]: raise TransportConfigurationError("; ".join(resolution["reasons"])) return str(resolution["transport"])