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.
Blue arrows show request traffic. Green arrows show backend responses returning to the gateway and the merged MCP response going back to the client.
| Layer | What happens |
|---|---|
| Client edge | An MCP client calls /contextforge-rs/servers/{virtual_host_id}/mcp with a bearer token and MCP session headers. |
| Gateway hot path | The gateway validates identity, loads runtime config, selects the virtual host, and routes MCP methods. |
| Backend edge | The 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.
| Boundary | Input | Output | Owner |
|---|---|---|---|
| Downstream | HTTP request, JWT, MCP headers | request extensions and downstream session id | gateway |
| Configuration | JWT subject and virtual host id | UserConfig and selected VirtualHost | control plane data, gateway enforcement |
| Upstream | selected backend names and URLs | running backend MCP client services | gateway |
Key terms
These words appear on almost every page. It is worth pinning them down once.
| Term | Meaning in this book |
|---|---|
| Dataplane | The process on the live request path (this repository). It handles MCP traffic. |
| Control plane | The external ContextForge application that authors config, policy, and identity. It is not in this repository. |
| Downstream | The client side of the gateway: the calling MCP client and its requests. |
| Upstream | The backend side of the gateway: the configured backend MCP servers. |
| Virtual host | A named routing group inside the caller’s config. The URL path selects exactly one. |
| Backend | One configured MCP server behind the gateway. Its map key becomes a routing prefix when a virtual host has multiple backends. |
| Principal | The authenticated caller identity, taken from the JWT sub claim. |
| Session | An 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
- To run the gateway yourself, start with Getting Started.
- To understand how requests move through it, read Architecture.
- For the exact client-facing protocol behavior, see MCP Behavior.
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.
| Page | What it covers |
|---|---|
| 🚀 Run the Gateway Locally | Docker services, local bootstrap helpers, user config, MCP session setup, and smoke tests. |
| ⚙️ Configuration Reference | Listener settings, Redis wiring, JWT verification, upstream transport, telemetry, logging, and runtime knobs. |
What “started” means
A useful local gateway run has these pieces:
| Piece | Why it matters |
|---|---|
| Redis | Holds runtime UserConfig keyed by JWT subject. |
| Backend MCP servers | Provide the tools, resources, and prompts the gateway merges. |
| Gateway listener | Accepts downstream streamable HTTP MCP traffic. |
| JWT verification key or secret | Lets the gateway authenticate downstream requests. |
| User config | Maps the caller’s JWT subject to virtual hosts and backend MCP URLs. |
| MCP session | Binds 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:
| Service | Local port | Role |
|---|---|---|
redis | 6379 | Runtime config store for user config. |
gateway-one | 5555 | Sample counter MCP backend. |
gateway-two | 5556 | Sample conformance MCP backend. |
contextforge-gateway-rs | 8001 | Rust 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, and8001. The Compose stack maps both plain (6379) and TLS (16379) Redis ports. - Test keys under
assets/:jwt.keyandjwt.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
| Symptom | Likely boundary |
|---|---|
401 Unauthorized | Missing bearer token, invalid signature, expired token, wrong issuer, wrong audience, or no configured decoder key. |
400 Problem occurred retrieving the configuration | Redis has no config for the JWT subject, or the Redis lookup returned no data. |
500 Problem occurred retrieving the configuration | The 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 empty | The initialize call failed before RMCP created a downstream session. |
| Backend calls fail | Backend URL is wrong, backend process is down, or --upstream-connection-mode rejects the URL scheme. |
| Calls fail after gateway restart | Backend 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:
| Need | Typical 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 Redis | Written 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
| Flag | Env var | Required | Meaning |
|---|---|---|---|
--address <host:port> | CONTEXTFORGE_GATEWAY_RS_ADDRESS | No | Plain HTTP listener. Omit it when serving only TLS. |
--tls-address <host:port> | CONTEXTFORGE_GATEWAY_RS_TLS_ADDRESS | No | TLS listener address. Requires certificate and private key. |
--server-certificate <path> | CONTEXTFORGE_GATEWAY_RS_TLS_SERVER_CERTIFICATE | With --tls-address | PEM certificate chain for downstream TLS. |
--server-private-key <path> | CONTEXTFORGE_GATEWAY_RS_TLS_SERVER_PRIVATE_KEY | With --tls-address | PEM private key for downstream TLS. |
--address and --tls-address may both be configured, but they must not use
the same socket address.
JWT Options
| Flag | Env var | Required | Meaning |
|---|---|---|---|
--token-verification-public-key <path> | CONTEXTFORGE_GATEWAY_RS_TOKEN_VERIFICATION_PUBLIC_KEY | For RSA tokens | RSA public key used for RS256, RS384, or RS512 tokens. |
--token-verification-secret <secret> | CONTEXTFORGE_GATEWAY_RS_TOKEN_SECRET | For HMAC tokens | Shared secret used for HS256, HS384, or HS512 tokens. |
--token-verification-private-key <path> | CONTEXTFORGE_GATEWAY_RS_TOKEN_VERIFICATION_PRIVATE_KEY | Local tools only | RSA 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:
| Claim | Expected value |
|---|---|
iss | mcpgateway |
aud | mcpgateway-api |
exp | Present 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
| Flag | Env var | Required | Meaning |
|---|---|---|---|
--redis-address <host> | CONTEXTFORGE_GATEWAY_RS_REDIS_HOSTNAME | Yes | Redis host name or IP. |
--redis-port <port> | CONTEXTFORGE_GATEWAY_RS_REDIS_PORT | Yes | Redis port. |
--redis-mode <mode> | CONTEXTFORGE_GATEWAY_RS_REDIS_CONNECTION_MODE | Yes | Redis connection mode: plain-text, tls, or mtls. |
--redis-tls-trust-bundle <path> | CONTEXTFORGE_GATEWAY_RS_REDIS_TLS_REDIS_TRUST_BUNDLE | TLS and mTLS | PEM trust bundle for Redis TLS. |
--redis-tls-client-certificate <path> | CONTEXTFORGE_GATEWAY_RS_REDIS_TLS_REDIS_CLIENT_CERTIFICATE | mTLS | PEM client certificate for Redis mTLS. |
--redis-tls-client-private-key <path> | CONTEXTFORGE_GATEWAY_RS_REDIS_TLS_REDIS_CLIENT_PRIVATE_KEY | mTLS | PEM client private key for Redis mTLS. |
--user-config-cache-expiry-seconds <n> | CONTEXTFORGE_GATEWAY_RS_USER_CONFIG_CACHE_EXPIRY_SECONDS | No, default 60 | Expiry 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
| Flag | Env var | Required | Meaning |
|---|---|---|---|
--upstream-connection-mode <mode> | CONTEXTFORGE_GATEWAY_RS_UPSTREAM_CONNECTION_MODE | No | Controls whether backend MCP URLs may be HTTP, HTTPS, or mTLS. |
--upstream-trust-bundle <path> | CONTEXTFORGE_GATEWAY_RS_TLS_UPSTREAM_TRUST_BUNDLE | No | Additional PEM trust bundle for HTTPS upstreams. |
--upstream-certificate <path> | CONTEXTFORGE_GATEWAY_RS_TLS_UPSTREAM_CERTIFICATE | mTLS modes | PEM client certificate for upstream mTLS. |
--upstream-private-key <path> | CONTEXTFORGE_GATEWAY_RS_TLS_UPSTREAM_PRIVATE_KEY | mTLS modes | PEM client private key for upstream mTLS. |
Connection modes:
| Mode | Behavior |
|---|---|
omitted or tls-only | HTTPS upstreams only. This is the safe default. |
plain-text-or-tls | Allows HTTP and HTTPS upstream URLs. Use this for the local Compose backends. |
plain-text-or-m-tls | Allows HTTP and HTTPS with client identity configured. |
mtls-only | Requires 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
| Flag | Env var | Default | Meaning |
|---|---|---|---|
--number-of-cpus <n> | CONTEXTFORGE_GATEWAY_RS_GATEWAY_CPUS | Host CPU count | Worker thread count for the Tokio runtime shape. |
--single-runtime <bool> | CONTEXTFORGE_GATEWAY_RS_SINGLE_RUNTIME | true | true uses one multi-thread runtime. false starts multiple current-thread runtimes. |
--runtime-plugins-enabled <bool> | CONTEXTFORGE_GATEWAY_RS_RUNTIME_PLUGINS_ENABLED | false | Enables 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
| Flag | Env var | Default | Meaning |
|---|---|---|---|
--enable-open-telemetry <bool> | CONTEXTFORGE_GATEWAY_RS_ENABLE_OPEN_TELEMETRY | false | Enables trace export. |
--enable-otel-metrics <bool> | CONTEXTFORGE_GATEWAY_RS_ENABLE_OTEL_METRICS | false | Enables HTTP server metric export. |
--otlp-protocol <protocol> | CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_PROTOCOL | grpc | grpc or http-protobuf. |
--otlp-endpoint <uri> | CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_ENDPOINT | Protocol-specific | Trace export endpoint. |
--otlp-metrics-endpoint <uri> | CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT | Protocol-specific | Metrics export endpoint. |
--otlp-headers <headers> | CONTEXTFORGE_GATEWAY_RS_OTEL_EXPORTER_OTLP_HEADERS | none | Comma-separated key=value headers for OTLP export. |
--otlp-service-name <name> | CONTEXTFORGE_GATEWAY_RS_OTEL_SERVICE_NAME | CONTEXTFORGE-GATEWAY-RS | OpenTelemetry service.name. |
Default endpoints:
| Protocol | Traces | Metrics |
|---|---|---|
grpc | http://127.0.0.1:4317 | http://127.0.0.1:4317 |
http-protobuf | http://127.0.0.1:4318/v1/traces | http://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 var | Default | Meaning |
|---|---|---|
--log-name / CONTEXTFORGE_GATEWAY_LOG_NAME | contextforge-gateway-rs.log | File log name in the current working directory. |
--log-rotation / CONTEXTFORGE_GATEWAY_LOG_ROTATION | hourly | Rotation mode: minutely, hourly, daily, or never. |
RUST_LOG | debug | Console event filter. |
RUST_FILE_LOG | debug | File event filter. |
RUST_TRACE_LOG | info | OpenTelemetry 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 combination | Reason |
|---|---|
--tls-address without server cert or key | Downstream TLS cannot be configured partially. |
Same socket for --address and --tls-address | The process cannot bind both listeners to the same address. |
--redis-mode tls without trust bundle | Redis TLS needs a root certificate bundle. |
--redis-mode mtls without trust bundle, client cert, or client key | Redis mTLS needs all three pieces. |
| mTLS upstream mode without upstream cert and key | The reqwest identity cannot be built. |
| Plain HTTP backend with default upstream mode | Default 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.
| Page | What it covers |
|---|---|
| 🧭 System Shape | The gateway’s role in ContextForge, its crate layout, and the line between dataplane and control plane. |
| 🔀 Request Flow | The ordered path from downstream HTTP request to backend MCP call and merged response. |
| 🧵 Concurrency And Runtime Model | Executor shapes, shared state and locks, fanout, cancellation, and the allocator. |
| 🔐 Authentication And User Config Lookup | How JWT claims, Redis-backed user config, and virtual host selection combine before routing. |
| 🔒 Security Model And Trust Boundaries | What the gateway trusts, what compromise of each boundary means, and transport security posture. |
| 🗂️ Runtime Configuration | The current UserConfig model, MessagePack Redis persistence, cache behavior, and expected growth. |
| 🤝 Control-Plane Integration | The current, still-provisional integration surface: Redis keys, schemas, token shape, and route parity. |
| 🔌 Backend Connections And Transports | Downstream listeners, upstream RMCP transports, config-store transport, and TLS direction. |
| 🧵 Session Ownership | How backend services are keyed, shared, cleaned up, and constrained by local process ownership. |
| 🧱 Architectural Choices | The 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.
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:
| Layer | Owns | Does not own |
|---|---|---|
| ContextForge control plane | Management workflows, UI, IAM lifecycle, durable config ownership, policy authoring, observability storage. | Hot MCP request handling or in-process MCP sessions. |
| Rust gateway dataplane | Auth, config lookup, virtual host selection, MCP fanout, plugin hooks, telemetry emission, upstream calls. | Control-plane APIs, customer IAM, UI, durable metrics storage. |
| Backend MCP servers | Actual 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:
| Area | Why it stays outside this repo |
|---|---|
| Management APIs and UI | They are user/admin workflows, not request-path forwarding. |
| Credential and policy authoring | The dataplane consumes policy; it should not become the policy editor. |
| Persistent config ownership | Durable storage and schema lifecycle belong to the control plane. |
| Durable observability storage | The gateway emits telemetry; it should not become the metrics database. |
| Customer IAM lifecycle | The gateway validates presented identity, not the customer identity product. |
The Rust dataplane owns the hot path:
| Area | Code-facing meaning |
|---|---|
| Listener setup | Expose downstream TCP and/or TLS endpoints. |
| JWT validation | Convert bearer auth into request claims. |
| Runtime config lookup | Load the caller’s UserConfig by JWT subject. |
| MCP fanout and routing | Present multiple backends as one MCP server. |
| Request and response hooks | Run plugin/policy hooks at explicit pipeline points. |
| Telemetry emission | Emit 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)
| Step | Owner | Result |
|---|---|---|
| Parse config | Binary crate | A process config value used to build the gateway. |
| Initialize logging | Binary crate | Console/file logging and trace export are ready before serving traffic. |
| Select runtime | runtime::Runtime | Either one multi-thread Tokio runtime or multiple current-thread runtimes. |
| Start plugin runtime | CPEX registry path | Optional plugin manager and reloadable config loop. |
| Build gateway | Gateway::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:
| Crate | Responsibility |
|---|---|
contextforge-gateway-rs | Process shell: CLI config, logging, telemetry exporters, Tokio runtime, allocator, plugin registry startup, gateway construction. |
contextforge-gateway-rs-lib | Dataplane library: Axum stack, request middleware, config lookup, MCP fanout/routing, backend sessions, upstream clients, downstream transports. |
contextforge-gateway-rs-apis | Shared contract: UserConfig, VirtualHost, BackendMCPGateway, Redis user key, plugin config document, schema generation. |
contextforge-gateway-rs-cpex | Plugin integration: CPEX runtime registry, Redis plugin config loading, supported tool pre/post hooks, stream event adaptation. |
contextforge-load-test | Performance 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:
| Context | Inserted by | Used by |
|---|---|---|
VirtualHostId | virtual_host_id_layer | Initialize and authorized MCP validators. |
ContextForgeClaims | claims_layer | Config lookup, session cleanup, routing. |
SessionId | session_id_layer | Authorized MCP calls after initialize. |
UserConfig | user_config_store_layer | Virtual 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:
| State | Owner | Lifetime |
|---|---|---|
CLI Config | Binary startup and Gateway | Process lifetime. |
| JWT decoders | ContextForgeGatewayAppState | Process lifetime. |
| User config | UserConfigStore, currently Redis plus in-process LRU | Control-plane authored, request-path consumed. |
| Request identity | Request extensions | One HTTP request. |
| Virtual host id | Request extensions | One HTTP request. |
| Downstream session id | RMCP plus SessionId extension | MCP session. |
| Backend RMCP services | BackendTransports map | Local process, per principal/backend/session. |
| Local user session mapping | LocalUserSessionStore | Local process, per principal/session. |
| Runtime plugin manager | CpexRuntimeRegistry and handle | Process lifetime, reloadable. |
| Logs, traces, metrics | Logging setup and Axum/tower layers | Process 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:
| Module | Owns |
|---|---|
common.rs | CLI 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.rs | Local 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:
| Subpage | Reason it exists |
|---|---|
| Request Flow | Shows the ordered path through startup, middleware, initialize, and authorized calls. |
| Authentication And User Config Lookup | Keeps identity and config selection separate from MCP method handling. |
| Runtime Configuration | Describes the data model the gateway consumes, independent of Redis transport details. |
| Backend Connections And Transports | Separates downstream, upstream, and config-store transports. |
| Session Ownership | Makes local backend session state and load-balancing constraints explicit. |
| Architectural Choices | Records 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.
Graph Legend
The graph uses color only to separate paths:
| Color | Meaning |
|---|---|
| Blue | Request direction: listener, middleware, RMCP dispatch, and backend calls. |
| Green | Response direction: backend result, response unwind, and client response. |
| Amber | initialize, where backend MCP client sessions are created. |
| Purple | Authorized MCP calls after Mcp-session-id exists. |
| Red | Layer-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
| Checkpoint | Established fact | Next dependency |
|---|---|---|
| Listener | The request reached the Rust dataplane over TCP or TLS. | Metrics, tracing, and nested routing can observe it. |
| Path extraction | The inner path matched /servers/{virtual_host_id}/mcp. | MCP handlers can resolve a VirtualHost. |
| Claims validation | The bearer token was accepted and ContextForgeClaims exists. | Config lookup can use claims.sub. |
| User config lookup | A UserConfig exists for the authenticated subject. | The virtual host check can run against that config. |
| Virtual host check | The path’s virtual host id exists in the caller’s config. | MCP validators can resolve the selected VirtualHost. |
| RMCP dispatch | The 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:
| Layer | Request behavior | Failure behavior |
|---|---|---|
virtual_host_id_layer | Extracts /servers/{virtual_host_id}/mcp and inserts VirtualHostId. | Returns 400 when the inner path does not match. |
claims_layer | Validates Authorization: Bearer ... with configured RS/HMAC decoder, issuer, audience, and expiration. Inserts ContextForgeClaims. | Returns 401 for missing or invalid bearer auth. |
session_id_layer | Reads 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_layer | Uses 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_layer | Checks 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:
InitializeCallValidatorreadsDownstreamSessionId,UserConfig,VirtualHostId, andContextForgeClaims.- It resolves
user_config.virtual_hosts[virtual_host_id]. - It reads the local user session mapping for
claims.sub + downstream_session_id. - For every backend in the selected virtual host, it concurrently builds a
StreamableHttpClientTransportwith the configured backend URL and serves aGatewayBackendClientover that transport. - 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.
- It writes the local user session mapping.
- It stores each backend service in
BackendTransportskeyed by principal, backend name, and downstream session id. - It returns
InitializeResultwith 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 family | Flow |
|---|---|
list_tools, list_resources, list_prompts, list_resource_templates | Borrow 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_tool | Resolve 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_prompt | Select the only backend and forward the identifier unchanged, or split and strip the prefix for a multi-backend host, then call the resolved backend. |
complete | Apply 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:
| Method | Current behavior |
|---|---|
ping | Returns 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:
| Mode | Shape | When |
|---|---|---|
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. |
false | One 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
BackendTransportsmap and user-session store insiderun_gateway. Backend session state is therefore per-runtime-thread, andSO_REUSEPORTgives 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
| State | Lock | Contention profile |
|---|---|---|
BackendTransports map | Arc<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 set | Arc<tokio::sync::Mutex<HashSet<String>>> | Local subscribe/unsubscribe only. |
| User config LRU cache | Arc<tokio::sync::Mutex<LruCache>> inside RedisUserConfigStore | One lock per config lookup on the hot path; misses add a Redis round trip. |
| User session LRU cache | Same pattern in LocalUserSessionStore | Initialize and delete paths. |
JWT decoders, upstream reqwest::Client, process Config | No 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
initializeopens 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_toolwatches 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.
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:
| Step | Code | Output |
|---|---|---|
| Path context | virtual_host_id_layer | VirtualHostId extension. |
| JWT validation | claims_layer | ContextForgeClaims extension. |
| Session header | session_id_layer | Optional SessionId extension. |
| Config lookup | user_config_store_layer | UserConfig extension. |
| Virtual host check | virtual_host_config_layer | 404 when the path’s virtual host id is not in the loaded config. |
| MCP validation | InitializeCallValidator or AuthorizedCallValidator | Selected 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 property | Current behavior |
|---|---|
| Algorithm | Accepts RS256/RS384/RS512 when an RSA public key is configured, or HS256/HS384/HS512 when a shared secret is configured. |
| Issuer | Must match mcpgateway. |
| Audience | Must match mcpgateway-api. |
| Expiration | exp is validated. |
| Unsupported algorithm | Rejected before claims are inserted. |
The decoded value is stored as ContextForgeClaims. The fields currently
important to routing are:
| Claim | Routing role |
|---|---|
sub | Becomes the user config key and the principal for backend session lookup. |
iss, aud, exp | Authentication checks only. |
jti, token_use, iat, teams, user, scopes | Carried 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:
| Stage | Behavior |
|---|---|
| LRU hit | Clone the decoded UserConfig from the cache. |
| LRU miss | MessagePack-encode User::new(subject), GET that Redis key, decode the MessagePack UserConfig, then cache it. |
| Cache size | 50,000 entries. |
| Cache expiry | --user-config-cache-expiry-seconds, default 60 seconds. 0 disables the cache and reads Redis on every request. |
| Redis retry setting | Connection 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:
| Failure | Response |
|---|---|
Missing Authorization header | 401 Unauthorized. |
Header does not start with Bearer | 401 Unauthorized. |
| JWT header or body cannot be decoded | 401 Unauthorized. |
| JWT uses an unsupported algorithm | 401 Unauthorized. |
| Required decoder key or secret is not configured | 401 Unauthorized. |
No user config exists for claims.sub | 400 Bad Request. |
| Redis/config store error other than missing data | 500 Internal Server Error. |
user_config_store_layer runs without claims | 400 Bad Request. |
| Virtual host id not present in the caller’s config | 404 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
| Boundary | Trust level | Enforced by |
|---|---|---|
| Downstream client | Untrusted. 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 material | Trust anchor. The RSA public key or HMAC secret in process config decides which tokens are accepted. | Process config; loaded at startup. |
| Redis | Control-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 servers | Trusted per configured URL. The gateway forwards caller traffic to them and merges their responses. | UserConfig backend URLs plus the upstream connection mode. |
| Plugins | Fully 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) orHS256/HS384/HS512(shared secret configured); anything else is rejected. issmust bemcpgateway,audmust bemcpgateway-api, andexpis validated. There is no revocation list: a leaked token is valid until it expires.- Authorization is config existence. The
subclaim selects the caller’sUserConfig; 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 return404before MCP handling. jti,token_use,iat,teams,user, andscopesare carried but not yet enforced;token_use,iat,teams, andscopesare optional, as isuser.full_name. Fine-grained permissions are future policy work.
What Compromise Means
| If this is compromised | Impact |
|---|---|
| JWT signing key or HMAC secret | Attacker mints tokens for any subject and reaches that subject’s backends. Rotate the key and restart; there is no revocation. |
| Redis write access | Attacker 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 server | Attacker sees the requests routed to that backend and controls its responses; the namespace prefix limits blast radius to that backend’s objects. |
| The gateway process | Full compromise: it holds the decoding keys in memory and live backend sessions. |
Transport Security
| Leg | Current posture |
|---|---|
| Downstream | TLS 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. |
| Upstream | HTTPS-only by default; plain HTTP must be opted into with --upstream-connection-mode. mTLS client identity is supported per process. |
| Redis | Plain, 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
UserConfigdocuments, 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.
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
| Surface | Source | Loaded | Main user |
|---|---|---|---|
Process Config | CLI flags and CONTEXTFORGE_GATEWAY_RS_* env vars parsed by clap. | Startup. | Listener setup, JWT decoder keys, Redis connection, upstream HTTP client, telemetry, runtime shape. |
UserConfig | UserConfigStore, currently Redis through RedisUserConfigStore. | Per request after JWT validation. | Virtual host and backend selection. |
RuntimePluginConfigDocument | Redis 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 field | Current MCP dataplane behavior |
|---|---|
UserConfig.virtual_hosts | Required. VirtualHostId from the path selects one entry. |
VirtualHost.backends map key | Required. Selects backend session state and becomes the public prefix when a multi-backend identifier has no explicit alias. |
BackendMCPGateway.url | Required. Used to build the upstream StreamableHttpClientTransport. |
BackendMCPGateway.name | Present in the model. Current routing uses the backend map key, not this field, as the namespace. |
transport | Present in the model. Current upstream code always builds a streamable HTTP client transport. |
passthrough_headers | Present in the model. Current MCP routing does not apply header pass-through policy from this field. |
allowed_tool_names | Present in the model. Current list/call routing does not enforce it. |
tool_name_aliases | Optional exact {downstream_alias: upstream_original} mapping used by tool list/call routing before the single-versus-multi-backend fallback. |
allowed_resource_names | Present in the model. Current resource routing does not enforce it. |
allowed_prompt_names | Present 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:
| Item | Encoding |
|---|---|
| Redis key | MessagePack-encoded User::new(jwt_subject). |
| Redis value | MessagePack-encoded UserConfig. |
| Cache key | Raw subject string through User::key(). |
| Cache value | Decoded UserConfig. |
The in-process cache is an implementation detail:
| Setting | Value |
|---|---|
| Entries | 50,000 |
| Expiry | --user-config-cache-expiry-seconds, default 60 seconds; 0 disables caching |
| Redis connection retries | 1,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 feature | Current support |
|---|---|
TOOL_PRE_INVOKE | Supported for call_tool before backend invocation. |
TOOL_POST_INVOKE | Supported for call_tool responses and backend progress events. |
| CPEX routing | Rejected. Gateway routing is owned by UserConfig and MCP routing code. |
| Plugin conditions | Rejected. |
| Plugin dirs, global policies, global defaults | Rejected. |
| Other hook types | Rejected. |
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:
| Area | Examples |
|---|---|
| Listener | address, tls_address, downstream TLS certificate and private key. |
| Authentication | RSA public key path or HMAC secret for JWT verification. |
| Redis | Host, port, plain/TLS/mTLS mode, trust bundle, client cert, client key. |
| Upstream HTTP client | Plaintext/TLS/mTLS mode, upstream trust bundle, client cert, client key. |
| Telemetry | OpenTelemetry traces, metrics endpoint/protocol, OTLP headers, service name. |
| Runtime shape | CPU count and single-runtime versus multi-runtime mode. |
| Plugins | Whether runtime plugins are enabled. |
| Logging | Log 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:
| Agreement | Value 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 host | 404 with body {"detail":"Server not found"}, matching the control-plane response shape. |
| Token issuer and audience | iss = mcpgateway, aud = mcpgateway-api — the values the control plane mints. |
| Claims shape | sub, 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 key | MessagePack-encoded User::new(jwt_subject) (key type plus subject). |
| User config value | MessagePack-encoded UserConfig; the JSON schema is generated into schemas/user_config.json. |
| Plugin config key | ContextForgeGatewayRuntimePluginConfig, 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.
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 class | Current implementation | Main owner | Purpose |
|---|---|---|---|
| Downstream listener | Axum/Hyper over TCP and optional Rustls TLS. | transports/ and Gateway::run_gateway. | Accept MCP streamable HTTP traffic from clients or the front door. |
| Upstream backend | Shared 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 store | Redis 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.
| Listener | Config fields | Behavior |
|---|---|---|
| TCP | address | Binds a Tokio TcpSocket, sets reuse options and keepalive, listens with backlog 1024, and serves Axum with graceful shutdown on ctrl_c. |
| TLS | tls_address, server_certificate, server_private_key | Builds a Rustls server config, accepts TLS by hand, then serves the same Axum router through Hyper. |
TLS listener setup has two important constraints:
| Constraint | Why |
|---|---|
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:
| Mode | reqwest behavior |
|---|---|
| unset | https_only(true). Same as TlsOnly. |
TlsOnly | HTTPS backends only. |
PlainTextOrTls | HTTP or HTTPS backends. |
PlainTextOrMTls | HTTP or HTTPS backends, with a client identity configured for TLS handshakes. |
MtlsOnly | HTTPS 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 mode | Connection behavior |
|---|---|
PlainText | Connects to host:port over TCP. |
Tls | Connects with rediss://host:port and a required trust bundle. |
Mtls | Connects 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.
| Setting | Today | Better long-term owner |
|---|---|---|
| Downstream TLS certificate | Process config. | Process config. It belongs to the gateway listener. |
| Upstream trust bundle and mTLS identity | Process config. | Runtime config per backend or referenced secret material. |
| Backend auth headers | Not applied from UserConfig yet. | Runtime config per backend. |
| Backend transport type | Model field exists, not routed yet. | Runtime config per backend. |
| Header pass-through policy | Model 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.
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.
| State | Key | Owner today | Lifetime |
|---|---|---|---|
| RMCP downstream session | RMCP DownstreamSessionId and later Mcp-session-id. | RMCP LocalSessionManager. | Local process. |
| User session mapping | UserSession { principal, downstream_session_id }. | LocalUserSessionStore. | Local LRU cache, 50,000 entries, 1 hour. |
| Backend running service | principal + backend_name + session_id. | BackendTransports. | Local process. |
| Backend upstream MCP session | Managed 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.
| Operation | What 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 shape | Session behavior |
|---|---|
list_tools, list_resources, list_prompts, list_resource_templates | Borrow 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, complete | Resolve 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:
| Option | Tradeoff |
|---|---|
Sticky routing by Mcp-session-id | Simple, preserves local state, but couples sessions to one process. |
| External session ownership | More flexible, but backend running service state must move out of process or become reconstructible. |
| Reinitialize after failover | Operationally 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.
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
| Choice | Current decision | Why it matters |
|---|---|---|
| Dataplane, not control plane | This 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 abstracted | MCP routing depends on UserConfig, VirtualHost, and UserConfigStore, not Redis commands. | Keeps future xDS/gRPC or another config stream possible. |
| Backend names are public | The backend map key is part of tool/resource/prompt names. | Backend renames are client-visible behavior changes. |
| Sessions are local today | Backend 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 contract | The 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 explicit | Tool 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:
| Hook | Current boundary |
|---|---|
TOOL_PRE_INVOKE | Runs before call_tool forwards to the selected backend. |
TOOL_POST_INVOKE | Runs 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:
| Concern | Stable owner |
|---|---|
| Gateway listener certificate | Process config. |
| JWT verification keys | Process config. |
| Backend URL, auth headers, pass-through policy, allowed objects | Runtime user config. |
| Backend-specific trust and client identity | Likely 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.
| Change | Expected follow-through |
|---|---|
| Backend namespace changes | Update merge logic, split logic, tests, docs, and migration notes. |
| Session state moves external | Update SessionManager, cleanup behavior, load-balancing docs, and failure-mode tests. |
| Config transport changes | Keep UserConfigStore as the boundary and update adapter tests. |
| Plugin hook surface expands | Document ordering, failure behavior, cancellation, streaming, and telemetry. |
| New protocol joins the gateway | Keep 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.
| Page | What it covers |
|---|---|
| 📋 MCP Method Reference | Initialize, list, call, read, and prompt behavior from the client-facing gateway point of view. |
| 🛣️ MCP Routing Semantics | How 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
| Aspect | Behavior |
|---|---|
| Required context | RMCP DownstreamSessionId, UserConfig, VirtualHostId, and ContextForgeClaims. The Mcp-session-id header is not required yet. |
| Fanout | One StreamableHttpClientTransport per configured backend in the selected virtual host, opened concurrently with futures::future::join_all. |
| Backend failure | Not fatal. A backend that fails to initialize is stored with no running service; list calls skip it and routed calls to it fail. |
| Stored state | The local user session mapping, plus one BackendTransports entry per backend keyed by principal, backend name, and downstream session id. |
| Result | InitializeResult 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:
| Aspect | Behavior |
|---|---|
| Fanout | Concurrent call to every connected backend in the session. |
| Identifiers | A 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. |
| Ordering | Merged output is sorted by name. |
| Failures | Failed or unavailable backends are logged and omitted from the merged result. |
| Pagination | One 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:
| Method | Behavior |
|---|---|
call_tool | Resolves 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_resource | Preserves a single-backend URI or strips a multi-backend prefix, then returns the selected backend’s result. |
subscribe, unsubscribe | Apply the same resource-URI routing, track the downstream subscription, and forward or stop forwarding matching resource-update notifications. |
get_prompt | Preserves a single-backend prompt name or strips a multi-backend prefix, then returns the selected backend’s result. |
complete | Applies 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:
| Method | Current behavior |
|---|---|
ping | Returns 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.
| Page | What it covers |
|---|---|
| 🧩 Plugins And Policy | Where request and response plugins fit, and why body access changes the runtime model. |
| 📈 Telemetry And Diagnostics | Signals needed to debug authentication, config lookup, routing, upstream calls, and merged results. |
| 🚧 Failure Modes | Expected failures by boundary, including auth, Redis, virtual hosts, backend sessions, and upstream transport. |
| 🧪 Testing | Workspace checks, in-repo integration tests, and the cf-integration full-stack test lanes. |
| ⚡ Performance | Dataplane-only load testing, full-stack Locust runs, headless versus web UI, and benchmark settings. |
| 🏗️ Deployment Notes | Front-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
| Signal | Produced by | Destination |
|---|---|---|
| Logs | tracing events from layers, validators, and MCP handlers. | Console and a rotating file in the working directory. |
| Request traces | tower_http::TraceLayer spans around every HTTP request. | OTLP trace export when --enable-open-telemetry is set. |
| HTTP metrics | axum-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 var | Default | Controls |
|---|---|---|
RUST_LOG | debug | Console events. |
RUST_FILE_LOG | debug | File events. |
RUST_TRACE_LOG | info | Which 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=debugis 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:
| Component | Role | Endpoint |
|---|---|---|
| Langfuse | Trace backend and span viewer. | UI at http://localhost:3100, login admin@example.com / changeme, project ContextForge Gateway (Rust). |
| OTel Collector | Receives OTLP from the gateway, fans traces and metrics out. | OTLP/HTTP on :4318, Prometheus exposition on :8889, raw dumps via docker logs otel-collector. |
| Prometheus | Scrapes 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:3100and check the project — one span per request with method, route, status, and latency. - Prometheus: open
http://localhost:9090;Status → Targetsshould showotel-collector:8889as UP, then try the starter queries below. - Collector stdout:
docker logs otel-collector --tail 200shows 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
| Question | Query |
|---|---|
| Request count by method, status, and service | http_server_request_duration_seconds_count |
| p95 latency | histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket[1m]))) |
| In-flight requests | http_server_active_requests |
| Payload throughput | http_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.
| Symptom | Where to look |
|---|---|
401 responses | claims_layer log lines: missing/invalid bearer token, unsupported algorithm, or no configured decoder key. |
400 config responses | user_config_store_layer log lines and Redis content for the JWT subject. |
404 Server not found | virtual_host_config_layer debug line showing the requested virtual host id and how many the caller’s config has. |
| MCP routing errors | AuthorizedCallValidator::validate debug lines, then call_tool/read_resource/get_prompt warns for the split and backend resolution. |
| Backend failures | initialize: warns for backends that failed to connect; routed-call warns name the failing backend. |
| Plugin problems | CPEX 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:
- W3C trace-context propagation across gateway hops (mcp-context-forge#4723).
- MCP-semantic spans with tool names and JSON-RPC method attributes (mcp-context-forge#4722).
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:
| Failure | Response | Owning layer |
|---|---|---|
Inner path does not match /servers/{virtual_host_id}/mcp | 400 Bad Request | virtual_host_id_layer |
Missing Authorization header or non-Bearer scheme | 401 Unauthorized | claims_layer |
| JWT cannot be decoded, uses an unsupported algorithm, or no matching decoder key/secret is configured | 401 Unauthorized | claims_layer |
| Expired token, wrong issuer, or wrong audience | 401 Unauthorized | claims_layer |
No user config exists for claims.sub, or claims are absent | 400 Bad Request | user_config_store_layer |
| Config store error other than missing data | 500 Internal Server Error | user_config_store_layer |
| Virtual host id not present in the caller’s config | 404 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.
| Failure | JSON-RPC error |
|---|---|
| Missing session id, user config, virtual host id, or claims extension | Internal error (Routing problem...). |
| Virtual host absent from the user config | RESOURCE_NOT_FOUND with message No configuration. Normally unreachable because virtual_host_config_layer already returned 404. |
Routing Failures
| Failure | Behavior |
|---|---|
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 name | Internal error (got no responses from backends). |
| Backend entry exists but has no running service | Internal error. Happens when that backend failed during initialize. |
| More than one backend entry matches | INVALID_REQUEST, and the session’s backend entries are removed via cleanup_backends. |
Backend Session Failures
| Situation | Behavior |
|---|---|
Backend unreachable during initialize | The backend is stored with no running service. initialize still succeeds with the remaining backends. |
| Backend unreachable during a routed call | The call returns an internal error; other backends are unaffected. |
| Gateway process restart | All 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 session | List calls return empty results and routed calls fail, because BackendTransports has no entries there. Stateful sessions need sticky routing; see Session Ownership. |
Plugin Failures
| Failure | Behavior |
|---|---|
| Plugin denies a tool call or response | The denial becomes an MCP error to the caller. |
| Soft plugin error | Logged; 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
| Failure | Behavior |
|---|---|
| Redis connection loss | The connection manager retries (configured with 1,000 retries). |
| User config missing | 400 Bad Request from user_config_store_layer. |
Redis GET returns an error | Currently 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 errors | 500 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-integrationharness 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 file | Covers |
|---|---|
gateway_list_tools.rs | List fanout, prefixing, and merged output. |
gateway_prompts.rs | Prompt listing and prefixed get_prompt routing. |
gateway_resource_templates.rs | Template fanout with prefixed names and URI templates, plus read_resource round-trips. |
gateway_plugins.rs | CPEX 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
| Command | What it runs |
|---|---|
scripts/cf-integration.sh test-all | Every 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-all | Same, plus the full Locust load run as a final lane. |
scripts/cf-integration.sh test-all-up | Start or update the stack, then test-all without the load lane. |
scripts/cf-integration.sh test-all-up-load | Start 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
| Variable | Purpose |
|---|---|
CF_DATAPLANE_IMAGE / CF_DATAPLANE_VERSION | Which published dataplane image the stack runs. |
CF_CONTROLPLANE_IMAGE / CF_CONTROLPLANE_REF | Which control-plane image and git ref to use. |
NGINX_PORT | Public front-door port (default 8080). |
CF_TEST_LOG_DIR | Where test-all writes timestamped logs. |
See the harness README for the full command and override list.
Performance
⚡ Two load paths:
contextforge-load-testmeasures the Rust dataplane alone, and thecf-integrationharness 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:
| Command | What it runs |
|---|---|
scripts/cf-integration.sh smoke | 1 user for 10 seconds — a quick sanity pass. |
scripts/cf-integration.sh locust | The 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:
| Variable | Functional default | Benchmark value |
|---|---|---|
CF_DATAPLANE_PUBLISHER_INTERVAL_SECONDS | 2 (fast config publish) | 60 (upstream default) |
CF_DATAPLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS | 0 (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-rsproxies 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 reuseportand configures upstream retries (error timeout http_502/503/504, 2 tries, 10 s window). For MCPPOSTbodies 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
| Leg | Options |
|---|---|
| Front door to gateway | Plain 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 backends | HTTPS-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(arust:1.96.1builder stage) on every push tomainand pushesghcr.io/<owner>/contextforge-gateway-rs:<version>, where the tag is the Cargo package version. There is nolatesttag — pin the version. - The reference Compose stack runs the gateway with raised limits worth
copying to real deployments:
nofile65535 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
- Front door routes only
/contextforge-rshere. - JWT verification key or secret in place and rotated with the control plane’s signing key.
- Redis reachable, TLS/mTLS across trust zones, write access restricted to
the control plane;
DATAPLANE_PUBLISHERenabled on the control plane. - Upstream connection mode matches the backend URL schemes.
- One replica per
Mcp-session-id(single replica or sticky routing). with_toolsdisabled in the production build.- 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.
| Page | What it covers |
|---|---|
| 🛠️ Contributing To The Gateway | Repository layout, expected validation, branch hygiene, and how to keep dataplane changes scoped. |
| 📚 Publishing This Book | How 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
| Change | Home |
|---|---|
| Dataplane behavior: routing, middleware, sessions, transports | contextforge-gateway-rs-lib — almost everything goes here. |
| Process shell: CLI flags, logging, runtime shape, exporters | contextforge-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 integration | contextforge-gateway-rs-cpex. |
| Load generation | contextforge-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 type | Minimum validation |
|---|---|
| Docs only | mdbook build docs/book and mdbook test docs/book. |
| Routing or session behavior | New or updated integration tests under crates/contextforge-gateway-rs-lib/tests/ against the mock backends. |
| Config shape | Schema regeneration plus a control-plane compatibility check. |
| Plugin behavior | gateway_plugins.rs coverage for the new hook path. |
| Performance-sensitive paths | A 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 frommain.
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:
| Trigger | Jobs |
|---|---|
| Pull request | build only: install mdBook, build the book, upload docs/book/book as the Pages artifact. |
Push to main | build, then deploy publishes the artifact to GitHub Pages. |
Manual workflow_dispatch | Same 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
- Add or rename the Markdown file under
docs/book/src/. - Update
docs/book/src/SUMMARY.md; its order is the reader’s path through the book. - 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.