Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

What is ContextForge Gateway?

Welcome to the ContextForge Gateway book.

contextforge-gateway-rs is the Rust dataplane for ContextForge: a single MCP entry point that sits in front of many backend MCP servers and makes them look like one. If you have used an API gateway or reverse proxy for HTTP APIs, this is the same idea for the Model Context Protocol (MCP).

Concretely, the gateway accepts MCP streamable HTTP traffic from a client, authenticates the caller, loads that caller’s runtime configuration, opens MCP client sessions to the backend MCP servers that configuration allows, and presents those backends to the client as one merged MCP server.

Throughout this book, downstream means the client side of the gateway and upstream means the backend side.

Gateway overview

Blue arrows show request traffic. Green arrows show backend responses returning to the gateway and the merged MCP response going back to the client.

LayerWhat happens
Client edgeAn MCP client calls /contextforge-rs/servers/{virtual_host_id}/mcp with a bearer token and MCP session headers.
Gateway hot pathThe gateway validates identity, loads runtime config, selects the virtual host, and routes MCP methods.
Backend edgeThe gateway opens or reuses MCP client sessions to configured backend MCP servers and merges what the client sees.

Key idea: downstream clients interact with one logical MCP server. The gateway decides which configured backend sessions are involved.

The gateway is not the management application. It does not own the UI, IAM lifecycle, tenant administration, durable metrics storage, or long-lived configuration editing. Those concerns stay in the external ContextForge control plane. This repository owns the hot request path and the runtime pieces needed to make that path fast, observable, and enforceable.

Most operators should not think about backend MCP servers directly. They should think about one client-facing MCP endpoint:

/contextforge-rs/servers/{virtual_host_id}/mcp

Behind that endpoint, the gateway has three main boundaries:

  • the downstream boundary, where clients connect over streamable HTTP and present their bearer token and MCP session id
  • the configuration boundary, where the gateway turns the JWT subject into a runtime UserConfig
  • the upstream boundary, where the gateway opens or reuses MCP client sessions to the configured backend servers

Most bugs in this service come from confusing those boundaries. A downstream request is not allowed to pick arbitrary upstream backends. Redis is not the routing model; it is the current config-store transport. Backend sessions are not durable cluster state; they are local process state today.

BoundaryInputOutputOwner
DownstreamHTTP request, JWT, MCP headersrequest extensions and downstream session idgateway
ConfigurationJWT subject and virtual host idUserConfig and selected VirtualHostcontrol plane data, gateway enforcement
Upstreamselected backend names and URLsrunning backend MCP client servicesgateway

Key terms

These words appear on almost every page. It is worth pinning them down once.

TermMeaning in this book
DataplaneThe process on the live request path (this repository). It handles MCP traffic.
Control planeThe external ContextForge application that authors config, policy, and identity. It is not in this repository.
DownstreamThe client side of the gateway: the calling MCP client and its requests.
UpstreamThe backend side of the gateway: the configured backend MCP servers.
Virtual hostA named routing group inside the caller’s config. The URL path selects exactly one.
BackendOne configured MCP server behind the gateway. Its map key becomes a routing prefix when a virtual host has multiple backends.
PrincipalThe authenticated caller identity, taken from the JWT sub claim.
SessionAn initialized MCP session, tracked by Mcp-session-id, that owns the per-caller backend client sessions.

Mental model

The gateway is easiest to understand as one logical MCP server assembled from a set of configured backend MCP servers.

On initialize, the gateway validates the caller, resolves the requested virtual host, and creates upstream MCP client sessions for that virtual host’s backends. On list calls, it fans out to those backends and merges the result. On targeted calls, it uses an explicit tool alias, the only configured backend, or a multi-backend prefix to route to exactly one backend.

For a stateful MCP call to work, five facts must line up:

JWT subject
  -> user config
  -> virtual host id
  -> downstream MCP session id
  -> backend session map entries

If any part is missing, the gateway should fail at the layer that owns that fact: auth, config lookup, virtual host resolution, session lookup, routing, or upstream transport.

Operational consequence: stateful MCP traffic needs session affinity until backend session state moves out of the gateway process.

Non-goals

This repository should not grow control-plane behavior by accident. It should not become the admin UI, tenant management API, policy authoring system, credential store, or durable observability backend.

It also should not expose backend topology as more than the MCP routing contract requires. Backend map keys are visible when multi-backend identifiers need prefixes, but clients should still experience one gateway endpoint and one logical MCP server.

Where to go next

Getting Started

This section is for the first working run of the gateway: start the local dependencies, launch the Rust dataplane, seed runtime config, and send real MCP traffic through it.

🚀 Goal: get from a clean checkout to one client-facing MCP endpoint at /contextforge-rs/servers/{virtual_host_id}/mcp.

PageWhat it covers
🚀 Run the Gateway LocallyDocker services, local bootstrap helpers, user config, MCP session setup, and smoke tests.
⚙️ Configuration ReferenceListener settings, Redis wiring, JWT verification, upstream transport, telemetry, logging, and runtime knobs.

What “started” means

A useful local gateway run has these pieces:

PieceWhy it matters
RedisHolds runtime UserConfig keyed by JWT subject.
Backend MCP serversProvide the tools, resources, and prompts the gateway merges.
Gateway listenerAccepts downstream streamable HTTP MCP traffic.
JWT verification key or secretLets the gateway authenticate downstream requests.
User configMaps the caller’s JWT subject to virtual hosts and backend MCP URLs.
MCP sessionBinds downstream session state to backend MCP client sessions.

If one of those is missing, the gateway should fail at the boundary that owns that fact: authentication, config lookup, virtual host resolution, session lookup, routing, or upstream transport.

Run the Gateway Locally

This page walks through a local run that proves the dataplane can authenticate a caller, load runtime config from Redis, initialize backend MCP sessions, and return merged MCP results to a downstream client.

The local flow uses the repository’s Docker Compose stack for Redis and two sample backend MCP servers:

ServiceLocal portRole
redis6379Runtime config store for user config.
gateway-one5555Sample counter MCP backend.
gateway-two5556Sample conformance MCP backend.
contextforge-gateway-rs8001Rust dataplane process you run with Cargo.

🧪 The token and user-config endpoints below are local bootstrap helpers. They are compiled with contextforge-gateway-rs-lib/with_tools. In a real deployment, the external ContextForge control plane mints tokens and writes config to Redis.

Run the numbered steps in order, in the same terminal. Later steps reuse shell variables such as ${TOKEN} and ${SESSION_ID} that earlier steps set, so a fresh shell will not have them.

Prerequisites

  • Rust toolchain matching the workspace rust-version.
  • Docker Compose.
  • Free local ports: 6379, 16379, 5555, 5556, and 8001. The Compose stack maps both plain (6379) and TLS (16379) Redis ports.
  • Test keys under assets/: jwt.key and jwt.key.pub.

1. Start Redis and Backend MCP Servers

docker compose -f docker/docker-compose-local.yaml up -d

The sample backends default to a CPU limit of 8 and a reservation of 4, which Docker rejects on hosts with fewer CPUs (range of CPUs is from 0.01 to ...). On smaller machines, override the sizing knobs:

GATEWAY_CPU_LIMIT=2 GATEWAY_CPU_RESERVATION=0.5 \
  docker compose -f docker/docker-compose-local.yaml up -d

Check that the local dependencies are running:

docker compose -f docker/docker-compose-local.yaml ps redis gateway-one gateway-two

The backends listen on the host so the gateway can reach them at http://127.0.0.1:5555/mcp and http://127.0.0.1:5556/mcp.

2. Start the Gateway

For the local bootstrap flow, run the binary with the with_tools dependency feature so the admin token and config endpoints are available:

cargo run -p contextforge-gateway-rs \
  --features contextforge-gateway-rs-lib/with_tools \
  --bin contextforge-gateway-rs -- \
  --address 127.0.0.1:8001 \
  --redis-address 127.0.0.1 \
  --redis-port 6379 \
  --redis-mode plain-text \
  --token-verification-public-key assets/jwt.key.pub \
  --token-verification-private-key assets/jwt.key \
  --upstream-connection-mode plain-text-or-tls \
  --number-of-cpus 4

Keep this process running. The gateway exposes MCP traffic under:

http://127.0.0.1:8001/contextforge-rs/servers/{virtual_host_id}/mcp

The local command uses --upstream-connection-mode plain-text-or-tls because the sample backend URLs are plain HTTP. Without that option, the default upstream client is HTTPS-only.

3. Mint a Test Token

In another terminal, request a JWT for the test subject:

TOKEN=$(curl --silent --show-error \
  --url http://127.0.0.1:8001/contextforge-rs/admin/tokens/admin@example.com)

printf '%s\n' "${TOKEN}"

The token’s sub claim is admin@example.com. The gateway uses that subject as the Redis user-config key.

4. Write User Config

Seed Redis with one virtual host and two backend MCP servers:

curl --silent --show-error --request POST \
  --url http://127.0.0.1:8001/contextforge-rs/admin/userconfigs/admin@example.com \
  --header 'content-type: application/json' \
  --data '{
    "virtual_hosts": {
      "c0ffee00f001f00lf00ldeadbeefdead": {
        "backends": {
          "gateway-one": {
            "name": "gateway-one",
            "url": "http://127.0.0.1:5555/mcp",
            "transport": "STREAMABLEHTTP",
            "passthrough_headers": [],
            "allowed_tool_names": [],
            "allowed_resource_names": [],
            "allowed_prompt_names": []
          },
          "gateway-two": {
            "name": "gateway-two",
            "url": "http://127.0.0.1:5556/mcp",
            "transport": "STREAMABLEHTTP",
            "passthrough_headers": [],
            "allowed_tool_names": [],
            "allowed_resource_names": [],
            "allowed_prompt_names": []
          }
        }
      }
    }
  }'

The important relationship is:

JWT subject admin@example.com
  -> Redis user config
  -> virtual host c0ffee00f001f00lf00ldeadbeefdead
  -> backend MCP URLs

5. Initialize an MCP Session

Open a streamable HTTP MCP session and save the returned mcp-session-id header:

INIT_HEADERS=$(mktemp)

curl --silent --show-error \
  --dump-header "${INIT_HEADERS}" \
  --url http://127.0.0.1:8001/contextforge-rs/servers/c0ffee00f001f00lf00ldeadbeefdead/mcp \
  --header "authorization: Bearer ${TOKEN}" \
  --header 'content-type: application/json' \
  --header 'accept: application/json, text/event-stream' \
  --data '{
    "jsonrpc": "2.0",
    "id": 0,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-11-25",
      "capabilities": {},
      "clientInfo": { "name": "curl", "version": "0.1.0" }
    }
  }'

SESSION_ID=$(awk 'tolower($1) == "mcp-session-id:" { gsub("\r", "", $2); print $2 }' "${INIT_HEADERS}")
printf '%s\n' "${SESSION_ID}"

During initialize, the gateway opens upstream MCP client sessions to the configured backends and stores them under the downstream session id.

Send the MCP initialized notification:

curl --silent --show-error \
  --url http://127.0.0.1:8001/contextforge-rs/servers/c0ffee00f001f00lf00ldeadbeefdead/mcp \
  --header "authorization: Bearer ${TOKEN}" \
  --header "mcp-session-id: ${SESSION_ID}" \
  --header 'mcp-protocol-version: 2025-11-25' \
  --header 'content-type: application/json' \
  --header 'accept: application/json, text/event-stream' \
  --data '{"jsonrpc":"2.0","method":"notifications/initialized"}'

6. List Tools

curl --silent --show-error \
  --url http://127.0.0.1:8001/contextforge-rs/servers/c0ffee00f001f00lf00ldeadbeefdead/mcp \
  --header "authorization: Bearer ${TOKEN}" \
  --header "mcp-session-id: ${SESSION_ID}" \
  --header 'mcp-protocol-version: 2025-11-25' \
  --header 'content-type: application/json' \
  --header 'accept: application/json, text/event-stream' \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

The response should contain tool names prefixed with their backend name, such as gateway-one-increment. That prefix is the routing contract for later tools/call requests.

7. Call a Tool

curl --silent --show-error \
  --url http://127.0.0.1:8001/contextforge-rs/servers/c0ffee00f001f00lf00ldeadbeefdead/mcp \
  --header "authorization: Bearer ${TOKEN}" \
  --header "mcp-session-id: ${SESSION_ID}" \
  --header 'mcp-protocol-version: 2025-11-25' \
  --header 'content-type: application/json' \
  --header 'accept: application/json, text/event-stream' \
  --data '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "gateway-one-increment",
      "arguments": {}
    }
  }'

The gateway strips gateway-one-, forwards increment to the gateway-one backend session, and returns the backend result to the client.

8. End the Session

curl --silent --show-error --request DELETE \
  --url http://127.0.0.1:8001/contextforge-rs/servers/c0ffee00f001f00lf00ldeadbeefdead/mcp \
  --header "authorization: Bearer ${TOKEN}" \
  --header "mcp-session-id: ${SESSION_ID}"

The gateway removes local session state and backend transports for that user and MCP session id.

Troubleshooting

SymptomLikely boundary
401 UnauthorizedMissing bearer token, invalid signature, expired token, wrong issuer, wrong audience, or no configured decoder key.
400 Problem occurred retrieving the configurationRedis has no config for the JWT subject, or the Redis lookup returned no data.
500 Problem occurred retrieving the configurationThe stored config could not be decoded, the Redis key could not be encoded, or another non-missing config-store error occurred.
404 with {"detail":"Server not found"}The path virtual host id is not present in that user’s config.
MCP session id is emptyThe initialize call failed before RMCP created a downstream session.
Backend calls failBackend URL is wrong, backend process is down, or --upstream-connection-mode rejects the URL scheme.
Calls fail after gateway restartBackend MCP session state is local process state today. Re-run initialize.

Tear Down

Stop the gateway with Ctrl-C, then stop local dependencies:

