"""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}) # Recognition is not execution authorization (#931, review 635 B1/B2). # # SUPPORTED_TRANSPORTS answers "is this an identifier this system knows, and may # it be bound, pinned and recorded?". It deliberately includes the remote # identifier, because #931 requires the bind to become pluggable. # # EXECUTABLE_TRANSPORTS answers a strictly narrower question: "is this entrypoint # commissioned to actually *serve* on that transport?". Only the local transport # is. Handing ``streamable-http`` to ``mcp.run`` would start FastMCP's HTTP # listener with no authentication, no TLS and no per-request principal — the # endpoint #938 owns and gates. Recognition must therefore never imply execution. # # #938 commissions the remote listener by adding REMOTE_TRANSPORT here, together # with the authentication and principal boundary its acceptance criteria require. EXECUTABLE_TRANSPORTS = frozenset({DEFAULT_TRANSPORT}) # Which issue owns commissioning each recognized-but-not-executable transport. # Used to make the refusal actionable rather than a generic denial. TRANSPORT_EXECUTION_OWNER = {REMOTE_TRANSPORT: "#938"} BLOCKER_TRANSPORT_NOT_BOUND = "transport_not_bound" BLOCKER_TRANSPORT_NOT_RECOGNIZED = "transport_not_recognized" BLOCKER_LISTENER_NOT_COMMISSIONED = "transport_listener_not_commissioned" 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 executable_transports() -> tuple[str, ...]: """Transports this entrypoint is commissioned to serve, sorted.""" return tuple(sorted(EXECUTABLE_TRANSPORTS)) def is_executable_transport(value: Any) -> bool: """True when *value* may actually be served by this entrypoint (#931). Strictly narrower than :func:`is_supported_transport`. A recognized identifier that is not executable is a correct, fully-bound configuration whose listener has simply not been commissioned yet. """ return normalize_transport(value) in EXECUTABLE_TRANSPORTS def assess_transport_execution(value: Any) -> dict[str, Any]: """Structured serve-authorization verdict for a bound transport (#931). This is the decision that separates a *recognized* transport from one this entrypoint may execute. It is deliberately a pure function of the bound identifier so the serve path cannot reach a listener the deployment has not commissioned. Args: value: The bound transport identifier, or ``None`` when unbound. Returns: dict with ``transport``, ``recognized``, ``executable``, ``allowed``, ``blocker_kind``, ``owner_issue``, ``reasons`` and ``exact_next_action``. ``allowed`` is true only for a bound, recognized, commissioned transport. """ name = normalize_transport(value) if not name: return { "transport": None, "recognized": False, "executable": False, "allowed": False, "blocker_kind": BLOCKER_TRANSPORT_NOT_BOUND, "owner_issue": None, "supported_transports": list(supported_transports()), "executable_transports": list(executable_transports()), "reasons": [ "no transport is bound; the serve path is fail-closed until " "bind_native_mcp_transport succeeds (#695/#931)" ], "exact_next_action": ( "Launch through the canonical entrypoint so " "bind_native_mcp_transport runs before tool service." ), } recognized = name in SUPPORTED_TRANSPORTS if not recognized: return { "transport": name, "recognized": False, "executable": False, "allowed": False, "blocker_kind": BLOCKER_TRANSPORT_NOT_RECOGNIZED, "owner_issue": None, "supported_transports": list(supported_transports()), "executable_transports": list(executable_transports()), "reasons": [ f"transport {name!r} is not a registered MCP transport (#931); " "it should have been refused at bind time" ], "exact_next_action": ( f"Set {TRANSPORT_ENV} to one of {list(supported_transports())}." ), } if name in EXECUTABLE_TRANSPORTS: return { "transport": name, "recognized": True, "executable": True, "allowed": True, "blocker_kind": None, "owner_issue": None, "supported_transports": list(supported_transports()), "executable_transports": list(executable_transports()), "reasons": [], "exact_next_action": None, } owner = TRANSPORT_EXECUTION_OWNER.get(name) owner_text = owner or "the issue that commissions this transport's listener" return { "transport": name, "recognized": True, "executable": False, "allowed": False, "blocker_kind": BLOCKER_LISTENER_NOT_COMMISSIONED, "owner_issue": owner, "supported_transports": list(supported_transports()), "executable_transports": list(executable_transports()), "reasons": [ f"transport {name!r} is registered and was bound and recorded, but " f"this entrypoint is not commissioned to serve it (#931). Serving it " f"would start a listener with no authentication, no transport " f"security and no per-request principal; that endpoint is owned by " f"{owner_text}." ], "exact_next_action": ( f"Serve on {DEFAULT_TRANSPORT} until {owner_text} commissions the " f"{name!r} listener with its authentication and principal boundary, " f"which adds {name!r} to EXECUTABLE_TRANSPORTS." ), } 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"])