ADR-051: Swappable MCP Ingress MountΒΆ
- Status: Accepted
- Date: 2026-04-18
- Deciders: Platform Team
- Related: ADR-043: Rust MCP Runtime Sidecar with Mode-Based Rollout, ADR-050: Defer Generic Cluster-Wide Settings Propagation Framework, Issue #4273: Runtime-mutable RUST_MCP_MODE, Issue #4278: Propagate runtime MCP mode override into the public reverse proxy
ContextΒΆ
Issue #4273 introduced the runtime-mutable MCP mode override. The original implementation mounted an MCPStreamableHTTPModeDispatcher at /mcp that held two hard-coded transports β MCPRuntimeHeaderTransportWrapper (Python) and RustMCPRuntimeProxy (the trusted PythonβRust forwarder over MCP_RUST_LISTEN_HTTP / UDS) β and chose between them per request based on _should_mount_public_rust_transport(). That worked for the two transports we had at the time but pinned the dispatcher to a closed set and embedded the selection policy inside the dispatch method.
Two near-term needs surfaced that the dispatcher couldn't accommodate cleanly:
-
An nginx-style reverse proxy to the Rust public listener. In a single-process / no-nginx deployment, edge mode still routes through Python's
RustMCPRuntimeProxyover the trusted-internal listener (MCP_RUST_LISTEN_HTTP). For deployments without nginx in front, the gateway can also play the nginx role itself by reverse-proxying to the Rust public listener atMCP_RUST_PUBLIC_LISTEN_HTTP. This is a third ingress kind, not a different way of using the existing two. -
Future ingresses we don't yet have specs for. Shadow-comparison (run both transports, compare results), percentage traffic split, per-method routing (POST β Rust direct, GET SSE β Python), header-based A/B canaries β each is a distinct ingress shape, not a tweak to the selection policy. Adding them under the closed dispatcher requires either growing its constructor signature with each new transport or subclassing repeatedly.
ADR-050 deferred the broader cluster-wide settings framework on the grounds that N=1 is too few examples to abstract. The MCP ingress case is different: we're already moving from N=2 to N=3 ingresses inside the same mount, and the future N is plausibly larger. The abstraction sweet spot is here.
DecisionΒΆ
We replace MCPStreamableHTTPModeDispatcher with MCPIngressMount: a thin ASGI indirection that holds a registry of named ASGI ingresses and a swappable selector function. The mount itself owns no policy.
Implementation:
-
mcpgateway/transports/mcp_ingress_mount.pyβMCPIngressMountclass.register(name, app)adds an ingress;set_selector(fn)swaps the policy;dispatch(scope, receive, send)is the ASGI entry point. Selector receives the ASGI scope so policies can inspect method/path/headers without changing the mount API. -
mcpgateway/transports/rust_mcp_public_proxy.pyβRustMCPPublicProxyApp, an ASGI app that does nginx-style reverse-proxy toMCP_RUST_PUBLIC_LISTEN_HTTP. AddsX-Forwarded-{For,Proto,Host}, RFC 7239Forwarded, andX-Real-IPso the Rust public listener's/_internal/mcp/authenticatecallback sees the original client info. Streams both directions; preservesAuthorization/Cookie(this is a public hop, not trusted-internal); does NOT inject any trust-marker headers. Built viabuild_rust_public_proxy_app()factory. -
mcpgateway/main.pyβ_select_mcp_ingress(scope)is the single selection policy._build_mcp_transport_app()constructs the mount, registerspython+rust-internal(always for boot=shadow/edge) andrust-public(only for boot=edge β shadow doesn't bind the Rust public listener). The mount is exposed under the legacymcp_transport_appmodule attribute and aliasesdispatchashandle_streamable_httpso theapp.mount("/mcp", app=mcp_transport_app.handle_streamable_http)line stays unchanged. -
mcpgateway/config.pyβ two new settings: mcp_rust_ingress: str = "internal"β selector input, picks between the two Rust ingress shapes ("internal"or"public").mcp_rust_public_proxy_upstream: str = "http://127.0.0.1:8787"β default upstream forRustMCPPublicProxyApp, matchingdocker-entrypoint.sh'sMCP_RUST_PUBLIC_LISTEN_HTTP=0.0.0.0:8787accessed via loopback inside the same pod.
The selection policy in _select_mcp_ingress preserves all the existing safety invariants:
_should_mount_public_rust_transport()is consulted first β same function the runtime-mode override coordinator already gates on, so shadow override / edge boot / safety-flag check / boot=full all behave identically to the prior dispatcher.- Within the Rust branch, the
mcp_rust_ingresssetting selects between the two shapes. Default is"internal"to preserve the prior behavior bit-for-bit; operators opt into the public-proxy shape with a single setting change. - For boot=full, the mount isn't used at all β the plain
RustMCPRuntimeProxyis mounted directly per the prior code, since full-boot has no dispatcher (flippingfullwould orphan Rust-held session/event-store state per ADR-043).
Why this isn't the framework ADR-050 deferredΒΆ
ADR-050 deferred a generic cluster-wide settings propagation framework. This ADR is scoped to the single mount point that serves /mcp and the ingress shapes that mount can hold. It doesn't introduce a registry of runtime settings, a propagation channel, or a coordinator β those remain purpose-built in runtime_state.py. The MoveCompatibility/safety invariant gate, the boot reconcile status surfacing, the audit pipeline all stay exactly where they are; the new mount just consumes should_mount_public_rust_transport() and settings.mcp_rust_ingress the same way the prior dispatcher consumed the same predicates.
ConsequencesΒΆ
PositiveΒΆ
- Adding a new ingress is one
register()call. No conditional in the dispatch hot path, no class hierarchy churn. Future shapes (shadow-comparison, percentage traffic split, per-method routing) plug in without touching the mount class or the existing ingresses. - Each ingress is a plain ASGI 3.0 callable. Testable in isolation by calling
await ingress(scope, receive, send)with a mock β seetests/unit/mcpgateway/transports/test_mcp_ingress_mount.py. - Selector swap is atomic.
set_selector(fn)replaces the policy without rebuilding the mount or touching ingress registrations. In practice the selector is set once at boot and the runtime-mode override coordinator drives behavior changes through the predicates the selector reads β but the mechanism for hot-swapping the policy itself is now there. - Operator-facing 503 names the missing ingress. When a deployment is misconfigured (selector returns an ingress name not registered for this build) the response body reads, e.g.,
MCP ingress 'rust-public' is not registered for this build; available: ['python', 'rust-internal']. - The nginx-style public proxy is now a usable production option. Single-process / no-proxy edge deployments can set
mcp_rust_ingress=publicand route directly to Rust's public listener without nginx in the picture β partially closing the gap tracked in #4278 for non-nginx topologies. - Drain semantics preserved. Per-request selection means in-flight requests on a deselected ingress complete on their original handler; only newly-accepted requests follow the new selection. Same property the prior dispatcher had β see the in-flight drain test in
tests/unit/mcpgateway/test_main_extended.py.
NegativeΒΆ
- One more abstraction to learn. Contributors wanting to understand the
/mcpmount now read three modules (mcp_ingress_mount.py, the ingress implementations, the selector inmain.py) instead of one dispatcher class. The mount is intentionally ~140 LOC including docstrings, and_select_mcp_ingressis one screen of code, so the additional cognitive load is small. - The legacy
handle_streamable_httpalias is a wart. The mount exposesdispatchas the natural ASGI entry point but theapp.mount("/mcp", app=mcp_transport_app.handle_streamable_http)line inmain.pypredates the rename. Aliasing one to the other keeps the mount line unchanged; a follow-up could change the mount line and drop the alias. - Public-listener proxy shares a port with the internal listener.
MCP_RUST_LISTEN_HTTP=127.0.0.1:8787andMCP_RUST_PUBLIC_LISTEN_HTTP=0.0.0.0:8787are the same port on different bind addresses. Operators who want to run both listeners from outside the defaultdocker-entrypoint.shflow need to be aware that the Rust binary picks one or the other based on env at startup β this isn't something the new ingress shape can change.
NeutralΒΆ
- The runtime-mode override admin API (
PATCH /admin/runtime/mcp-mode) is unchanged. It still reads/writesRuntimeState; the selector picks it up via_should_mount_public_rust_transport()on the next request. - Docs at
docs/docs/architecture/rust-mcp-runtime.mdand the operator guidance in.env.example/docker-compose.ymlcontinue to describe shadow β edge as the user-visible toggle. The internal/public ingress choice is a deployment-shape setting separate from the runtime override.
Migration from MCPStreamableHTTPModeDispatcherΒΆ
Code consumers:
mcp_transport_app: MCPStreamableHTTPModeDispatcherβmcp_transport_app: MCPIngressMountfor boot=shadow/edge. Same attribute on the module, different class.dispatcher._python_transport/_rust_transportβmount._ingresses["python"]/mount._ingresses["rust-internal"](ormount.names()for the registered set).dispatcher.handle_streamable_http(scope, receive, send)βmount.dispatch(scope, receive, send). Thehandle_streamable_httpalias is preserved on the mount so the existingapp.mount(...)line works unchanged.
Tests:
- The dispatcher-behavior tests in
tests/unit/mcpgateway/test_main_extended.py(theTestConditionalPaths::test_import_*group plus the twotest_mcp_ingress_mount_*async tests) now assert againstMCPIngressMount/_select_mcp_ingressinstead of the prior dispatcher class. Behavior assertions (per-request routing, in-flight drain) are preserved. - New focused tests for the mount itself live at
tests/unit/mcpgateway/transports/test_mcp_ingress_mount.py. - New focused tests for the public-listener proxy (header forwarding, XFF spoof rejection, hop-by-hop stripping, error mapping, streaming close-on-exit) live at
tests/unit/mcpgateway/transports/test_rust_mcp_public_proxy.py.
When to revisitΒΆ
Build the next layer of abstraction (e.g., a registry-based ingress plugin system that auto-registers from setup.cfg entry points) when:
- A second non-Rust ingress shape appears that benefits from registration outside
main.pyβ e.g., a third-party plugin shipping its own MCP ingress. - The selector grows past ~50 LOC of policy. At that point the right move is to extract policy into composable predicates rather than to change the mount.
Until then, registration in main.py is the simplest thing that works and keeps the available ingress set auditable from one location.
ReferencesΒΆ
- Implementation:
mcpgateway/transports/mcp_ingress_mount.pymcpgateway/transports/rust_mcp_public_proxy.pymcpgateway/main.py(_build_mcp_transport_app,_select_mcp_ingress)mcpgateway/config.py(mcp_rust_ingress,mcp_rust_public_proxy_upstream)- Tests:
tests/unit/mcpgateway/transports/test_mcp_ingress_mount.pytests/unit/mcpgateway/test_main_extended.py(the twotest_mcp_ingress_mount_*tests + the threetest_import_*tests)- Architecture overview: Rust MCP Runtime
- Reverse-proxy follow-up: #4278