docker compose -f docker/docker-compose-local.yaml down

Configuration Reference

This page is the reference for every gateway setting. The binary parses its configuration with clap, so each option has both a CLI flag and an environment variable, and both feed the same Config struct. When a setting is supplied as a flag and an environment variable at the same time, the command-line flag wins.

For the always-current list, ask the binary directly:

cargo run -p contextforge-gateway-rs --bin contextforge-gateway-rs -- --help

The sections below group the options by concern: listeners, JWT, Redis, upstream transport, runtime, telemetry, and logging.

Minimum Useful Configuration

At startup, the CLI requires Redis location and Redis connection mode:

--redis-address
--redis-port
--redis-mode

To serve useful traffic, the process also needs:

NeedTypical flag
At least one downstream listener--address 127.0.0.1:8001 or --tls-address 0.0.0.0:8443
JWT verification material--token-verification-public-key or --token-verification-secret
A compatible upstream client mode--upstream-connection-mode plain-text-or-tls for local HTTP backends
Runtime user config in RedisWritten by the control plane or by local bootstrap helpers

If no token verification key or secret is configured, authenticated MCP requests cannot pass the claims layer.

Listener Options

FlagEnv varRequiredMeaning
--address <host:port>CONTEXTFORGE_GATEWAY_RS_ADDRESSNoPlain HTTP listener. Omit it when serving only TLS.
--tls-address <host:port>CONTEXTFORGE_GATEWAY_RS_TLS_ADDRESSNoTLS listener address. Requires certificate and private key.
--server-certificate <path>CONTEXTFORGE_GATEWAY_RS_TLS_SERVER_CERTIFICATEWith --tls-addressPEM certificate chain for downstream TLS.
--server-private-key <path>CONTEXTFORGE_GATEWAY_RS_TLS_SERVER_PRIVATE_KEYWith --tls-addressPEM private key for downstream TLS.

--address and --tls-address may both be configured, but they must not use the same socket address.

JWT Options

FlagEnv varRequiredMeaning
--token-verification-public-key <path>CONTEXTFORGE_GATEWAY_RS_TOKEN_VERIFICATION_PUBLIC_KEYFor RSA tokensRSA public key used for RS256, RS384, or RS512 tokens.
--token-verification-secret <secret>CONTEXTFORGE_GATEWAY_RS_TOKEN_SECRETFor HMAC tokensShared secret used for HS256, HS384, or HS512 tokens.
--token-verification-private-key <path>CONTEXTFORGE_GATEWAY_RS_TOKEN_VERIFICATION_PRIVATE_KEYLocal tools onlyRSA private key used by the optional local token helper. Present only when contextforge-gateway-rs-lib/with_tools is enabled.

The claims layer validates issuer, audience, and expiration:

ClaimExpected value
issmcpgateway
audmcpgateway-api
expPresent and not expired

The JWT sub claim selects the Redis user config key. The path virtual host id then selects one virtual host inside that config.

Redis Options

FlagEnv varRequiredMeaning
--redis-address <host>CONTEXTFORGE_GATEWAY_RS_REDIS_HOSTNAMEYesRedis host name or IP.
--redis-port <port>CONTEXTFORGE_GATEWAY_RS_REDIS_PORTYesRedis port.
--redis-mode <mode>CONTEXTFORGE_GATEWAY_RS_REDIS_CONNECTION_MODEYesRedis connection mode: plain-text, tls, or mtls.
--redis-tls-trust-bundle <path>CONTEXTFORGE_GATEWAY_RS_REDIS_TLS_REDIS_TRUST_BUNDLETLS and mTLSPEM trust bundle for Redis TLS.
--redis-tls-client-certificate <path>CONTEXTFORGE_GATEWAY_RS_REDIS_TLS_REDIS_CLIENT_CERTIFICATEmTLSPEM client certificate for Redis mTLS.
--redis-tls-client-private-key <path>CONTEXTFORGE_GATEWAY_RS_REDIS_TLS_REDIS_CLIENT_PRIVATE_KEYmTLSPEM client private key for Redis mTLS.
--user-config-cache-expiry-seconds <n>CONTEXTFORGE_GATEWAY_RS_USER_CONFIG_CACHE_EXPIRY_SECONDSNo, default 60Expiry for the in-process user config cache in front of Redis. 0 disables caching and reads Redis on every request.

Local Compose exposes plain Redis on 127.0.0.1:6379, so local runs normally use:

--redis-address 127.0.0.1 \
--redis-port 6379 \
--redis-mode plain-text

Runtime config values are MessagePack encoded. Redis is the current transport for config, not the routing model itself.

Upstream MCP Transport Options

FlagEnv varRequiredMeaning
--upstream-connection-mode <mode>CONTEXTFORGE_GATEWAY_RS_UPSTREAM_CONNECTION_MODENoControls whether backend MCP URLs may be HTTP, HTTPS, or mTLS.
--upstream-trust-bundle <path>CONTEXTFORGE_GATEWAY_RS_TLS_UPSTREAM_TRUST_BUNDLENoAdditional PEM trust bundle for HTTPS upstreams.
--upstream-certificate <path>CONTEXTFORGE_GATEWAY_RS_TLS_UPSTREAM_CERTIFICATEmTLS modesPEM client certificate for upstream mTLS.
--upstream-private-key <path>CONTEXTFORGE_GATEWAY_RS_TLS_UPSTREAM_PRIVATE_KEYmTLS modesPEM client private key for upstream mTLS.

Connection modes:

ModeBehavior
omitted or tls-onlyHTTPS upstreams only. This is the safe default.
plain-text-or-tlsAllows HTTP and HTTPS upstream URLs. Use this for the local Compose backends.
plain-text-or-m-tlsAllows HTTP and HTTPS with client identity configured.
mtls-onlyRequires HTTPS and uses the configured client certificate and key.

If an upstream backend URL is http://... and the mode is omitted, calls fail before reaching that backend because the reqwest client is HTTPS-only.

Runtime Options

FlagEnv varDefaultMeaning
--number-of-cpus <n>CONTEXTFORGE_GATEWAY_RS_GATEWAY_CPUSHost CPU countWorker thread count for the Tokio runtime shape.
--single-runtime <bool>CONTEXTFORGE_GATEWAY_RS_SINGLE_RUNTIMEtruetrue uses one multi-thread runtime. false starts multiple current-thread runtimes.
--runtime-plugins-enabled <bool>CONTEXTFORGE_GATEWAY_RS_RUNTIME_PLUGINS_ENABLEDfalseEnables CPEX runtime plugin execution and Redis plugin config loading.

When runtime plugins are enabled, plugin config is read from Redis key ContextForgeGatewayRuntimePluginConfig. That key is a control-plane trust boundary because it decides which registered hooks run.

Telemetry Options

FlagEnv varDefaultMeaning
--enable-open-telemetry <bool>CONTEXTFORGE_GATEWAY_RS_ENABLE_OPEN_TELEMETRYfalseEnables trace export.
--enable-otel-metrics <bool>CONTEXTFORGE_GATEWAY_RS_ENABLE_OTEL_METRICSfalseEnables HTTP server metric export.
--otlp-protocol <protocol>CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_PROTOCOLgrpcgrpc or http-protobuf.
--otlp-endpoint <uri>CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_ENDPOINTProtocol-specificTrace export endpoint.
--otlp-metrics-endpoint <uri>CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_METRICS_ENDPOINTProtocol-specificMetrics export endpoint.
--otlp-headers <headers>CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_HEADERSnoneComma-separated key=value headers for OTLP export.
--otlp-service-name <name>CONTEXTFORGE_GATEWAY_RS_OTEL_SERVICE_NAMECONTEXTFORGE-GATEWAY-RSOpenTelemetry service.name.

Default endpoints:

ProtocolTracesMetrics
grpchttp://127.0.0.1:4317http://127.0.0.1:4317
http-protobufhttp://127.0.0.1:4318/v1/traceshttp://127.0.0.1:4318/v1/metrics

RUST_TRACE_LOG controls which spans reach the OTLP trace layer. The HTTP trace layer emits debug-level spans, so local trace verification usually needs:

RUST_TRACE_LOG=debug

Logging Options

Flag or env varDefaultMeaning
--log-name / CONTEXTFORGE_GATEWAY_LOG_NAMEcontextforge-gateway-rs.logFile log name in the current working directory.
--log-rotation / CONTEXTFORGE_GATEWAY_LOG_ROTATIONhourlyRotation mode: minutely, hourly, daily, or never.
RUST_LOGdebugConsole event filter.
RUST_FILE_LOGdebugFile event filter.
RUST_TRACE_LOGinfoOpenTelemetry span filter.

Common Flag Sets

Local HTTP Gateway And Plain Redis

--address 127.0.0.1:8001 \
--redis-address 127.0.0.1 \
--redis-port 6379 \
--redis-mode plain-text \
--token-verification-public-key assets/jwt.key.pub \
--upstream-connection-mode plain-text-or-tls

Downstream TLS Listener

--tls-address 0.0.0.0:8443 \
--server-certificate assets/contextforgeCA/contextforge-server.cert.pem \
--server-private-key assets/contextforgeCA/contextforge-server.key.pem

You may combine this with --address to expose both HTTP and HTTPS listeners.

HMAC Token Verification

--token-verification-secret "${CONTEXTFORGE_GATEWAY_RS_TOKEN_SECRET}"

Use this only when downstream JWTs are signed with an HS* algorithm. RSA tokens need --token-verification-public-key.

Redis TLS

--redis-address 127.0.0.1 \
--redis-port 16379 \
--redis-mode tls \
--redis-tls-trust-bundle assets/contextforgeCA/contextforge.intermediate.ca-chain.cert.pem

Upstream mTLS

--upstream-connection-mode mtls-only \
--upstream-trust-bundle assets/contextforgeCA/contextforge.intermediate.ca-chain.cert.pem \
--upstream-certificate assets/contextforgeCA/contextforge-client.cert.pem \
--upstream-private-key assets/contextforgeCA/contextforge-client.key.pem

The certificate and key paths must point to PEM files accepted by reqwest.

Startup Validation

The gateway fails fast for these invalid combinations:

Invalid combinationReason
--tls-address without server cert or keyDownstream TLS cannot be configured partially.
Same socket for --address and --tls-addressThe process cannot bind both listeners to the same address.
--redis-mode tls without trust bundleRedis TLS needs a root certificate bundle.
--redis-mode mtls without trust bundle, client cert, or client keyRedis mTLS needs all three pieces.
mTLS upstream mode without upstream cert and keyThe reqwest identity cannot be built.
Plain HTTP backend with default upstream modeDefault upstream mode is HTTPS-only.

Architecture

This section explains how the gateway is put together and why the main boundaries exist.

🧭 Read this when changing the hot path. The architecture pages keep request handling, config lookup, backend sessions, transports, and control-plane boundaries explicit.

The pages are ordered for a first read: start at the top for the big picture, then work down into each boundary. If you are changing one area, jump straight to its page.

PageWhat it covers
🧭 System ShapeThe gateway’s role in ContextForge, its crate layout, and the line between dataplane and control plane.
🔀 Request FlowThe ordered path from downstream HTTP request to backend MCP call and merged response.
🧵 Concurrency And Runtime ModelExecutor shapes, shared state and locks, fanout, cancellation, and the allocator.
🔐 Authentication And User Config LookupHow JWT claims, Redis-backed user config, and virtual host selection combine before routing.
🔒 Security Model And Trust BoundariesWhat the gateway trusts, what compromise of each boundary means, and transport security posture.
🗂️ Runtime ConfigurationThe current UserConfig model, MessagePack Redis persistence, cache behavior, and expected growth.
🤝 Control-Plane IntegrationThe current, still-provisional integration surface: Redis keys, schemas, token shape, and route parity.
🔌 Backend Connections And TransportsDownstream listeners, upstream RMCP transports, config-store transport, and TLS direction.
🧵 Session OwnershipHow backend services are keyed, shared, cleaned up, and constrained by local process ownership.
🧱 Architectural ChoicesThe main tradeoffs behind dataplane scope, namespacing, config boundaries, and future protocols.

System Shape

🧭 Architecture lens: this page explains what the gateway is, what it is not, and which code owns each boundary.

System shape

The ContextForge Gateway is the Rust dataplane process for MCP traffic. It is not a second ContextForge application. It accepts downstream streamable HTTP MCP requests, builds request context, loads runtime config, opens or reuses backend MCP client sessions, and returns one merged MCP server view to the caller.

That shape matters because this repository sits on the traffic path. Its design should bias toward predictable request behavior, explicit state ownership, and small hot-path dependencies.

Three-Layer Model

The gateway is easiest to reason about as three layers:

LayerOwnsDoes not own
ContextForge control planeManagement workflows, UI, IAM lifecycle, durable config ownership, policy authoring, observability storage.Hot MCP request handling or in-process MCP sessions.
Rust gateway dataplaneAuth, config lookup, virtual host selection, MCP fanout, plugin hooks, telemetry emission, upstream calls.Control-plane APIs, customer IAM, UI, durable metrics storage.
Backend MCP serversActual tools, resources, prompts, and backend protocol behavior.Caller auth, virtual host selection, gateway-level policy.

In text form, the same model is:

ContextForge control plane
  -> writes runtime config and owns management workflows

contextforge-gateway-rs process
  -> authenticates, loads config, routes, fans out, applies hooks, emits signals

backend MCP servers
  -> own the actual tools, resources, and prompts

Boundary rule: Redis is the current config-store transport, not the architecture. Routing code should depend on UserConfigStore, not Redis commands.

Control Plane Boundary

The external ContextForge control plane owns the slow-changing product surface:

AreaWhy it stays outside this repo
Management APIs and UIThey are user/admin workflows, not request-path forwarding.
Credential and policy authoringThe dataplane consumes policy; it should not become the policy editor.
Persistent config ownershipDurable storage and schema lifecycle belong to the control plane.
Durable observability storageThe gateway emits telemetry; it should not become the metrics database.
Customer IAM lifecycleThe gateway validates presented identity, not the customer identity product.

