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]>
This commit is contained in:
2026-07-28 11:15:00 -05:00
co-authored by Claude Opus 4.8
parent 2fb835a1aa
commit a143cd065b
6 changed files with 567 additions and 25 deletions
+71
View File
@@ -62,6 +62,23 @@ class UnsanctionedRuntimeError(RuntimeError):
"""Raised when mutation/credential code runs outside a native MCP daemon."""
class TransportExecutionError(UnsanctionedRuntimeError):
"""Raised when a bound transport may not be served by this entrypoint (#931).
Subclasses :class:`UnsanctionedRuntimeError` so every existing fail-closed
handler still catches it, while letting a caller that cares distinguish
"nothing is bound" from "something valid is bound but its listener has not
been commissioned". Carries the structured verdict on ``.assessment``.
"""
def __init__(self, message: str, assessment: dict[str, Any] | None = None):
super().__init__(message)
self.assessment = assessment or {}
self.blocker_kind = self.assessment.get("blocker_kind")
self.owner_issue = self.assessment.get("owner_issue")
self.transport = self.assessment.get("transport")
def is_pytest_runtime() -> bool:
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
"1",
@@ -432,6 +449,53 @@ def assert_transport_bound(context: str = "tool service") -> str:
)
def assess_serve_authorization() -> dict[str, Any]:
"""Structured verdict on whether this process may serve tools (#931).
This is the decision that consumes :func:`bound_transport`. It is what stops
the bound identifier from being reporting-only metadata: the serve path
cannot proceed unless the value pinned at bind is one this entrypoint is
commissioned to execute.
Never raises; returns the verdict so callers and diagnostics can inspect it.
"""
return mcp_transport_config.assess_transport_execution(bound_transport())
def authorize_transport_execution(context: str = "tool service") -> str:
"""Return the transport this process may serve, or fail closed (#931).
Two distinct boundaries, in order:
1. **Bind presence** — :func:`assert_transport_bound` enforces the
pre-existing #695 contract, so an unbound runtime keeps its established
failure and reason code.
2. **Execution authorization** — the bound identifier must be one this
entrypoint is commissioned to serve. A registered transport whose
listener has not been commissioned is refused here, before any listener
is created and before any tool can dispatch.
That ordering matters: recognition, validation and durable recording all
still happen for a remote identifier, so #931's seam is intact; only the act
of *serving* it is withheld until its owning issue commissions it.
"""
# Boundary 1: unbound stays exactly as fail-closed as it was under #695.
assert_transport_bound(context)
# Boundary 2: bound, but is this entrypoint allowed to serve it?
assessment = assess_serve_authorization()
if assessment.get("allowed"):
return str(assessment["transport"])
reasons = "; ".join(assessment.get("reasons") or []) or "not authorized"
next_action = assessment.get("exact_next_action") or ""
raise TransportExecutionError(
f"Refusing {context} (#931) [{assessment.get('blocker_kind')}]: "
f"{reasons} {next_action}".strip(),
assessment,
)
def is_sanctioned_mcp_daemon() -> bool:
"""Backward-compatible name; #695 requires native transport, not env alone."""
if is_production_native_mcp_transport():
@@ -563,6 +627,13 @@ def native_runtime_status() -> dict[str, Any]:
"default_transport": mcp_transport_config.DEFAULT_TRANSPORT,
"supported_transports": list(mcp_transport_config.supported_transports()),
"transport_env": mcp_transport_config.TRANSPORT_ENV,
# #931 review 635: recognition and execution authorization are distinct.
# ``supported`` is what may be bound; ``executable`` is what this
# entrypoint may actually serve. A recognized-but-uncommissioned
# transport reports serve_authorized False with a named blocker.
"executable_transports": list(mcp_transport_config.executable_transports()),
"serve_authorized": bool(assess_serve_authorization().get("allowed")),
"serve_authorization": assess_serve_authorization(),
"mode": rt.get("mode"),
"session_state_dir": pinned_session_state_dir() or rt.get("session_state_dir"),
"session_state_dir_pinned": pinned_session_state_dir() is not None,