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:
@@ -0,0 +1,154 @@
|
||||
"""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})
|
||||
|
||||
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 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"])
|
||||
Reference in New Issue
Block a user