The Rust dataplane owns the hot path:

AreaCode-facing meaning
Listener setupExpose downstream TCP and/or TLS endpoints.
JWT validationConvert bearer auth into request claims.
Runtime config lookupLoad the caller’s UserConfig by JWT subject.
MCP fanout and routingPresent multiple backends as one MCP server.
Request and response hooksRun plugin/policy hooks at explicit pipeline points.
Telemetry emissionEmit logs, traces, and metrics around the path.

The front door can route only MCP traffic to this process while leaving other ContextForge traffic on existing paths:

/contextforge-rs/servers/{virtual_host_id}/mcp

From a client point of view, that endpoint behaves like one ContextForge MCP server. Internally, it is a focused proxy, fanout, policy, and merge dataplane.

Process Assembly

Startup begins in crates/contextforge-gateway-rs/src/main.rs. The binary crate does process-level assembly:

install rustls crypto provider
  -> Config::parse()
  -> logging::init_tracing_logging(&config)
  -> runtime::Runtime::from(&config)
  -> optional CpexRuntimeRegistry
  -> Gateway::builder()
       .with_config(config)
       .with_user_config_store_type(UserConfigStoreType::Redis)
       .with_session_manager(LocalSessionManager::default())
       .with_plugin_runtime(...)
       .build()
  -> runtime.execute(gateway, plugin_registry)
StepOwnerResult
Parse configBinary crateA process config value used to build the gateway.
Initialize loggingBinary crateConsole/file logging and trace export are ready before serving traffic.
Select runtimeruntime::RuntimeEither one multi-thread Tokio runtime or multiple current-thread runtimes.
Start plugin runtimeCPEX registry pathOptional plugin manager and reloadable config loop.
Build gatewayGateway::builder()Dataplane dependencies are assembled and handed to the runtime.

runtime::Runtime changes executor shape, not gateway semantics. The default path is one multi-thread Tokio runtime. The binary crate also sets tikv_jemallocator as the global allocator and owns file logging, console logging, trace export, and metrics export setup.

Gateway Assembly

crates/contextforge-gateway-rs-lib/src/lib.rs builds the dataplane stack in Gateway::run_gateway:

RedisUserConfigStore
LocalUserSessionStore
BackendTransports
reqwest::Client for backend MCP calls
RMCP StreamableHttpService<McpService>
Axum middleware stack
TCP and/or TLS listeners

The resulting route tree is:

/contextforge-rs
  /servers/{virtual_host_name}/mcp
  /admin/...          local bootstrap helpers when with_tools is enabled
  /health            local bootstrap helper when with_tools is enabled

The route segment is named virtual_host_name in Axum, but the gateway extracts the actual path value as a VirtualHostId. Routing code should treat it as a virtual host id.

Workspace Layers

The repository is a Cargo workspace with narrow responsibilities:

CrateResponsibility
contextforge-gateway-rsProcess shell: CLI config, logging, telemetry exporters, Tokio runtime, allocator, plugin registry startup, gateway construction.
contextforge-gateway-rs-libDataplane library: Axum stack, request middleware, config lookup, MCP fanout/routing, backend sessions, upstream clients, downstream transports.
contextforge-gateway-rs-apisShared contract: UserConfig, VirtualHost, BackendMCPGateway, Redis user key, plugin config document, schema generation.
contextforge-gateway-rs-cpexPlugin integration: CPEX runtime registry, Redis plugin config loading, supported tool pre/post hooks, stream event adaptation.
contextforge-load-testPerformance harness: end-to-end MCP traffic driver.

Keep those boundaries stable. Adding dataplane behavior to the binary crate makes the process shell harder to test and reuse. Adding control-plane behavior to the library crate makes the hot path harder to reason about.

Pipeline Shape

The target shape is a bidirectional AI traffic pipeline: authentication, rate limiting, routing and protocol selection, request mutation, optional retrieval, and request guardrails on the way upstream; response guardrails, response mutation, and telemetry on the way back. Upstreams may eventually be MCP, A2A, or model providers. Preserve the ordering as pieces land: auth and config before backend selection, request plugins before upstream calls, response plugins before returning, telemetry around both sides.

The current code implements the MCP subset of that pipeline:

downstream request
  -> virtual host extraction
  -> JWT validation
  -> session extraction
  -> user config lookup
  -> MCP handler validation
  -> request plugin hooks
  -> backend MCP call

upstream response
  -> response plugin hooks
  -> merge, namespace, or pass through
  -> metrics, tracing, and logging
  -> downstream response

The Axum layer registration order is important because Tower layers execute from the outside in. The stack is built so the request reaches handlers with:

ContextInserted byUsed by
VirtualHostIdvirtual_host_id_layerInitialize and authorized MCP validators.
ContextForgeClaimsclaims_layerConfig lookup, session cleanup, routing.
SessionIdsession_id_layerAuthorized MCP calls after initialize.
UserConfiguser_config_store_layerVirtual host selection and backend selection.

After those extensions exist, virtual_host_config_layer rejects requests with 404 when the path’s virtual host id is not present in the loaded UserConfig, so MCP handlers only see resolvable virtual hosts.

initialize is the special call. RMCP provides a downstream session id before the gateway has a Mcp-session-id header. The gateway fans out to configured backends, opens upstream MCP client sessions, and stores those running services under:

principal + backend_name + downstream_session_id

Later calls use the Mcp-session-id header to find those stored backend services.

State Ownership

The architecture keeps request state, session state, runtime config, and process state in separate ownership scopes. The current ownership model is:

StateOwnerLifetime
CLI ConfigBinary startup and GatewayProcess lifetime.
JWT decodersContextForgeGatewayAppStateProcess lifetime.
User configUserConfigStore, currently Redis plus in-process LRUControl-plane authored, request-path consumed.
Request identityRequest extensionsOne HTTP request.
Virtual host idRequest extensionsOne HTTP request.
Downstream session idRMCP plus SessionId extensionMCP session.
Backend RMCP servicesBackendTransports mapLocal process, per principal/backend/session.
Local user session mappingLocalUserSessionStoreLocal process, per principal/session.
Runtime plugin managerCpexRuntimeRegistry and handleProcess lifetime, reloadable.
Logs, traces, metricsLogging setup and Axum/tower layersProcess lifetime.

Session rule: backend MCP services are local process state today. Load-balanced deployments need sticky routing, external session state, or a reinitialize-after-failover story.

Redis-backed user session storage exists in code, but the default gateway assembly wires LocalUserSessionStore. Do not assume session state is durable or shared across gateway nodes.

Module Boundaries

The library crate has deliberately narrow internal module roles:

ModuleOwns
common.rsCLI config shape, JWT claim shape, Redis config validation, upstream reqwest::Client construction.
layers/HTTP request extension extraction and request-bound validation.
gateway/MCP server behavior, initialize fanout, list merging, prefixed routing, backend service state.
gateway/session_store/Local and Redis-capable user session storage abstractions.
user_config_store/UserConfigStore trait and Redis-backed runtime config store.
transports/Downstream TCP and TLS listener setup.
tools.rsLocal bootstrap helpers compiled only with with_tools.

Concrete Redis commands stay behind UserConfigStore. Downstream listener transport stays in transports/. MCP method behavior stays in gateway/. Request extension extraction stays in layers/.

Reusable Shell

The code is MCP-first today, but the repository is intentionally not named or structured as only an MCP proxy. Authentication, configuration ingestion, TLS handling, plugin execution, telemetry, runtime shape, and session strategy are gateway-shell concerns.

MCP-specific behavior should remain isolated to the current MCP modules so future A2A or model-provider routing can reuse the shell instead of growing a parallel stack.

Why These Architecture Pages Exist

The architecture section is split into subpages because each page tracks a boundary that already exists in modules or runtime ownership:

SubpageReason it exists
Request FlowShows the ordered path through startup, middleware, initialize, and authorized calls.
Authentication And User Config LookupKeeps identity and config selection separate from MCP method handling.
Runtime ConfigurationDescribes the data model the gateway consumes, independent of Redis transport details.
Backend Connections And TransportsSeparates downstream, upstream, and config-store transports.
Session OwnershipMakes local backend session state and load-balancing constraints explicit.
Architectural ChoicesRecords tradeoffs that should not be changed accidentally.

This split should make later pages easier to fill in without turning the architecture chapter into one long mixed-concern essay.

Request Flow

🎯 Flow invariant: Axum builds request context before RMCP handlers route MCP methods. MCP handlers should read typed extensions, not parse headers, paths, or Redis keys directly.

Request flow

Graph Legend

The graph uses color only to separate paths:

ColorMeaning
BlueRequest direction: listener, middleware, RMCP dispatch, and backend calls.
GreenResponse direction: backend result, response unwind, and client response.
Amberinitialize, where backend MCP client sessions are created.
PurpleAuthorized MCP calls after Mcp-session-id exists.
RedLayer-local HTTP rejection before MCP method handling.

This page follows a normal streamable HTTP MCP request through the code. The shape below is based on the current main.rs, runtime.rs, Gateway::run_gateway, the request layers, and McpService. To watch the same flow with real requests, follow Run the Gateway Locally alongside this page.

Startup Path

Startup begins in crates/contextforge-gateway-rs/src/main.rs:

install rustls crypto provider
  -> Config::parse()
  -> logging::init_tracing_logging(&config)
  -> Runtime::from(&config)
  -> optional CpexRuntimeRegistry
  -> Gateway::builder()
       .with_config(config)
       .with_user_config_store_type(UserConfigStoreType::Redis)
       .with_session_manager(LocalSessionManager::default())
       .with_plugin_runtime(...)
       .build()
  -> runtime.execute(gateway, plugin_registry)

runtime.execute either runs one multi-thread Tokio runtime or starts multiple current-thread runtimes. In both modes it initializes the optional CPEX runtime and then calls gateway.run_gateway().

HTTP Stack Order

Gateway::run_gateway builds the service stack in crates/contextforge-gateway-rs-lib/src/lib.rs. Tower layers execute from the outside in, so a normal MCP request reaches the handler in this order:

TCP/TLS listener
  -> HttpMetricsLayer
  -> TraceLayer
  -> /contextforge-rs nested router
  -> CORS layer
  -> virtual_host_id_layer
  -> claims_layer
  -> session_id_layer
  -> user_config_store_layer
  -> virtual_host_config_layer
  -> /servers/{virtual_host_name}/mcp RMCP service

The inner Axum route is:

/servers/{virtual_host_name}/mcp

The public route is nested under:

/contextforge-rs/servers/{virtual_host_name}/mcp

The route segment is named virtual_host_name, but the layer stores the value as VirtualHostId.

Flow Checkpoints

CheckpointEstablished factNext dependency
ListenerThe request reached the Rust dataplane over TCP or TLS.Metrics, tracing, and nested routing can observe it.
Path extractionThe inner path matched /servers/{virtual_host_id}/mcp.MCP handlers can resolve a VirtualHost.
Claims validationThe bearer token was accepted and ContextForgeClaims exists.Config lookup can use claims.sub.
User config lookupA UserConfig exists for the authenticated subject.The virtual host check can run against that config.
Virtual host checkThe path’s virtual host id exists in the caller’s config.MCP validators can resolve the selected VirtualHost.
RMCP dispatchThe streamable HTTP request is mapped to an MCP method.The handler chooses initialize, routed backend calls, or local behavior.

Middleware Context

The request layers insert the context used later by RMCP handlers:

LayerRequest behaviorFailure behavior
virtual_host_id_layerExtracts /servers/{virtual_host_id}/mcp and inserts VirtualHostId.Returns 400 when the inner path does not match.
claims_layerValidates Authorization: Bearer ... with configured RS/HMAC decoder, issuer, audience, and expiration. Inserts ContextForgeClaims.Returns 401 for missing or invalid bearer auth.
session_id_layerReads Mcp-session-id and inserts SessionId when present.Missing session id is allowed here; authorized MCP handlers reject it later when required.
user_config_store_layerUses claims.sub as User::new(subject), loads UserConfig, and inserts it.Returns 400 for missing config, 500 for other store failures, and 400 if claims are absent.
virtual_host_config_layerChecks that the path’s virtual host id exists in the loaded UserConfig.Returns 404 with body {"detail":"Server not found"} when the virtual host is not in the caller’s config.

For DELETE, session_id_layer also has response-side behavior. It lets RMCP handle the request first. If the RMCP response succeeds and a session id exists, it removes the local user session mapping and removes backend transports for principal + session_id.

Initialize Flow

initialize does not require the downstream Mcp-session-id header. RMCP creates a DownstreamSessionId and places it in the request context.

McpService::initialize runs this sequence:

  1. InitializeCallValidator reads DownstreamSessionId, UserConfig, VirtualHostId, and ContextForgeClaims.
  2. It resolves user_config.virtual_hosts[virtual_host_id].
  3. It reads the local user session mapping for claims.sub + downstream_session_id.
  4. For every backend in the selected virtual host, it concurrently builds a StreamableHttpClientTransport with the configured backend URL and serves a GatewayBackendClient over that transport.
  5. It collects backend capabilities and running RMCP client services. The capabilities are stored with backend transport state for future routing, but they do not shape the downstream initialize response yet.
  6. It writes the local user session mapping.
  7. It stores each backend service in BackendTransports keyed by principal, backend name, and downstream session id.
  8. It returns InitializeResult with the gateway’s current fixed capability set: completions, prompts, resources, and tools enabled.

Backend initialization is concurrent through futures::future::join_all.

Authorized MCP Calls

Routed MCP calls after initialization use AuthorizedCallValidator. It requires:

SessionId
UserConfig
VirtualHostId
ContextForgeClaims

The validator resolves the same virtual host from the authenticated user’s config, then SessionManager locates backend services for:

