Files
Gitea-Tools/mcp_transport_config.py
T
jcwalker3andClaude Opus 4.8 a143cd065b fix(transport): gate transport execution behind commissioning (review 635)
Addresses B1 and B2 from formal review 635 on PR #966. Both are one defect:
the seam conflated two questions that must stay separate.

  recognition:  "is this an identifier this system knows, and may it be
                 bound, pinned and recorded?"
  execution:    "is this entrypoint commissioned to actually serve on it?"

SUPPORTED_TRANSPORTS answered the first and was then used to answer the
second, so registering streamable-http was the same act as executing it.
With mcp.run fed the bound value, GITEA_MCP_TRANSPORT=streamable-http
reached FastMCP.run -> run_streamable_http_async and started a uvicorn
HTTP server over the whole tool surface with auth=None, no TLS and no
per-request principal. That endpoint is owned by #938 and is an explicit
non-goal of #931. B2 is why B1 was reachable: bound_transport was read
only by reporting sites, so no decision anywhere depended on it.

The fix adds an execution-authorization layer rather than removing the
identifier or mapping it to stdio, both of which would have destroyed the
seam #931 exists to create.

mcp_transport_config gains EXECUTABLE_TRANSPORTS, a strict subset of
SUPPORTED_TRANSPORTS containing only the local transport, plus
TRANSPORT_EXECUTION_OWNER recording that #938 commissions the remote
listener. assess_transport_execution returns a structured verdict:
transport, recognized, executable, allowed, blocker_kind, owner_issue,
reasons and exact_next_action.

mcp_daemon_guard gains assess_serve_authorization, which is the decision
that consumes bound_transport and is what stops it being reporting-only
metadata, and authorize_transport_execution, which enforces two ordered
boundaries: assert_transport_bound first, so an unbound runtime keeps its
pre-existing #695 failure and reason code; then execution authorization,
so a registered but uncommissioned transport is refused before any
listener exists and before any tool can dispatch. TransportExecutionError
subclasses UnsanctionedRuntimeError, so every existing fail-closed handler
still catches it while a caller that cares can distinguish the two cases.
native_runtime_status now reports executable_transports, serve_authorized
and the full serve_authorization verdict.

The entrypoint serves through authorize_transport_execution.

Net effect. stdio: unchanged, still binds and still reaches the runner.
streamable-http: still recognized, still validated, still pinned, still
recorded in decision-lock and audit provenance -- and refused at the serve
boundary with blocker transport_listener_not_commissioned naming #938.
Unregistered identifiers still fail earlier, at bind validation. Unbound
execution keeps the #695 contract. Rebind idempotence and conflict
rejection, session isolation, provenance and secret handling are untouched.

Tests: 63 in tests/test_issue_931_transport_bind_seam.py, up from 42. New
coverage proves stdio reaches the real runner; the remote identifier binds
and is recorded yet never reaches run(); no socket is bound during the
refusal, asserted with a socket.bind tripwire; the refusal names the
transport, the unmet requirement and #938; the decision genuinely consumes
bound_transport, proven by flipping only that value; no mutation is
authorized after denial; verdicts do not leak across runtimes; and the
refusal carries no credential material. Per review 635 the two file-list
scans are now globs over the production surface, and a new test asserts
there is exactly one mcp.run site and that it routes through the guard.

#956 anchors re-anchored for the two lines this change shifted,
mcp_daemon_guard.py 178->195 and 519->583. No other anchor moved.

Full suite from the branches/ worktree: 28 failed, 5864 passed, 6 skipped,
1047 subtests. Base 9b80e75c: 28 failed, 5801 passed, 6 skipped, 1047
subtests. Failing test IDs are identical sets in both directions; the +63
passed are this module.

No listener, socket, authentication, TLS or principal code is added. #938,
#957, #958 and #959 remain unimplemented.

Refs #931, #938. Addresses review 635.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 11:15:00 -05:00

293 lines
12 KiB
Python

"""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"])