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:
+105
-13
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user