principal + backend_name + session_id

Current routed method families:

Method familyFlow
list_tools, list_resources, list_prompts, list_resource_templatesBorrow all configured backend services, call every available backend concurrently with fan_out_list, preserve identifiers for one backend or namespace them for multiple backends, sort merged output, and return one list. Explicit tool aliases are preserved exactly.
call_toolResolve an exact tool alias first; otherwise preserve the name for one backend or split {backend_name}-{tool_name} for multiple backends. Resolve one backend, run the optional tool hooks, track progress, call the backend, and return the result.
read_resource, subscribe, unsubscribe, get_promptSelect the only backend and forward the identifier unchanged, or split and strip the prefix for a multi-backend host, then call the resolved backend.
completeApply the same conditional routing to the prompt name or resource URI in ref, then return the selected backend’s completion result.

GatewayBackendClient handles backend progress notifications for call_tool. RMCP assigns a new progress token to each backend request, so the gateway maps that backend token to the corresponding downstream token. Request enqueue and mapping publication are serialized against progress lookup so an immediate backend notification cannot overtake registration. When a notification matches an in-flight backend token, the gateway restores the downstream token, optionally runs the stream-event post hook, and forwards the notification to the downstream client.

call_backend_tool also watches the downstream cancellation token. If the downstream call is cancelled before the backend responds, the gateway sends a cancel request to the backend handle.

Local MCP Methods

Some MCP methods are currently local to the gateway implementation rather than backend-routed:

MethodCurrent behavior
pingReturns success.

This path still passes through the same HTTP middleware, but it does not use backend fanout or identifier routing.

Response Path

Backend responses return to McpService first. call_tool may run response plugin hooks before returning. List calls merge backend output, preserving single-backend identifiers and namespacing multi-backend identifiers as needed.

The HTTP response then unwinds through virtual_host_config_layer, user_config_store_layer, session_id_layer, claims_layer, virtual_host_id_layer, CORS, trace, and metrics. On successful DELETE, session_id_layer performs local session and backend transport cleanup during this unwind.

Concurrency And Runtime Model

🧵 Execution lens: the gateway is async Rust on Tokio with jemalloc as the global allocator. This page explains the two executor shapes, what state is shared under which locks, and where work fans out.

Executor Shapes

--single-runtime selects between two models:

ModeShapeWhen
true (default)One multi-thread Tokio runtime with --number-of-cpus worker threads (default: host CPU count). All connections share one runtime and one set of gateway state.The default for all stateful MCP traffic.
falseOne OS thread per CPU, each running its own current-thread Tokio runtime, each executing the full gateway stack. Listeners bind with SO_REUSEPORT, so the kernel spreads incoming connections across the per-thread listeners.A shared-nothing, per-core experiment shape for throughput work.

In multi-runtime mode, the first thread initializes the optional CPEX plugin runtime before the others start; the current-thread builders are tuned with a global queue interval of 1024 and 4 I/O events per tick.

⚠️ Multi-runtime consequence: each runtime thread builds its own BackendTransports map and user-session store inside run_gateway. Backend session state is therefore per-runtime-thread, and SO_REUSEPORT gives no connection affinity — later requests in a streamable HTTP session arrive on new connections and can land on a thread that does not own the session. Treat single-runtime mode as the only mode that supports stateful MCP sessions today; this is the in-process version of the load-balancing constraint.

Shared State And Locks

StateLockContention profile
BackendTransports mapArc<tokio::sync::Mutex<HashMap<...>>>Locked briefly on initialize insert, per-call borrow, and cleanup. Borrowing clones Arc<RunningService> handles so the lock is not held across backend calls.
Subscription setArc<tokio::sync::Mutex<HashSet<String>>>Local subscribe/unsubscribe only.
User config LRU cacheArc<tokio::sync::Mutex<LruCache>> inside RedisUserConfigStoreOne lock per config lookup on the hot path; misses add a Redis round trip.
User session LRU cacheSame pattern in LocalUserSessionStoreInitialize and delete paths.
JWT decoders, upstream reqwest::Client, process ConfigNo lock — immutable after startup, shared by Arc/clone.None.

The design rule: locks guard maps of handles, not I/O. Backend calls, Redis reads, and plugin hooks all run outside any gateway lock.

Fanout And Cancellation

  • initialize opens one backend transport per configured backend concurrently (futures::future::join_all); a failed backend degrades that backend only.
  • List methods fan out to all connected backends concurrently and merge.
  • Targeted calls resolve exactly one backend service handle.
  • call_tool watches the downstream cancellation token and forwards a cancel to the backend if the client gives up first; backend progress notifications are forwarded downstream while the call is in flight.

Listener Behavior

The TCP listener binds with reuseaddr, reuseport, and keepalive, listens with a backlog of 1024, and serves Axum with graceful shutdown on ctrl_c. The TLS listener accepts by hand through Rustls and serves the same router via Hyper.

Allocator

The binary sets tikv_jemallocator as the global allocator, which holds up better than the system allocator under the many small, short-lived allocations of per-request JSON and header processing.

Authentication And User Config Lookup

🔐 Boundary: authentication proves who is calling. User config lookup decides which virtual hosts and backends that caller can reach.

Authentication and user config lookup

This page follows the identity boundary in the request path. The gateway does not let an MCP method choose arbitrary backend URLs. It validates the bearer token, loads the caller’s UserConfig, and only then lets MCP validators select a virtual host from that config.

Request Order

Authentication and config lookup happen before McpService handles the MCP method:

StepCodeOutput
Path contextvirtual_host_id_layerVirtualHostId extension.
JWT validationclaims_layerContextForgeClaims extension.
Session headersession_id_layerOptional SessionId extension.
Config lookupuser_config_store_layerUserConfig extension.
Virtual host checkvirtual_host_config_layer404 when the path’s virtual host id is not in the loaded config.
MCP validationInitializeCallValidator or AuthorizedCallValidatorSelected VirtualHost, session id, and claims.

The order matters: user_config_store_layer needs ContextForgeClaims, and MCP validators need both UserConfig and VirtualHostId.

Token Validation

claims_layer reads Authorization: Bearer ... and decodes the JWT with the algorithm declared in the JWT header.

Token propertyCurrent behavior
AlgorithmAccepts RS256/RS384/RS512 when an RSA public key is configured, or HS256/HS384/HS512 when a shared secret is configured.
IssuerMust match mcpgateway.
AudienceMust match mcpgateway-api.
Expirationexp is validated.
Unsupported algorithmRejected before claims are inserted.

The decoded value is stored as ContextForgeClaims. The fields currently important to routing are:

ClaimRouting role
subBecomes the user config key and the principal for backend session lookup.
iss, aud, expAuthentication checks only.
jti, token_use, iat, teams, user, scopesCarried in claims for future policy use; not currently used by MCP routing. token_use, iat, teams, and scopes are optional, as is user.full_name, so tokens without those fields still validate.

A concrete decoded payload for the local admin@example.com subject looks like this (timestamps shown as example Unix seconds). Of everything here, MCP routing depends only on sub today. The optional fields are included for illustration:

{
  "iss": "mcpgateway",
  "aud": "mcpgateway-api",
  "sub": "admin@example.com",
  "exp": 1717180800,
  "iat": 1717177200,
  "jti": "example-token",
  "token_use": "api",
  "teams": ["team_awesome"],
  "user": {
    "email": "admin@example.com",
    "full_name": "API Token User",
    "is_admin": true,
    "auth_provider": "api_token"
  },
  "scopes": {
    "server_id": "my_id",
    "permissions": ["tools.read", "servers.use"],
    "ip_restrictions": ["192.169.1.0/24"],
    "time_restrictions": null
  }
}

User Config Key

user_config_store_layer turns the subject into a typed key:

ContextForgeClaims.sub
  -> User::new(subject)
  -> UserConfigStore::get_config(&user)

The Redis adapter serializes that User key with MessagePack. The key includes both the key type and the subject, so user config data is not just stored under the raw subject string.

Cache And Redis Lookup

RedisUserConfigStore checks an in-process LRU cache before going to Redis:

StageBehavior
LRU hitClone the decoded UserConfig from the cache.
LRU missMessagePack-encode User::new(subject), GET that Redis key, decode the MessagePack UserConfig, then cache it.
Cache size50,000 entries.
Cache expiry--user-config-cache-expiry-seconds, default 60 seconds. 0 disables the cache and reads Redis on every request.
Redis retry settingConnection manager is configured with 1,000 retries.

The cache is an implementation detail of RedisUserConfigStore. Routing code depends on UserConfigStore, not Redis commands.

Failure Behavior

Failures before RMCP method handling are HTTP responses:

FailureResponse
Missing Authorization header401 Unauthorized.
Header does not start with Bearer 401 Unauthorized.
JWT header or body cannot be decoded401 Unauthorized.
JWT uses an unsupported algorithm401 Unauthorized.
Required decoder key or secret is not configured401 Unauthorized.
No user config exists for claims.sub400 Bad Request.
Redis/config store error other than missing data500 Internal Server Error.
user_config_store_layer runs without claims400 Bad Request.
Virtual host id not present in the caller’s config404 Not Found with body {"detail":"Server not found"}.

A valid user config can still fail a request: virtual_host_config_layer returns 404 before MCP method handling when the config does not contain the path’s VirtualHostId.

What This Boundary Does Not Do

Authentication and user config lookup do not route to a backend by themselves. They only establish:

caller identity
  + caller UserConfig
  + requested VirtualHostId

McpService still has to validate the MCP call, resolve the virtual host, and choose either the initialize path, routed backend path, or local method path.

Security Model And Trust Boundaries

🔒 Trust rule: the gateway trusts its process config and the control-plane-authored data in Redis. It does not trust downstream callers beyond a validated JWT, and it reaches backends only through configured URLs.

Trust Boundaries

BoundaryTrust levelEnforced by
Downstream clientUntrusted. Every request must present a valid bearer JWT; the session id alone grants nothing without matching principal state.claims_layer, validators, and the principal-scoped backend session keys.
JWT verification materialTrust anchor. The RSA public key or HMAC secret in process config decides which tokens are accepted.Process config; loaded at startup.
RedisControl-plane trust boundary. Whoever can write Redis controls routing (UserConfig) and, when runtime plugins are enabled, which registered hooks execute (ContextForgeGatewayRuntimePluginConfig).Redis TLS/mTLS connection modes; the dataplane never writes user config in production builds.
Backend MCP serversTrusted per configured URL. The gateway forwards caller traffic to them and merges their responses.UserConfig backend URLs plus the upstream connection mode.
PluginsFully trusted code. Hooks run in-process and can read and mutate tool payloads.Compiled-in factories only; Redis config activates registered factories, it cannot load new code.

Identity And Authorization

Authentication is bearer-JWT only:

  • Accepted algorithms are RS256/RS384/RS512 (public key configured) or HS256/HS384/HS512 (shared secret configured); anything else is rejected.
  • iss must be mcpgateway, aud must be mcpgateway-api, and exp is validated. There is no revocation list: a leaked token is valid until it expires.
  • Authorization is config existence. The sub claim selects the caller’s UserConfig; the path selects one virtual host inside it. A caller can never reach a backend that is not in their own config, and unknown virtual hosts return 404 before MCP handling.
  • jti, token_use, iat, teams, user, and scopes are carried but not yet enforced; token_use, iat, teams, and scopes are optional, as is user.full_name. Fine-grained permissions are future policy work.

What Compromise Means

If this is compromisedImpact
JWT signing key or HMAC secretAttacker mints tokens for any subject and reaches that subject’s backends. Rotate the key and restart; there is no revocation.
Redis write accessAttacker rewrites routing (arbitrary backend URLs receive caller traffic) and, if runtime plugins are enabled, chooses which registered hooks run on payloads. Protect Redis with TLS/mTLS and control-plane-only write access.
A backend MCP serverAttacker sees the requests routed to that backend and controls its responses; the namespace prefix limits blast radius to that backend’s objects.
The gateway processFull compromise: it holds the decoding keys in memory and live backend sessions.

Transport Security

LegCurrent posture
DownstreamTLS optional (--tls-address, no client auth — identity is the bearer token). Plain HTTP is acceptable only behind a trusted front door on a private network.
UpstreamHTTPS-only by default; plain HTTP must be opted into with --upstream-connection-mode. mTLS client identity is supported per process.
RedisPlain, TLS, or mTLS via --redis-mode. Use TLS or mTLS anywhere Redis crosses a trust zone, because Redis is the config trust boundary.

CORS is currently wide open (any origin, method, and header). The API is bearer-token based and cookie-free, so cross-site request forgery does not apply, but expect this to tighten as policy work lands.

Local Bootstrap Helpers

The contextforge-gateway-rs-lib/with_tools feature compiles in /contextforge-rs/admin/tokens/{user}, /contextforge-rs/admin/userconfigs/{user}, and /contextforge-rs/health. These routes are registered outside the authentication middleware, so token minting and config writes are unauthenticated by design — they exist only for local bootstrap. Production builds must not enable this feature: in a real deployment the control plane mints tokens and writes config.

Secrets Handling

  • The HMAC secret is held as a SecretString; key and certificate material is read from disk paths at startup.
  • Log hygiene is a standing rule: never log tokens, authorization headers, secrets, Redis key/value bytes, full UserConfig documents, or backend credentials.

Runtime Configuration

🗂️ Config boundary: process config tells the gateway how to run. Runtime user config tells each request where it may route. Plugin config controls the optional CPEX hook runtime.

Runtime configuration

The gateway consumes configuration from three places. Keeping them separate is important because they change at different times and are used by different parts of the dataplane.

Config Surfaces

