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:
@@ -53,6 +53,30 @@ REMOTE_TRANSPORT = "streamable-http"
|
||||
# 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"
|
||||
|
||||
@@ -89,6 +113,120 @@ def is_remote_transport(value: Any) -> bool:
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user