feat(transport): add transport-neutral MCP bind seam

Closes #931.

The transport was a literal passed once at the bottom of the module,
bind_native_mcp_transport(transport="stdio"), and every guard that asks
"is this a trusted native session" resolves that question through the
value bound there. The string was a constant in the authorization chain
rather than configuration, so a remote transport had no way to be
expressed and no way to be told apart from an untrusted offline import.

mcp_transport_config becomes the single source of truth: it owns the
permitted set, the default, and the resolution of deployment
configuration (GITEA_MCP_TRANSPORT) into a validated identifier. Unset
configuration still yields stdio, so existing deployments are unchanged.
streamable-http is accepted so the bind is pluggable; standing up its
listener remains #938. sse is deliberately not registered.

bind_native_mcp_transport now resolves from configuration when no
transport argument is given, validates against that one permitted set
before writing the runtime record, and pins the result. bound_transport
is the shared accessor every transport-aware guard reads; it reports the
pinned value and never the environment, so a post-bind GITEA_MCP_TRANSPORT
change cannot move what a guard observes -- the rule already applied to
the session-state root under #695 AC2. Rebinding the same identifier is
idempotent; rebinding a different one is refused, so two guards can never
disagree within one process. assert_transport_bound fails closed before
mcp.run, so an absent or invalid bind stops the server instead of serving
tools over a transport no guard can name.

The bound identifier is now recorded in durable provenance:
mutation_provenance_fields gains bound_transport, which reaches the
decision lock through mcp_session_state.save_state and the audit records
that already spread those fields. The pre-existing transport field keeps
its trust-class values, so no durable record changes shape.
assess_transport_for_auth_mint reports the bound transport through the
same accessor; its verdict is unchanged.

#956's anchor fixture and threat model are re-anchored for the lines this
change shifts, and the two boundary claims that #931 makes false -- the
"literal stdio bind" in B1 and the stdio contract at mcp_server.py:4 --
are restated. Without this the #956 guard fails, which is what it is for.