SurfaceSourceLoadedMain user
Process ConfigCLI flags and CONTEXTFORGE_GATEWAY_RS_* env vars parsed by clap.Startup.Listener setup, JWT decoder keys, Redis connection, upstream HTTP client, telemetry, runtime shape.
UserConfigUserConfigStore, currently Redis through RedisUserConfigStore.Per request after JWT validation.Virtual host and backend selection.
RuntimePluginConfigDocumentRedis key ContextForgeGatewayRuntimePluginConfig when runtime plugins are enabled.Startup and watcher reload.CPEX tool pre/post hooks.

The control plane owns durable authoring. This repo owns reading those values and applying them on the request path.

UserConfig Shape

The API crate defines the shared runtime routing model:

UserConfig
  virtual_hosts: HashMap<String, VirtualHost>

VirtualHost
  backends: HashMap<String, BackendMCPGateway>

BackendMCPGateway
  name: String
  url: Url
  transport: Transport
  passthrough_headers: Vec<String>
  allowed_tool_names: Vec<String>
  allowed_resource_names: Vec<String>
  allowed_prompt_names: Vec<String>

Transport currently declares:

STREAMABLEHTTP
SSE
STDIO

The Rust types above are easier to picture as JSON. For a complete, working UserConfig document, see the seed request body in Run the Gateway Locally.

What The MCP Dataplane Uses Today

The struct is already wider than the current MCP routing code. That is useful, but the distinction should stay explicit:

Config fieldCurrent MCP dataplane behavior
UserConfig.virtual_hostsRequired. VirtualHostId from the path selects one entry.
VirtualHost.backends map keyRequired. Selects backend session state and becomes the public prefix when a multi-backend identifier has no explicit alias.
BackendMCPGateway.urlRequired. Used to build the upstream StreamableHttpClientTransport.
BackendMCPGateway.namePresent in the model. Current routing uses the backend map key, not this field, as the namespace.
transportPresent in the model. Current upstream code always builds a streamable HTTP client transport.
passthrough_headersPresent in the model. Current MCP routing does not apply header pass-through policy from this field.
allowed_tool_namesPresent in the model. Current list/call routing does not enforce it.
tool_name_aliasesOptional exact {downstream_alias: upstream_original} mapping used by tool list/call routing before the single-versus-multi-backend fallback.
allowed_resource_namesPresent in the model. Current resource routing does not enforce it.
allowed_prompt_namesPresent in the model. Current prompt routing does not enforce it.

The current route selection is:

JWT subject
  -> User::new(subject)
  -> UserConfig
  -> path VirtualHostId
  -> VirtualHost
  -> backend map key
  -> BackendMCPGateway.url

Expected config growth beyond the current fields:

  • route selection across multiple MCP endpoints
  • principal/virtual-host filters for tools, resources, and prompts
  • backend auth/TLS material references
  • request/response header pass/add/remove rules
  • plugin/CPEX hook settings
  • pagination/SSE behavior where protocol handling needs config
  • future A2A and LLM routing/provider settings

Redis Storage

RedisUserConfigStore stores user routing config as MessagePack:

ItemEncoding
Redis keyMessagePack-encoded User::new(jwt_subject).
Redis valueMessagePack-encoded UserConfig.
Cache keyRaw subject string through User::key().
Cache valueDecoded UserConfig.

The in-process cache is an implementation detail:

SettingValue
Entries50,000
Expiry--user-config-cache-expiry-seconds, default 60 seconds; 0 disables caching
Redis connection retries1,000

Routing code should stay behind the UserConfigStore trait. That keeps Redis, MessagePack, and cache behavior out of MCP method handling.

Plugin Runtime Config

When runtime_plugins_enabled is true, startup builds a CpexRuntimeRegistry::with_redis_config(...). The registry reads a separate runtime plugin document:

Redis key: ContextForgeGatewayRuntimePluginConfig

RuntimePluginConfigDocument
  version: 1
  cpex: CpexConfig

The plugin config loader accepts JSON bytes or MessagePack bytes. It rejects documents with the wrong version, missing cpex config, unsupported CPEX features, or an unavailable config store.

Current supported plugin scope is deliberately narrow:

CPEX featureCurrent support
TOOL_PRE_INVOKESupported for call_tool before backend invocation.
TOOL_POST_INVOKESupported for call_tool responses and backend progress events.
CPEX routingRejected. Gateway routing is owned by UserConfig and MCP routing code.
Plugin conditionsRejected.
Plugin dirs, global policies, global defaultsRejected.
Other hook typesRejected.

The registry has a watcher interval of 10 minutes. On valid reloads it swaps in the new runtime. On invalid reloads it marks the runtime failed so new plugin calls return an internal MCP error until a valid config is applied.

Process Config

Process Config is not per-user routing state. It is parsed once and controls the gateway shell:

AreaExamples
Listeneraddress, tls_address, downstream TLS certificate and private key.
AuthenticationRSA public key path or HMAC secret for JWT verification.
RedisHost, port, plain/TLS/mTLS mode, trust bundle, client cert, client key.
Upstream HTTP clientPlaintext/TLS/mTLS mode, upstream trust bundle, client cert, client key.
TelemetryOpenTelemetry traces, metrics endpoint/protocol, OTLP headers, service name.
Runtime shapeCPU count and single-runtime versus multi-runtime mode.
PluginsWhether runtime plugins are enabled.
LoggingLog name and rotation.

The process config decides whether the gateway can start and what dependencies it can reach. It does not decide which backend a specific MCP caller may use; that remains in UserConfig.

Selection Invariant

Backend selection should only happen after these facts exist:

authenticated subject
  + loaded UserConfig
  + path VirtualHostId
  + MCP session context

That invariant is why runtime config access is in middleware and MCP validators, not hidden inside individual backend calls.

Control-Plane Integration

🤝 Provisional: no formal contract with the control plane has been stipulated yet. This page is a snapshot of the current de facto integration surface with IBM/mcp-context-forge as implemented today. Any row may change while the project is early; when a proper contract is agreed, this page should track it.

Current Integration Surface

These are the values both sides currently rely on:

AgreementValue today
Client-facing route/servers/{virtual_host_id}/mcp behaves like the legacy ContextForge MCP endpoint. The front door rewrites it to /contextforge-rs/servers/{virtual_host_id}/mcp on the dataplane.
Unknown virtual host404 with body {"detail":"Server not found"}, matching the control-plane response shape.
Token issuer and audienceiss = mcpgateway, aud = mcpgateway-api — the values the control plane mints.
Claims shapesub, jti, iss, aud, exp, and user are required. token_use, iat, teams, and scopes are optional, as is user.full_name. The dataplane routes on sub only.
User config keyMessagePack-encoded User::new(jwt_subject) (key type plus subject).
User config valueMessagePack-encoded UserConfig; the JSON schema is generated into schemas/user_config.json.
Plugin config keyContextForgeGatewayRuntimePluginConfig, JSON or MessagePack, version: 1 with a cpex section.

Changing any of these is a cross-repo change: the dataplane, the control-plane publisher, and the integration harness all need updating together.

Config Publishing

The control plane owns durable config and publishes runtime snapshots to Redis. With DATAPLANE_PUBLISHER=true, it rewrites the dataplane’s UserConfig keys on an interval — every 60 seconds by default, configurable in newer control-plane images.

Config staleness on the dataplane is bounded by two knobs:

worst-case staleness = publisher interval + user config cache expiry

The dataplane’s in-process cache defaults to 60 seconds (--user-config-cache-expiry-seconds; 0 disables it and reads Redis on every request). Functional test setups shorten the publisher interval and disable the cache; production keeps both at 60.

Schema Generation

contextforge-gateway-rs-apis is the single source of truth for the shared config shapes. It generates the JSON Schemas the control plane can validate against:

cargo run -p contextforge-gateway-rs-apis

This writes schemas/user.json and schemas/user_config.json. Regenerate and commit them whenever UserConfig, VirtualHost, BackendMCPGateway, or the User key type changes.

Front-Door Split

Only MCP dataplane traffic comes to this process. The repository’s reference docker/nginx.conf proxies location ^~ /contextforge-rs to the gateway; all UI, management API, and other ContextForge traffic stays on the existing control-plane paths.

Verifying The Integration

The cf-integration harness tests exactly this surface: it runs the stock upstream control-plane stack with the nginx split and the dataplane publisher enabled, then drives probe, live-test, and load lanes through the public route. When the integration surface changes, its lanes are what prove both sides still agree. See Testing for the commands.

Backend Connections And Transports

🚚 Transport boundary: downstream listener traffic, upstream backend traffic, and config-store traffic are separate concerns. Keep them separate even when they all use TCP underneath.

Backend connections and transports

The gateway has three transport classes on the hot path. They are built in different modules, configured from different fields, and serve different architecture roles.

Transport Classes

Transport classCurrent implementationMain ownerPurpose
Downstream listenerAxum/Hyper over TCP and optional Rustls TLS.transports/ and Gateway::run_gateway.Accept MCP streamable HTTP traffic from clients or the front door.
Upstream backendShared reqwest::Client plus RMCP StreamableHttpClientTransport.common.rs, gateway/mcp_service/initialization.rs, and gateway/backend_transports.rs.Open MCP client sessions to configured backend MCP servers.
Config storeRedis plain, TLS, or mTLS connection manager.common.rs and user_config_store/.Load UserConfig and plugin runtime config from control-plane authored storage.

The current MCP dataplane only uses streamable HTTP for backend MCP traffic. BackendMCPGateway.transport already has STREAMABLEHTTP, SSE, and STDIO, but upstream routing does not branch on that field yet.

Downstream Listeners

Gateway::run_gateway builds one Axum router and can expose it through TCP, TLS, or both.

ListenerConfig fieldsBehavior
TCPaddressBinds a Tokio TcpSocket, sets reuse options and keepalive, listens with backlog 1024, and serves Axum with graceful shutdown on ctrl_c.
TLStls_address, server_certificate, server_private_keyBuilds a Rustls server config, accepts TLS by hand, then serves the same Axum router through Hyper.

TLS listener setup has two important constraints:

ConstraintWhy
tls_address requires both certificate and private key.The listener cannot build a Rustls server config without both.
tls_address cannot equal address.TCP and TLS cannot bind the same socket in this process.

The downstream TLS listener currently uses with_no_client_auth(). Client identity is established by the gateway’s bearer JWT layer, not by downstream mTLS.

Upstream Backend Client

The upstream HTTP client is built once at gateway startup:

Config
  -> reqwest::Client::try_from(&config)
  -> clone per backend initialize task
  -> StreamableHttpClientTransport::with_client(...)

The process-level upstream mode controls whether backend URLs may use plain HTTP, HTTPS, or HTTPS with client identity:

Modereqwest behavior
unsethttps_only(true). Same as TlsOnly.
TlsOnlyHTTPS backends only.
PlainTextOrTlsHTTP or HTTPS backends.
PlainTextOrMTlsHTTP or HTTPS backends, with a client identity configured for TLS handshakes.
MtlsOnlyHTTPS backends only, with a client identity configured for TLS handshakes.

If upstream_trust_bundle is configured, the PEM bundle is merged into the client’s TLS trust roots. For mTLS modes, the upstream certificate and private key are read from disk and combined into a reqwest::Identity.

Backend MCP Transport

During initialize, the selected virtual host fans out to every configured backend:

VirtualHost.backends
  -> for each backend URL
  -> build StreamableHttpClientTransportConfig
  -> serve GatewayBackendClient over StreamableHttpClientTransport
  -> store running service in BackendTransports

For HTTPS backend URLs, the gateway also sets a custom Host header from the backend URL host and optional port. HTTP backend URLs do not get this custom header in the current code.

Backend connection failures are not fatal to the whole initialize call. The gateway stores the backend entry with no running service, so list calls can continue with available backends and routed calls to that backend can fail locally.

Config-Store Transport

Redis is the current config-store transport. It is used for user config and, when runtime plugins are enabled, plugin runtime config.

Redis modeConnection behavior
PlainTextConnects to host:port over TCP.
TlsConnects with rediss://host:port and a required trust bundle.
MtlsConnects with rediss://host:port, required trust bundle, required client certificate, and required client key.

The Redis user config adapter stores:

MessagePack(User::new(claims.sub)) -> MessagePack(UserConfig)

The Redis connection manager is configured with 1,000 retries. The adapter keeps an in-process LRU cache in front of Redis, but routing code should only depend on the UserConfigStore trait.

What Should Move To Runtime Config

Transport security is mostly process config today. That keeps startup simple, but it is not the final shape for every backend-specific decision.

SettingTodayBetter long-term owner
Downstream TLS certificateProcess config.Process config. It belongs to the gateway listener.
Upstream trust bundle and mTLS identityProcess config.Runtime config per backend or referenced secret material.
Backend auth headersNot applied from UserConfig yet.Runtime config per backend.
Backend transport typeModel field exists, not routed yet.Runtime config per backend.
Header pass-through policyModel field exists, not enforced yet.Runtime config per backend or route policy.

The boundary to preserve is simple: listener code should not know Redis schema, MCP routing code should not know Redis command details, and backend transport creation should stay behind a small, explicit upstream boundary.

Session Ownership

🧷 Session invariant: backend MCP services are local process state keyed by authenticated principal, backend namespace, and downstream MCP session id.

Session ownership

The current gateway keeps backend MCP client services in memory. That choice is simple and fast, but it defines how initialized MCP sessions can be routed in a deployment.

Owned State

Several pieces of state participate in one MCP session. They do not all have the same owner.

StateKeyOwner todayLifetime
RMCP downstream sessionRMCP DownstreamSessionId and later Mcp-session-id.RMCP LocalSessionManager.Local process.
User session mappingUserSession { principal, downstream_session_id }.LocalUserSessionStore.Local LRU cache, 50,000 entries, 1 hour.
Backend running serviceprincipal + backend_name + session_id.BackendTransports.Local process.
Backend upstream MCP sessionManaged inside RMCP running client service.Backend service handle.Local process and backend server.

RedisUserSessionStore exists, but Gateway::run_gateway wires LocalUserSessionStore today. Even if the user session mapping moved to Redis, the live RMCP backend services in BackendTransports would still be local unless that architecture changes too.

Initialize Creates Backend Services

initialize is the ownership creation point:

InitializeCallValidator
  -> selected VirtualHost
  -> claims.sub
  -> RMCP DownstreamSessionId
  -> one upstream client service per backend
  -> BackendTransports entries

The backend transport key is:

BackendTransportKey
  principal: claims.sub
  backend_name: backend map key
  session_id: downstream session id

The backend map key matters because one downstream MCP session can fan out to many backend MCP sessions. The principal matters because two callers could present the same downstream session id value.

Borrowing Backend Services

SessionManager is a request-scoped view over the selected virtual host, session id, principal, and shared backend transport map.

OperationWhat it does
get_backend_names()Reads backend namespaces from the selected VirtualHost.
borrow_transports()Locks the map, finds entries for the current principal/session/backend names, and clones the shared Arc<RunningService>.
cleanup_backends(reason)Removes backend entries for the current principal/session/backend names.

borrow_transports() does not move services out of the map in the current code. It clones shared service handles, so there is no return step after a request completes.

List Calls And Routed Calls

Backend service lookup splits into two patterns:

MCP call shapeSession behavior
list_tools, list_resources, list_prompts, list_resource_templatesBorrow every available backend service for the selected virtual host, call them concurrently, then merge successful responses with aliases or conditional namespacing.
call_tool, read_resource, subscribe, unsubscribe, get_prompt, completeResolve an explicit tool alias, select the only backend without rewriting, or split a multi-backend prefix; then call the single resolved backend service.

An initialized backend entry can still contain no running service if backend initialize failed. List calls skip unavailable backends. Routed calls to that backend return a routing error.

If routed lookup ever detects duplicate backend matches, the session is treated as invalid and cleanup_backends removes the local backend entries for that principal and session.

Delete Cleanup

session_id_layer owns response-side cleanup for downstream DELETE:

DELETE with Mcp-session-id
  -> let RMCP handle the request
  -> if RMCP response is successful
  -> remove LocalUserSessionStore entry
  -> remove BackendTransports entries for principal + session id

Cleanup is intentionally tied to a successful downstream delete response. If RMCP rejects the delete, the layer returns that response and leaves local session state untouched.

Load-Balancing Consequence

Random load balancing is not safe for stateful MCP sessions today. After initialize, later requests with the same Mcp-session-id must reach the process that owns the backend running services.

Known deployment options are:

OptionTradeoff
Sticky routing by Mcp-session-idSimple, preserves local state, but couples sessions to one process.
External session ownershipMore flexible, but backend running service state must move out of process or become reconstructible.
Reinitialize after failoverOperationally simple, but clients must tolerate session loss.

Until backend service ownership changes, design request handling as if a stateful MCP session belongs to one gateway process. The same constraint appears inside one host in multi-runtime mode, where each runtime thread owns its own backend transports; see Concurrency And Runtime Model.

Architectural Choices

🧱 Design rule: these choices are not permanent, but changing one should be a deliberate architecture decision with code, tests, and migration notes.

Architectural choices

This page records the choices that should stay visible as the gateway evolves. They describe the shape of the current Rust dataplane, not just preferences.

Choice Matrix

ChoiceCurrent decisionWhy it matters
Dataplane, not control planeThis repo consumes runtime config and handles traffic. It does not own UI, IAM lifecycle, management APIs, or durable observability storage.Keeps the hot path small and prevents product workflows from leaking into request routing.
Config access is abstractedMCP routing depends on UserConfig, VirtualHost, and UserConfigStore, not Redis commands.Keeps future xDS/gRPC or another config stream possible.
Backend names are publicThe backend map key is part of tool/resource/prompt names.Backend renames are client-visible behavior changes.
Sessions are local todayBackend RMCP services live in BackendTransports inside one process.Load-balanced deployments need sticky routing or a new session ownership design.
Merged MCP semantics define the contractThe client sees one gateway MCP server with namespaced backend objects.Backend topology should not become a hard client dependency beyond the namespace contract.
Plugin boundaries stay explicitTool pre/post hooks are integrated at known points around backend invocation.Payload mutation needs clear failure, timeout, cancellation, and telemetry behavior.

Dataplane, Not Control Plane

The gateway should enforce decisions already made elsewhere:

control plane authors config and policy
  -> Redis/config transport exposes runtime data
  -> Rust dataplane enforces it on MCP traffic

New features should start with one question:

Is this hot-path enforcement, or is this a management workflow?

If it is a workflow, it probably belongs outside this repo. The Rust gateway should enforce the result of management decisions, not become the place where those decisions are authored.

Config Access Is Abstracted

Redis is the current storage and transport adapter. It is not the routing model. The routing model is:

UserConfig
  -> VirtualHost
  -> BackendMCPGateway

That is why request code should stay behind UserConfigStore. Redis key encoding, MessagePack, cache expiry, and retry settings belong in the adapter, not in MCP method handling.

Backend Names Are Conditionally Public

Backend map keys are visible in the MCP namespace when multiple backends need disambiguation:

backend map key: gateway-one
backend tool: increment
gateway tool: gateway-one-increment

For a single backend, upstream identifiers pass through unchanged. Explicit tool_name_aliases published by the control plane take precedence in either case. Otherwise the map key, not BackendMCPGateway.name, is the namespace used by multi-backend routing.

Session State Is Local Today

Backend services are not just data. They are live RMCP running services stored under:

principal + backend_name + downstream_session_id

That makes the current state model fast and direct, but not horizontally portable. Do not design request handling as if every node can serve every stateful MCP session until backend service ownership has an external owner or can be rebuilt safely.

Merged MCP Semantics Define The Contract

The downstream client should reason about one MCP server:

client
  -> ContextForge Gateway
  -> merged tools/resources/prompts

Backend identity appears only when namespacing is needed, and clients should not need to know transport details, Redis storage, fanout mechanics, or plugin runtime internals.

This choice leaves room for filtering, policy, and route changes without turning every backend topology change into a client integration change.

Plugin Boundaries Stay Explicit

Plugins can inspect or mutate payloads. That is more powerful than a header filter, so the hook points need to stay obvious.

Current CPEX support is intentionally narrow:

HookCurrent boundary
TOOL_PRE_INVOKERuns before call_tool forwards to the selected backend.
TOOL_POST_INVOKERuns after call_tool receives a backend result and on backend progress events.

Avoid adding ad hoc plugin calls in the middle of routing code. If a new hook is needed, define its ownership, failure behavior, timeout behavior, cancellation behavior, streaming behavior, and telemetry attribution.

Transport Security Is Split

Downstream TLS is listener-level process config. Upstream backend security is also process config today, but some of it is naturally backend-specific.

Keep this split visible:

ConcernStable owner
Gateway listener certificateProcess config.
JWT verification keysProcess config.
Backend URL, auth headers, pass-through policy, allowed objectsRuntime user config.
Backend-specific trust and client identityLikely runtime config or referenced secret material over time.

The gateway should not bury transport security decisions inside MCP method handlers. They should remain either startup assembly or explicit backend transport construction.

MCP-First, Not MCP-Only

The current code implements MCP behavior, but the shell is broader:

auth
config lookup
transport setup
plugin runtime
telemetry
session strategy

Keep protocol-neutral concerns reusable. Future A2A or model-provider routing should be able to reuse the gateway shell without copying the MCP routing stack.

When A Choice Changes

Changing one of these choices should update more than one file.

ChangeExpected follow-through
Backend namespace changesUpdate merge logic, split logic, tests, docs, and migration notes.
Session state moves externalUpdate SessionManager, cleanup behavior, load-balancing docs, and failure-mode tests.
Config transport changesKeep UserConfigStore as the boundary and update adapter tests.
Plugin hook surface expandsDocument ordering, failure behavior, cancellation, streaming, and telemetry.
New protocol joins the gatewayKeep shared shell code protocol-neutral and isolate protocol-specific routing.

These pages are part of that safety net: they make architecture drift visible before it becomes accidental API behavior.

MCP Behavior

This section describes what the gateway exposes as an MCP server and how it maps downstream MCP calls onto configured backend MCP servers.

📋 Use this section for protocol behavior. It covers the public MCP surface, backend fanout, namespacing, targeted routing, pagination, and streaming gaps.

PageWhat it covers
📋 MCP Method ReferenceInitialize, list, call, read, and prompt behavior from the client-facing gateway point of view.
🛣️ MCP Routing SemanticsHow backend prefixes become the public tool, resource, and prompt namespace.

MCP Method Reference

📋 Reference lens: this page lists what each MCP method does at the gateway today, from the client’s point of view. For how identifiers are preserved, aliased, or namespaced, see MCP Routing Semantics.

Gateway methods fall into three groups: initialize creates backend sessions, routed methods use them, and ping remains local to the gateway process.

Initialize

AspectBehavior
Required contextRMCP DownstreamSessionId, UserConfig, VirtualHostId, and ContextForgeClaims. The Mcp-session-id header is not required yet.
FanoutOne StreamableHttpClientTransport per configured backend in the selected virtual host, opened concurrently with futures::future::join_all.
Backend failureNot fatal. A backend that fails to initialize is stored with no running service; list calls skip it and routed calls to it fail.
Stored stateThe local user session mapping, plus one BackendTransports entry per backend keyed by principal, backend name, and downstream session id.
ResultInitializeResult with the gateway’s current fixed capability set: completions, prompts, resources, and tools enabled. Backend capabilities are stored with transport state but are not merged into the response yet.

Routed List Methods

list_tools, list_resources, list_prompts, and list_resource_templates share one fanout path:

AspectBehavior
FanoutConcurrent call to every connected backend in the session.
IdentifiersA single backend preserves upstream identifiers; multiple backends use the backend map-key prefix. Explicit control-plane tool aliases are returned exactly as configured. Resource templates apply the same single-versus-multi rule to both names and URI templates.
OrderingMerged output is sorted by name.
FailuresFailed or unavailable backends are logged and omitted from the merged result.
PaginationOne backend call per request and no downstream cursor; see Known Gaps.

Routed Targeted Methods

Targeted calls resolve exactly one backend using an explicit tool alias, a single-backend pass-through, or a multi-backend prefix:

MethodBehavior
call_toolResolves an exact control-plane alias first, then falls back to the single-versus-multi rule. It runs the optional plugin hooks, tracks progress, forwards the backend-local tool name, and propagates downstream cancellation.
read_resourcePreserves a single-backend URI or strips a multi-backend prefix, then returns the selected backend’s result.
subscribe, unsubscribeApply the same resource-URI routing, track the downstream subscription, and forward or stop forwarding matching resource-update notifications.
get_promptPreserves a single-backend prompt name or strips a multi-backend prefix, then returns the selected backend’s result.
completeApplies the same routing to the prompt name or resource URI in ref and returns the selected backend’s completion result.

Routed failures are JSON-RPC errors: an identifier that cannot select a backend or an unavailable backend returns an internal error, and duplicate backend matches invalidate the session; see Failure Modes.

Local Methods

This method passes through the same HTTP middleware but does not touch backends:

MethodCurrent behavior
pingReturns success.

Session Delete

A downstream DELETE with Mcp-session-id is handled by RMCP first. On a successful response, session_id_layer removes the local user session mapping and the BackendTransports entries for that principal and session id. See Session Ownership for the cleanup rules.

MCP Routing Semantics

The gateway presents multiple backend MCP servers as one downstream MCP server. It fans out list operations, preserves identifiers when only one backend is configured, and adds a backend namespace when multiple backends need to be disambiguated. Explicit tool aliases published by the control plane take precedence over both forms.

Backend Prefixes

Backend map keys are part of the public namespace only for multi-backend virtual hosts without an explicit tool alias:

backend tool "increment" from backend "gateway-one"
  -> "gateway-one-increment"

backend resource "counter" from backend "gateway-one"
  -> "gateway-one-counter"

backend prompt "summarize" from backend "research"
  -> "research-summarize"

For a single-backend virtual host, those identifiers remain increment, counter, and summarize. Resource URIs and resource-template URI templates follow the same rule. This keeps a transparent gateway from changing the MCP contract when no collision is possible.

For tools, BackendMCPGateway.tool_name_aliases maps an exact downstream alias to the upstream original name. An alias is advertised and routed exactly as published, including case, dots, and underscores. When no alias exists, single-backend hosts preserve the original tool name and multi-backend hosts fall back to {backend-map-key}-{tool-name}.

When a prefix is required, it is a routing contract rather than a display detail. Changing a backend map key changes downstream identifiers for that multi-backend virtual host.

List Operations

List operations fan out:

list_tools               -> all connected backends -> merged sorted tools
list_resources           -> all connected backends -> merged sorted resources
list_prompts             -> all connected backends -> merged sorted prompts
list_resource_templates  -> all connected backends -> merged sorted templates

For a single backend, each successful result is returned with its identifier unchanged, except for explicit tool aliases. For multiple backends, resources, prompts, and tools without aliases are rewritten with their backend map-key prefix. Resource-template names and URI templates are both prefixed. Failed or unavailable backends are logged and omitted from the current merged list result.

Routed Operations

Calls that target one object use the inverse rule. A single-backend host selects its only backend and forwards the identifier unchanged. A multi-backend host splits the prefixed identifier:

gateway-one-increment
  -> backend_name = gateway-one
  -> upstream tool name = increment

read_resource, subscribe, unsubscribe, get_prompt, and complete share this conditional routing helper. For complete, the routed value is the prompt name or resource URI inside its ref. Resource-update notifications apply the same rule on the response path, so their URI matches the URI originally exposed to the downstream client.

call_tool first resolves an exact control-plane alias, then applies the same single-versus-multi-backend fallback. Backend names can themselves contain - (as in gateway-one), so the splitter does not cut on the first -. Instead it walks the configured backend names, takes the first one the prefixed name starts with, and then requires a - immediately after that name. That is why gateway-one-increment resolves to backend gateway-one and tool increment, while a malformed name such as gateway-oneincrement is rejected.