Non-goals, untouched: the remote listener (#938), per-request principal
resolution (#932), client-managed provenance policy (#934), TLS and
remote client authentication, role capability sets, repository-binding
semantics.

Tests: tests/test_issue_931_transport_bind_seam.py, 42 tests, covering
default bind, explicit stdio, the accepted non-stdio identifier, an
unregistered identifier, no bind at all, one-value agreement across
guards, post-bind environment tampering, the durable decision-lock
record, the rebind contract, and the #695 protections that must not
regress.

Full suite from the branches/ worktree: 28 failed, 5843 passed, 6
skipped, 1047 subtests. The pinned base 9b80e75c reports 28 failed, 5801
passed, 6 skipped, 1047 subtests. The failing test IDs are identical sets
-- no failure added, none fixed; the +42 passed are this PR's new module.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-28 09:59:01 -05:00
co-authored by Claude Opus 4.8
parent 9b80e75ca3
commit c162608175
9 changed files with 942 additions and 75 deletions
+105 -13
View File
@@ -32,6 +32,8 @@ import time
from pathlib import Path
from typing import Any
import mcp_transport_config
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
@@ -42,7 +44,9 @@ FORCE_PROVENANCE_FAIL_ENV = "GITEA_TEST_FORCE_UNSANCTIONED"
_NATIVE_RUNTIME: dict[str, Any] | None = None
# Production transport identifiers accepted by bind_native_mcp_transport.
_PRODUCTION_TRANSPORTS = frozenset({"stdio"})
# #931: the permitted set is defined once, in mcp_transport_config. This name
# is kept as an alias so the guard never restates a transport identifier.
_PRODUCTION_TRANSPORTS = mcp_transport_config.SUPPORTED_TRANSPORTS
_RUNTIME_MODE_PRODUCTION = "production"
_RUNTIME_MODE_TEST = "test"
_PHASE_ENTRYPOINT_CLAIMED = "entrypoint_claimed"
@@ -171,23 +175,46 @@ def mark_sanctioned_daemon() -> dict[str, Any]:
return native_runtime_status()
def bind_native_mcp_transport(*, transport: str) -> dict[str, Any]:
"""Bind the live native MCP transport lifecycle (#695).
def bind_native_mcp_transport(*, transport: str | None = None) -> dict[str, Any]:
"""Bind the live native MCP transport lifecycle (#695 / #931).
Must be called from the resolved canonical entrypoint immediately before
the real MCP server transport loop (e.g. ``mcp.run(transport=\"stdio\")``).
Requires a prior successful :func:`mark_sanctioned_daemon` claim in this
process. Import-only or offline launch without this bind leaves
the real MCP server transport loop (``mcp.run``). Requires a prior
successful :func:`mark_sanctioned_daemon` claim in this process.
Import-only or offline launch without this bind leaves
:func:`is_native_mcp_transport` false.
#931: ``transport`` is now optional. Omitting it — which is what the
production entrypoint does — resolves the identifier from deployment
configuration via :func:`mcp_transport_config.resolve_configured_transport`,
yielding :data:`mcp_transport_config.DEFAULT_TRANSPORT` when nothing is
configured. An explicit argument remains supported for tests and for a
launcher that has already resolved the value. Either way the identifier is
validated against the single permitted set before the runtime record is
written, so no tool can dispatch over an unregistered transport.
The resolved value is pinned into the process-local record and is read back
only through :func:`bound_transport`. Rebinding to a different transport is
refused, so two guards can never observe different values in one process.
"""
global _NATIVE_RUNTIME
transport_name = (transport or "").strip().lower()
if transport_name not in _PRODUCTION_TRANSPORTS:
raise UnsanctionedRuntimeError(
f"bind_native_mcp_transport rejected: transport {transport!r} is "
f"not a production MCP transport (#695). Allowed: "
f"{sorted(_PRODUCTION_TRANSPORTS)}."
)
if transport is None:
resolution = mcp_transport_config.resolve_configured_transport()
transport_name = str(resolution["transport"])
if not resolution["supported"]:
raise UnsanctionedRuntimeError(
"bind_native_mcp_transport rejected: "
+ "; ".join(resolution["reasons"])
+ " No tool is served over an unregistered transport."
)
else:
transport_name = mcp_transport_config.normalize_transport(transport)
if transport_name not in _PRODUCTION_TRANSPORTS:
raise UnsanctionedRuntimeError(
f"bind_native_mcp_transport rejected: transport {transport!r} is "
f"not a production MCP transport (#695). Allowed: "
f"{sorted(_PRODUCTION_TRANSPORTS)}."
)
entrypoint_path = _caller_official_entrypoint_path()
if entrypoint_path is None:
@@ -216,6 +243,23 @@ def bind_native_mcp_transport(*, transport: str) -> dict[str, Any]:
"between mark and bind (#695)."
)
# #931: one process binds one transport. Re-binding the same identifier is
# idempotent (a retried launch step must not fail); re-binding a different
# one is refused, because a guard that already read the first value would
# otherwise disagree with a guard that reads the second.
already_bound = (_NATIVE_RUNTIME.get("transport") or "").strip()
if (
already_bound
and _NATIVE_RUNTIME.get("phase") == _PHASE_TRANSPORT_BOUND
and already_bound != transport_name
):
raise UnsanctionedRuntimeError(
"bind_native_mcp_transport rejected: transport is already bound to "
f"{already_bound!r} in this process; rebinding to "
f"{transport_name!r} is forbidden (#931). Restart the server to "
"change the deployment transport."
)
# Pin session-state root for this server lifetime (#695 AC2 / PR #701).
# Changing GITEA_MCP_SESSION_STATE_DIR after bind must not manufacture a
# second authority domain for decision locks / workflow proofs.
@@ -353,6 +397,41 @@ def is_production_native_mcp_transport() -> bool:
return (_NATIVE_RUNTIME or {}).get("mode") == _RUNTIME_MODE_PRODUCTION
def bound_transport() -> str | None:
"""The one authoritative bound transport identifier, or ``None`` (#931).
This is the shared accessor every transport-aware guard reads. It reports
the value pinned at bind time, never the environment, so changing
``GITEA_MCP_TRANSPORT`` after the bind cannot move what a guard observes —
the same rule :func:`pinned_session_state_dir` applies to session state.
``None`` means unbound: an offline import or a launch that never reached
the bind. Callers must treat that as fail-closed, exactly as they already
treat :func:`is_native_mcp_transport` returning false.
"""
if not is_native_mcp_transport():
return None
return (_NATIVE_RUNTIME or {}).get("transport") or None
def assert_transport_bound(context: str = "tool service") -> str:
"""Return the bound transport, or fail closed before *context* (#931).
Called immediately before the server enters its transport loop so an
invalid or absent bind stops the process rather than serving tools over a
transport no guard can name.
"""
transport = bound_transport()
if transport:
return transport
raise UnsanctionedRuntimeError(
f"No MCP transport is bound; refusing {context} (#931). "
"bind_native_mcp_transport must succeed from the canonical entrypoint "
"before any tool is served. Offline import and standalone launch "
"cannot reconstruct a bind."
)
def is_sanctioned_mcp_daemon() -> bool:
"""Backward-compatible name; #695 requires native transport, not env alone."""
if is_production_native_mcp_transport():
@@ -476,6 +555,14 @@ def native_runtime_status() -> dict[str, Any]:
"entrypoint_path": rt.get("entrypoint_path"),
"phase": rt.get("phase"),
"transport": rt.get("transport"),
# #931: the authoritative bound identifier, plus the seam that defines
# what may be bound. ``bound_transport`` is None until a bind succeeds,
# so an offline import is distinguishable from a stdio session.
"bound_transport": bound_transport(),
"transport_bound": bound_transport() is not None,
"default_transport": mcp_transport_config.DEFAULT_TRANSPORT,
"supported_transports": list(mcp_transport_config.supported_transports()),
"transport_env": mcp_transport_config.TRANSPORT_ENV,
"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,
@@ -502,7 +589,12 @@ def mutation_provenance_fields() -> dict[str, Any]:
if st.get("mode") == _RUNTIME_MODE_TEST and st["native_mcp_transport"]:
transport = "test_native_mcp"
return {
# ``transport`` stays the trust *class* it has always been, so existing
# durable records keep their shape. ``bound_transport`` (#931) adds the
# bound identifier itself, which is what lets an operator tell from a
# durable record which transport performed a mutation.
"transport": transport,
"bound_transport": st.get("bound_transport"),
"native_mcp_transport": bool(st["native_mcp_transport"]),
"production_native_mcp_transport": bool(
st.get("production_native_mcp_transport")