After selecting a route, the gateway resolves exactly one connected backend service for the principal and downstream session. Missing backends fail the call. Duplicate matches are treated as invalid session state and trigger backend cleanup.

Known Gaps

Pagination is not complete. list_tools, list_resources, list_prompts, and list_resource_templates currently perform one backend call and return a merged response with no downstream cursor. Full parity needs to gather all backend pages or define a merged cursor strategy.

Streaming/SSE behavior is also still a tracked design area. The target is to stream downstream as backend chunks arrive while preserving plugin behavior, backpressure, cancellation, and telemetry attribution.

Operations

This section covers runtime behavior after the gateway is serving traffic: plugins, diagnostics, failure modes, tests, load tests, and deployment constraints.

🧪 Use this section when operating or verifying the gateway. It focuses on plugin hooks, observability, failure boundaries, load testing, and deployment assumptions.

PageWhat it covers
🧩 Plugins And PolicyWhere request and response plugins fit, and why body access changes the runtime model.
📈 Telemetry And DiagnosticsSignals needed to debug authentication, config lookup, routing, upstream calls, and merged results.
🚧 Failure ModesExpected failures by boundary, including auth, Redis, virtual hosts, backend sessions, and upstream transport.
🧪 TestingWorkspace checks, in-repo integration tests, and the cf-integration full-stack test lanes.
PerformanceDataplane-only load testing, full-stack Locust runs, headless versus web UI, and benchmark settings.
🏗️ Deployment NotesFront-door routing, GitHub Pages publication, session affinity, and cluster constraints.

Plugins And Policy

Plugins are a policy boundary, not just middleware. They can inspect and mutate payloads, so the gateway keeps the supported hook surface narrow and explicit.

Runtime Enablement

Runtime plugins are disabled by default. When enabled, the binary creates a CPEX runtime registry and the runtime initializes it before serving traffic.

Plugin configuration is loaded from Redis at:

ContextForgeGatewayRuntimePluginConfig

The runtime registry builds an initialized immutable plugin manager from that configuration. Reloading swaps the manager instead of mutating a live one.

Supported Hooks

The supported surface is deliberately narrow:

cmf.tool_pre_invoke
cmf.tool_post_invoke

The gateway rejects route-based plugin selection, plugin directories, global policies/defaults, non-tool hooks, and plugin conditions. Those features need clear behavior for streaming, failures, timeouts, backpressure, context propagation, and observability before they belong on the hot path.

Tool Call Behavior

For call_tool, the pre hook runs after backend routing has selected the backend and stripped the public prefix. The hook sees the backend name, routed tool name, and arguments. It can:

  • leave arguments unchanged
  • replace arguments
  • deny the call

After the upstream backend returns, the post hook can:

  • leave the result unchanged
  • rewrite the result payload
  • deny the response

Hook state is carried across the upstream call so pre and post hooks can share CPEX context for the same logical tool call.

Boundary Rules

Plugin execution must not poison shared gateway state. A plugin denial becomes an MCP error. Soft plugin errors are logged. Unsupported plugin configuration fails validation before the runtime is accepted.

Future hook expansion should define behavior for streaming/SSE, cancellation, timeouts, backpressure, and telemetry before adding new hook points.

Telemetry And Diagnostics

📈 Observability lens: the gateway emits three signals — logs, request traces, and HTTP metrics. This page explains how each is produced, how to verify them locally, and where to look when a request misbehaves.

Signal Overview

SignalProduced byDestination
Logstracing events from layers, validators, and MCP handlers.Console and a rotating file in the working directory.
Request tracestower_http::TraceLayer spans around every HTTP request.OTLP trace export when --enable-open-telemetry is set.
HTTP metricsaxum-otel-metrics (HttpMetricsLayer) request instruments.OTLP metrics export when --enable-otel-metrics is set.

All export settings are process config; see the telemetry section of the Configuration Reference.

Logging

Console and file logging are always on. The file appender writes contextforge-gateway-rs.log (configurable with --log-name) in the current working directory and rotates hourly by default (--log-rotation).

Three environment filters control verbosity independently:

Env varDefaultControls
RUST_LOGdebugConsole events.
RUST_FILE_LOGdebugFile events.
RUST_TRACE_LOGinfoWhich spans reach the OTLP trace exporter.

Dataplane log messages use a stable method_name - event text field = value shape, so boundary-specific prefixes are grep-friendly: claims_layer, user_config_store_layer, virtual_host_config_layer, AuthorizedCallValidator::validate, initialize:, call_tool, and so on.

Traces

TraceLayer emits its request spans at DEBUG level.

⚠️ RUST_TRACE_LOG=debug is required for trace export. The default span filter (info) drops the HTTP spans before they reach the OTLP exporter, so nothing arrives at the trace backend.

Enable export with --enable-open-telemetry true plus the OTLP protocol, endpoint, and header flags. Each handled HTTP request produces one span with method, route, status, and latency.

Metrics

--enable-otel-metrics true turns on the HTTP server instruments: request duration histogram, active request gauge, and request/response body size histograms, all labeled with method, status code, and service.name.

Metrics are pushed by a PeriodicReader every 30 seconds, so allow about 35 seconds after the first request before the first data point appears downstream.

Local Verification Stack

A complete local pipeline ships under docker/ as overlays on top of the local run stack:

ComponentRoleEndpoint
LangfuseTrace backend and span viewer.UI at http://localhost:3100, login admin@example.com / changeme, project ContextForge Gateway (Rust).
OTel CollectorReceives OTLP from the gateway, fans traces and metrics out.OTLP/HTTP on :4318, Prometheus exposition on :8889, raw dumps via docker logs otel-collector.
PrometheusScrapes the collector for browsable PromQL.UI at http://localhost:9090.

1. Start the stack

docker compose \
  -f docker/docker-compose-local.yaml \
  -f docker/docker-compose-langfuse.yaml \
  -f docker/docker-compose-otel-collector.yaml \
  up -d

Re-run with ps instead of up -d and wait until every container is healthy.

2. Run the gateway with export enabled

RUST_TRACE_LOG=debug \
cargo run --release --bin contextforge-gateway-rs -- \
  --address 0.0.0.0:8001 \
  --redis-port 6379 --redis-address 127.0.0.1 --redis-mode=plain-text \
  --token-verification-public-key assets/jwt.key.pub \
  --number-of-cpus 4 \
  --upstream-connection-mode=plain-text-or-tls \
  --enable-open-telemetry true \
  --enable-otel-metrics true \
  --otlp-protocol http-protobuf \
  --otlp-endpoint  http://127.0.0.1:3100/api/public/otel/v1/traces \
  --otlp-headers   "Authorization=Basic cGstbGYtY29udGV4dGZvcmdlOnNrLWxmLWNvbnRleHRmb3JnZQ==" \
  --otlp-metrics-endpoint http://127.0.0.1:4318/v1/metrics \
  --otlp-service-name contextforge-gateway-rs

The Authorization header is Basic auth for the seeded Langfuse project keys (pk-lf-contextforge:sk-lf-contextforge, base64-encoded).

3. Generate traffic

for i in {1..10}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    http://127.0.0.1:8001/contextforge-rs/admin/tokens/admin@example.com
done

Any response counts: each request is traced and recorded as a metric sample.

4. Inspect the data

  • Langfuse: open http://localhost:3100 and check the project — one span per request with method, route, status, and latency.
  • Prometheus: open http://localhost:9090; Status → Targets should show otel-collector:8889 as UP, then try the starter queries below.
  • Collector stdout: docker logs otel-collector --tail 200 shows raw OTLP trace and metric dumps.

The data path is:

gateway :8001
  -> OTLP/HTTP traces  -> Langfuse :3100 (span UI)
  -> OTLP/HTTP metrics -> OTel Collector :4318
                            -> stdout dump (docker logs)
                            -> Prometheus exposition :8889 -> Prometheus :9090

Tear the stack down with the same three -f files and down.

Prometheus Starter Queries

QuestionQuery
Request count by method, status, and servicehttp_server_request_duration_seconds_count
p95 latencyhistogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket[1m])))
In-flight requestshttp_server_active_requests
Payload throughputhttp_server_request_body_size_bytes_sum / http_server_response_body_size_bytes_sum

Debugging Checklist

Work down the boundaries in request order; each failure has an owning signal. Failure Modes lists the exact responses per boundary.

SymptomWhere to look
401 responsesclaims_layer log lines: missing/invalid bearer token, unsupported algorithm, or no configured decoder key.
400 config responsesuser_config_store_layer log lines and Redis content for the JWT subject.
404 Server not foundvirtual_host_config_layer debug line showing the requested virtual host id and how many the caller’s config has.
MCP routing errorsAuthorizedCallValidator::validate debug lines, then call_tool/read_resource/get_prompt warns for the split and backend resolution.
Backend failuresinitialize: warns for backends that failed to connect; routed-call warns name the failing backend.
Plugin problemsCPEX pipeline error logs; an invalid reload marks the plugin runtime failed until a valid config is applied.

Known Gaps

Tracked upstream, not yet implemented here:

Failure Modes

🚧 Boundary rule: every failure should come from the layer that owns the missing fact. Identity and config failures are HTTP responses before MCP handling; routing and backend failures are JSON-RPC errors.

HTTP Layer Failures

These happen in middleware, before any MCP method runs:

FailureResponseOwning layer
Inner path does not match /servers/{virtual_host_id}/mcp400 Bad Requestvirtual_host_id_layer
Missing Authorization header or non-Bearer scheme401 Unauthorizedclaims_layer
JWT cannot be decoded, uses an unsupported algorithm, or no matching decoder key/secret is configured401 Unauthorizedclaims_layer
Expired token, wrong issuer, or wrong audience401 Unauthorizedclaims_layer
No user config exists for claims.sub, or claims are absent400 Bad Requestuser_config_store_layer
Config store error other than missing data500 Internal Server Erroruser_config_store_layer
Virtual host id not present in the caller’s config404 Not Found with {"detail":"Server not found"}virtual_host_config_layer

MCP Validation Failures

InitializeCallValidator and AuthorizedCallValidator re-check the request context before method handling. These are defense-in-depth errors: in a healthy stack the middleware has already established the context.

FailureJSON-RPC error
Missing session id, user config, virtual host id, or claims extensionInternal error (Routing problem...).
Virtual host absent from the user configRESOURCE_NOT_FOUND with message No configuration. Normally unreachable because virtual_host_config_layer already returned 404.

Routing Failures

FailureBehavior
Prefixed name or completion reference does not start with a configured backend name plus -Internal error (wrong tool name / wrong resource name / wrong prompt name / wrong completion reference).
No backend entry matches the split nameInternal error (got no responses from backends).
Backend entry exists but has no running serviceInternal error. Happens when that backend failed during initialize.
More than one backend entry matchesINVALID_REQUEST, and the session’s backend entries are removed via cleanup_backends.

Backend Session Failures

SituationBehavior
Backend unreachable during initializeThe backend is stored with no running service. initialize still succeeds with the remaining backends.
Backend unreachable during a routed callThe call returns an internal error; other backends are unaffected.
Gateway process restartAll backend session state is lost because it is local process state. Clients must re-run initialize.
Request lands on a gateway node that does not own the sessionList calls return empty results and routed calls fail, because BackendTransports has no entries there. Stateful sessions need sticky routing; see Session Ownership.

Plugin Failures

FailureBehavior
Plugin denies a tool call or responseThe denial becomes an MCP error to the caller.
Soft plugin errorLogged; the call proceeds.
Invalid plugin config document (wrong version, missing cpex config, unsupported features)Rejected at load. On an invalid reload, the runtime is marked failed and plugin calls return an internal MCP error until a valid config is applied.

Config Store Failures

FailureBehavior
Redis connection lossThe connection manager retries (configured with 1,000 retries).
User config missing400 Bad Request from user_config_store_layer.
Redis GET returns an errorCurrently reported by the Redis adapter as missing data, so the layer returns 400 Bad Request.
User config undecodable, key encoding failure, or other non-missing store errors500 Internal Server Error from user_config_store_layer.

For a symptom-first version of this table, see the troubleshooting section in Run the Gateway Locally.

Testing

🧪 Verification rings: workspace checks prove the code compiles and unit behavior holds, in-repo integration tests prove MCP routing against mock backends, and the cf-integration harness proves the whole control-plane-to-dataplane path end to end. Load and benchmark runs have their own page: Performance.

Workspace Validation

CI runs these on every change; run them locally before pushing:

cargo fmt --all --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo nextest run --locked --workspace

Use cargo test when nextest is unavailable. For book changes, also run mdbook build docs/book and mdbook test docs/book.

In-Repo Integration Tests

crates/contextforge-gateway-rs-lib/tests/ exercises the gateway against in-process mock MCP backends (shared helpers live in tests/support/):

Test fileCovers
gateway_list_tools.rsList fanout, prefixing, and merged output.
gateway_prompts.rsPrompt listing and prefixed get_prompt routing.
gateway_resource_templates.rsTemplate fanout with prefixed names and URI templates, plus read_resource round-trips.
gateway_plugins.rsCPEX pre/post tool hooks around call_tool and stream events.

These run in cargo nextest run with no Docker dependencies.

Full-Stack Integration Harness

cf-integration wires the external ContextForge control plane (cf-controlplane) to this dataplane the way production intends: the stock upstream Compose stack, plus exactly two intentional differences — nginx routes only /servers/{virtual_host_id}/mcp to the dataplane (as /contextforge-rs/servers/{virtual_host_id}/mcp), and the control plane runs with DATAPLANE_PUBLISHER=true so virtual server configs reach the dataplane through Redis. Because the stack otherwise matches upstream, test failures measure dataplane behavior, not stack drift.

Quick Start

scripts/cf-integration.sh up

This checks out the control plane under .integration/mcp-context-forge, pulls the published dataplane image, and starts the combined stack plus a local MCP counter backend. The admin UI is at http://localhost:8080/admin (admin@example.com / changeme). A Fast Time backend is auto-registered as a fixed virtual server, so the commands below work with no manual UI step; backends added through the UI are published to the dataplane by the control-plane publisher.

Route Probe

scripts/cf-integration.sh probe

Verifies the public nginx-to-dataplane route end to end: a 401 negative check, initialize, session reuse, tools/list, and tools/call.

Full Test Runs

CommandWhat it runs
scripts/cf-integration.sh test-allEvery live lane against the running stack, with per-test result rows and full output in a timestamped log under .integration/test-logs/ (override with CF_TEST_LOG_DIR).
CF_TEST_ALL_LOCUST=true scripts/cf-integration.sh test-allSame, plus the full Locust load run as a final lane.
scripts/cf-integration.sh test-all-upStart or update the stack, then test-all without the load lane.
scripts/cf-integration.sh test-all-up-loadStart or update the stack, then test-all with the load lane.

Individual lanes are live-mcp, live-rbac, live-protocol, and live-all. live-mcp is the green lane: the full MCP protocol end-to-end suite passes against this harness. Remaining failures in the other lanes measure known dataplane feature gaps; the harness reports/ directory keeps the current classification.

Control-Plane Baseline

To separate dataplane regressions from upstream behavior, the harness can run the stock control-plane-only stack (no dataplane, no nginx split, no publisher) with the same commands:

scripts/cf-integration.sh down                    # frees the shared host ports
scripts/cf-integration.sh controlplane-test-all   # up + live core + locust

Individual steps are controlplane-up, controlplane-live-core, controlplane-live-all, controlplane-locust, and controlplane-down. The baseline load run is covered in Performance.

Key Settings

VariablePurpose
CF_DATAPLANE_IMAGE / CF_DATAPLANE_VERSIONWhich published dataplane image the stack runs.
CF_CONTROLPLANE_IMAGE / CF_CONTROLPLANE_REFWhich control-plane image and git ref to use.
NGINX_PORTPublic front-door port (default 8080).
CF_TEST_LOG_DIRWhere test-all writes timestamped logs.

See the harness README for the full command and override list.

Performance

Two load paths: contextforge-load-test measures the Rust dataplane alone, and the cf-integration harness measures the full nginx-to-control-plane-to-dataplane stack with Locust. Use the first to profile gateway changes and the second to measure what users would see.

Dataplane-Only Load Testing

crates/contextforge-load-test is a Goose-based traffic driver that speaks the full streamable HTTP MCP flow against a running gateway. Start the local stack with seeded user config first, then:

cargo run --release --bin contextforge-load-test -- \
  --host 'http://127.0.0.1:8001' \
  -u 120 -r 40 --run-time 120s \
  --report-file report.html

-u is concurrent users, -r is the spawn rate per second, and --report-file writes an HTML report. This measures the Rust dataplane alone, without a control plane or front door in the path. Curated run reports live in the repository’s reports/ directory.

Full-Stack Load With Locust

The cf-integration harness runs Locust against the public nginx route with a streamable-HTTP-aware locustfile:

CommandWhat it runs
scripts/cf-integration.sh smoke1 user for 10 seconds — a quick sanity pass.
scripts/cf-integration.sh locustThe full load run, default 100 users for 5 minutes.

Tune with environment variables:

LOCUST_USERS=20 LOCUST_SPAWN_RATE=5 LOCUST_RUN_TIME=2m \
  scripts/cf-integration.sh locust

MCP_VIRTUAL_SERVER_ID targets a UI-created virtual server instead of the auto-registered Fast Time one, and MCP_TOOL_NAMES picks the tools to call. Locust HTML/CSV output lands under .integration/mcp-context-forge/reports/; curated run reports live in the harness reports/ directory.

Headless Versus Locust Web UI

The harness runs Locust headless by default (LOCUST_MODE=headless): a timed run that writes the HTML and CSV reports and prints only the summary. Setting LOCUST_MODE to any other value (for example web) switches the Locust service to interactive mode: a master with the web UI on port 8089 and a class picker (LOCUST_EXPECT_WORKERS controls the expected worker count). The one-off locust command does not publish container ports, so for the web UI start the Locust service through the stack’s testing Compose profile — the upstream stack maps 8089:8089 — then open http://localhost:8089 and drive the run from the browser.

Benchmark Settings

The harness tunes config propagation for functional runs, not throughput:

VariableFunctional defaultBenchmark value
CF_DATAPLANE_PUBLISHER_INTERVAL_SECONDS2 (fast config publish)60 (upstream default)
CF_DATAPLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS0 (cache disabled)60 (upstream default)

Restore both to 60 before measuring throughput, or the fast publish loop and per-request Redis reads distort the numbers.

Control-Plane Baseline Load

To compare against the stack without the dataplane in the path:

scripts/cf-integration.sh down                # frees the shared host ports
scripts/cf-integration.sh controlplane-locust

The baseline run defaults to the non-UI Locust class subset (health, Fast Time, Fast Test, version/meta). CONTROLPLANE_LOCUST_CLASSES=all adds the admin/UI/mutating surfaces. LOCUST_USERS, LOCUST_SPAWN_RATE, and LOCUST_RUN_TIME apply here too.

Deployment Notes

🏗️ Deployment lens: the gateway is one stateless-config, stateful-session process behind a front door. Everything here follows from that: route only MCP traffic to it, keep Redis close and trusted, and give stateful sessions affinity.

Front-Door Routing

The reference docker/nginx.conf shows the intended split:

  • location ^~ /contextforge-rs proxies to the gateway upstream.
  • Everything else — UI, management APIs, other ContextForge traffic — stays on the existing control-plane paths.
  • The reference listener uses backlog=4096 reuseport and configures upstream retries (error timeout http_502/503/504, 2 tries, 10 s window). For MCP POST bodies this effectively retries only connection-stage failures: nginx does not re-send non-idempotent requests once they reached an upstream, and MCP calls are not idempotent.

There is no production health endpoint today: /health is a with_tools bootstrap helper served at /contextforge-rs/health, and production builds compile it out. Use TCP-level checks or the exported metrics for liveness until a real health endpoint exists. (The reference nginx config’s location = /health predates this and does not match the gateway’s route.)

The cf-integration harness runs the same split with the stock upstream control-plane stack and rewrites public /servers/{id}/mcp to /contextforge-rs/servers/{id}/mcp.

TLS Choices

LegOptions
Front door to gatewayPlain HTTP on a trusted private network (the common shape behind nginx), or terminate TLS at the gateway with --tls-address plus certificate and key. Both listeners can run at once on different sockets.
Gateway to Redis--redis-mode plain, TLS, or mTLS. Use TLS/mTLS across trust zones — Redis is the config trust boundary (see Security Model).
Gateway to backendsHTTPS-only by default; opt into plain HTTP or mTLS with --upstream-connection-mode.

Session Affinity And Failover

Backend MCP sessions are local process state (Session Ownership):

  • More than one replica requires sticky routing by Mcp-session-id; the reference nginx config does not provide this, so today’s safe shapes are a single replica or a front door that adds stickiness.
  • On restart or failover, sessions are gone; clients must re-run initialize. Design clients to treat a session-not-found error as “reinitialize”, not “retry”.
  • A Redis-backed user session store exists in code, but live backend services would still be process-local; a remote session story is future work.
  • Inside one host, the same constraint applies to --single-runtime false; see Concurrency And Runtime Model.

Redis Availability

Redis is required at startup and on every uncached config lookup. The connection manager retries heavily (1,000 retries) rather than failing fast, and the in-process cache (default 60 s) rides out short blips for warm subjects. A cold subject during a Redis outage fails at config lookup; the current Redis adapter reports failed GET calls as missing data, so the layer returns 400 until Redis returns.

Images And Sizing

  • CI builds docker/Dockerfile (a rust:1.96.1 builder stage) on every push to main and pushes ghcr.io/<owner>/contextforge-gateway-rs:<version>, where the tag is the Cargo package version. There is no latest tag — pin the version.
  • The reference Compose stack runs the gateway with raised limits worth copying to real deployments: nofile 65535 and TCP tuning (tcp_fin_timeout=15, widened local port range) for high connection churn.
  • Size CPU with --number-of-cpus (defaults to host CPU count) and keep the default single multi-thread runtime for stateful traffic. Memory scales with active sessions (live backend services) and the config caches (up to 50,000 entries each).

Deployment Checklist

  1. Front door routes only /contextforge-rs here.
  2. JWT verification key or secret in place and rotated with the control plane’s signing key.
  3. Redis reachable, TLS/mTLS across trust zones, write access restricted to the control plane; DATAPLANE_PUBLISHER enabled on the control plane.
  4. Upstream connection mode matches the backend URL schemes.
  5. One replica per Mcp-session-id (single replica or sticky routing).
  6. with_tools disabled in the production build.
  7. Telemetry export pointed at the collector; see Telemetry And Diagnostics.

Project

This section covers how to work on the repository and the book itself.

🛠️ Use this section for repo workflow. It keeps contribution and publishing guidance separate from runtime architecture.

PageWhat it covers
🛠️ Contributing To The GatewayRepository layout, expected validation, branch hygiene, and how to keep dataplane changes scoped.
📚 Publishing This BookHow the mdBook build feeds GitHub Pages and how to preview the output before pushing.

Contributing To The Gateway

🛠️ Contribution rule: put behavior in the crate that owns it, keep the client-visible contracts stable, and update the matching book page in the same change.

Where Changes Belong

ChangeHome
Dataplane behavior: routing, middleware, sessions, transportscontextforge-gateway-rs-lib — almost everything goes here.
Process shell: CLI flags, logging, runtime shape, exporterscontextforge-gateway-rs (the binary crate). Do not add dataplane logic here.
Shared config shapes (UserConfig, User, plugin config document)contextforge-gateway-rs-apis. Regenerate the JSON schemas after any change: cargo run -p contextforge-gateway-rs-apis (see Control-Plane Integration).
Plugin integrationcontextforge-gateway-rs-cpex.
Load generationcontextforge-load-test.

Inside the library crate, keep the module boundaries from System Shape: config validation in common.rs, extension extraction in layers/, MCP behavior in gateway/, listeners in transports/, Redis details behind UserConfigStore.

Changing MCP Routing

The backend prefix namespace is a client-visible contract (MCP Routing Semantics). Any change to it must update the merge logic, the split logic, and the tests in the same PR — and the Control-Plane Integration if the client-facing surface moves.

The project is still early, with no external users: prefer the right architecture over preserving unstable APIs or compatibility surfaces.

Adding Plugin Hooks

New hook points need defined behavior for failure, timeout, cancellation, streaming, and telemetry attribution before they land on the hot path — see Plugins And Policy. Avoid ad hoc plugin calls in the middle of routing code.

Branch And Pull Request Workflow

Name contribution branches user/<user>/<pr-branch>, where <user> is the contributor’s GitHub username and <pr-branch> is a short, kebab-case summary of the change. For example: user/alice/fix-session-cleanup.

Open pull requests as drafts while work or validation is still in progress. Mark a pull request ready for review only after its implementation, documentation, tests, and required checks are complete.

Validation

The pre-commit hooks run these local gates:

cargo fmt --all --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo deny check advisories licenses
cargo nextest run --locked --workspace
cargo build --locked --workspace
cargo bench --locked --workspace --no-run

CI runs the same gates and additionally runs cargo shear --check-test-targets --deny-warnings --locked.

Expectations by change type:

Change typeMinimum validation
Docs onlymdbook build docs/book and mdbook test docs/book.
Routing or session behaviorNew or updated integration tests under crates/contextforge-gateway-rs-lib/tests/ against the mock backends.
Config shapeSchema regeneration plus a control-plane compatibility check.
Plugin behaviorgateway_plugins.rs coverage for the new hook path.
Performance-sensitive pathsA load-test run before and after.

For end-to-end confidence against the real control plane, run the cf-integration lanes.

Keep The Book True

Every page in this book states verifiable behavior. When a change makes a page wrong — a flag, a status code, a lock, a boundary — fix the page in the same PR. Stale architecture docs are worse than none.

Publishing This Book

📚 Publishing path: mdBook renders docs/book/src/ into static HTML, and the GitHub Pages workflow deploys that HTML from main.

Local Build And Preview

The Pages workflow uses mdBook 0.5.3; use the same version locally:

cargo install mdbook --version 0.5.3 --locked

Build and preview:

mdbook build docs/book
mdbook serve docs/book --hostname 127.0.0.1 --port 3000 --open

The generated HTML lands in docs/book/book/, which is ignored by git. Never edit files there; edit docs/book/src/ instead.

Validation Before Pushing

mdbook build docs/book
mdbook test docs/book
git diff --check

mdbook test runs Rust code blocks as tests and confirms that every chapter in SUMMARY.md parses.

Workflow Behavior

.github/workflows/pages.yml runs when a change touches docs/book/** or the workflow file itself:

TriggerJobs
Pull requestbuild only: install mdBook, build the book, upload docs/book/book as the Pages artifact.
Push to mainbuild, then deploy publishes the artifact to GitHub Pages.
Manual workflow_dispatchSame as a pull request run.

Builds are cancelled when a newer run starts on the same ref; deploys are serialized and never cancelled mid-flight.

Repository Settings

Publishing requires the repository’s GitHub Pages source to be set to GitHub Actions. Without that setting, the deploy job cannot publish the uploaded artifact.

Adding Or Renaming Chapters

  1. Add or rename the Markdown file under docs/book/src/.
  2. Update docs/book/src/SUMMARY.md; its order is the reader’s path through the book.
  3. Run the validation commands above.

Draft chapters use the > Status: draft. To be implemented. marker followed by a ## To implement list, so unfinished pages stay visible and navigable.