Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ContextForge External Dataplane — Wiki

This wiki captures durable project context and working preferences. Check this index at the start of a task to decide whether deeper context is needed, then follow only the links that are relevant.

Pages

FileWhat it covers
getting-started.mdFull docker stack, local cargo dev, cf-integration — commands and URIs
project.mdWhat the project is, goals, stakeholders, key modules, crate ownership, active work
preferences.mdWorking standards, code style, logging rules, branch naming, AI interaction preferences
architecture.mdCurrent middleware stack order, pipeline shape, module boundaries, state ownership, executor shapes
routing.mdStateless routing model: VirtualHost routing tables, per-request backend lifecycle, method quick reference, header forwarding, plugin hooks
mcp-capability-allocation.mdTentative ContextForge 2.0 target topology, ownership, state model, Phase 1-4 roadmap, and Phase 3 flows
failure-modes.mdHTTP/MCP/routing/backend/plugin failure table — exact HTTP codes and JSON-RPC errors
config.mdKey CLI flags, JWT claims, UserConfig shape, plugin config, telemetry debugging, startup validation, local observability stack
deployment.mdExternal-dataplane deployment checklist, health endpoint caveat, nginx routing, TLS choices, session affinity, Redis availability, image pinning
security.mdTrust boundaries among the control plane, built-in dataplane, and external dataplane; Origin/Host validation; transport security; secrets handling
performance.mdControl-plane Locust load runs, benchmark settings, and built-in-dataplane baseline
testing.mdWorkspace checks, in-repo integration tests, full-stack harness lanes, settings, and control-plane baseline

Quick orientation

  • Repo: contextforge-data-plane — the Rust ContextForge external dataplane.
  • Core invariant: the ContextForge external dataplane is pure routing logic. No IAM, UI, or metrics storage.
  • Protocol target: new external-dataplane behavior targets MCP 2026-07-28 over Streamable HTTP with server/discover and per-request client metadata. The remaining 2025-11-25/initialize paths are temporary compatibility coverage, not an expansion surface. Legacy SSE is outside the external dataplane.
  • Status convention: project, architecture, routing, and operations pages describe the current implementation. The page under Upcoming describes the tentative ContextForge 2.0 target and migration roadmap.
  • Architecture context: architecture.md — read before touching the hot path. Full wiki index above.
  • Validation gate: cargo fmt + cargo clippy + cargo nextest + cargo deny must be clean; CI also runs cargo shear. See preferences.md for by-change-type requirements.
  • System topology: client → nginx → [ContextForge built-in dataplane | ContextForge external dataplane | ContextForge control plane]; external-dataplane config flows from the control plane via dataplane_publisher.py → Redis → external dataplane. See project.md § System topology.

Project Overview

This page describes the current implementation. The tentative product end state and Phase 1-4 migration are documented in ContextForge 2.0 Target Architecture and Roadmap.

What this project is

contextforge-data-plane is the Rust-based ContextForge external dataplane. It is a scalable, separately deployable MCP (Model Context Protocol) gateway that routes AI tool calls from MCP clients to backend MCP servers.

The IBM/mcp-context-forge Python repository contains two different product components: the ContextForge control plane and the ContextForge built-in dataplane. This Rust repository is the third component:

LayerOwns today
ContextForge control plane (Python)IAM, UI, management APIs, durable administrative state, policy/catalog compilation, metrics storage, and external-dataplane configuration publishing.
ContextForge built-in dataplane (Python)MCP request handling shipped in the same repository as the control plane. Supports 2026-07-28 and 2025-11-25, including stateful and stateless behavior.
ContextForge external dataplane (Rust, this repo)Separately deployed MCP request routing and authorization enforcement. The target supports both protocol versions without session state; cross-version adaptation is best effort.

The ContextForge external dataplane must never take on control-plane concerns.

Terminology

Use the full component names in product-wide architecture and deployment documentation:

  • ContextForge control plane means the Python management plane in IBM/mcp-context-forge. It owns administrative workflows and publishes effective runtime configuration; it is not the name for every process or MCP route in that repository.
  • ContextForge built-in dataplane means the Python MCP request path in the same IBM/mcp-context-forge repository. “Built-in” describes where it ships, not a legacy-only or slow-path role. It handles the old and new protocol versions and can serve stateful or stateless clients.
  • ContextForge external dataplane means this independently deployable Rust repository. “External” means external to the Python repository/deployment, not untrusted or third-party. Its target request path is stateless for both supported protocol versions.
  • Stateful means later MCP requests can depend on session context established by initialize or a session identifier. Stateless means every request is independently authenticated, authorized, resolved, and completed without reusable MCP session state.

Always use one of the three canonical names. Do not use unqualified “dataplane,” “local dataplane,” “slow dataplane,” or “fast dataplane” as a product component name.

flowchart LR
    C(["MCP Client\nold/new · stateful/stateless"])

    subgraph Infra["Infrastructure"]
        N["nginx\nTLS termination\nrouting fan-out"]
    end

    subgraph EDP["ContextForge External Dataplane  (Rust, this repo)"]
        direction TB
        MW["Middleware stack\nvirtual host · JWT · session · user config"]
        RT["MCP Routing\nfan-out · prefix namespace\nlist merge · capability merge"]
        PL["Plugin hooks\ncmf.tool_pre_invoke\ncmf.tool_post_invoke\ncmf.prompt_pre_fetch\ncmf.prompt_post_fetch"]
        MW --> RT --> PL
    end

    subgraph PythonRepo["IBM/mcp-context-forge  (Python repo)"]
        direction TB
        CP["ContextForge control plane\nIAM · UI · management"]
        BDP["ContextForge built-in dataplane\nold/new · stateful/stateless"]
        PUB["dataplane_publisher.py\nwrites UserConfig to Redis"]
        CP --> PUB
    end

    R[("Redis\nUserConfig store\nMessagePack")]
    BE["Backend MCP Servers"]

    C --> N
    N -->|"external route - currently 2026-07-28"| EDP
    N -->|"UI / IAM / management"| CP
    N -->|"built-in MCP routes"| BDP
    PUB --> R
    EDP -->|"read-only UserConfig"| R
    EDP -->|"MCP calls"| BE
    BDP -->|"MCP calls"| BE

Goals and objectives

  • Provide a production-grade, low-latency routing layer between MCP clients and backend MCP servers.
  • Support MCP 2026-07-28 and 2025-11-25 over Streamable HTTP as stateless downstream contracts.
  • Enforce a clean ContextForge external dataplane/control plane boundary — no IAM, UI, or metrics storage logic in this repo.
  • Keep config access behind the UserConfigStore abstraction (backed by Redis/MessagePack).
  • Remain in the right architectural shape during early development, prioritising correctness over backward compatibility.

Key stakeholders and users

  • Platform teams — deploy and operate the gateway as infrastructure.
  • AI application developers — use the gateway as the MCP proxy layer for their applications.
  • Internal contributors — engineers evolving the ContextForge external dataplane toward stateless 2026-07-28 and 2025-11-25 protocol support.

Key modules and architecture

Architecture context lives in the wiki. Key pages:

Wiki pageCovers
architecture.mdCrate layout, pipeline shape, state ownership, module boundaries
routing.mdBackend prefix namespace, routing contract, session state, method reference
mcp-capability-allocation.mdTentative ContextForge 2.0 end state, responsibility allocation, and Phase 1-4 roadmap
config.mdJWT validation, config keying, UserConfig shape, cache behavior
security.mdTrust boundaries, invariants, and tradeoffs

Crate ownership

CratePurpose
contextforge-data-plane-libAll ContextForge external-dataplane behavior: routing, middleware, sessions, transports. Almost everything goes here.
contextforge-data-plane (binary)Process shell only: CLI flags, logging, runtime shape. No ContextForge external-dataplane logic.
contextforge-data-plane-apisShared config shapes (UserConfig, User, plugin config). Regenerate JSON schemas after any change: cargo run -p contextforge-data-plane-apis.
contextforge-data-plane-cpexPlugin integration (CPEX hook factories).

Key invariants:

  • Redis/config access goes through UserConfigStore only — never leak Redis details into routing code.
  • The backend prefix naming contract must not change without updating merge logic, split logic, and tests.
  • When behavior on the hot path changes, the matching wiki page must be updated in the same change.

Active work (near-term)

  • Protocol migration: support same-version 2026-07-28 and 2025-11-25 paths over Streamable HTTP, provide best-effort translation in either cross-version direction, and replace stateful session paths with request-scoped handling.
  • Legacy SSE transport and session affinity are being removed from the ContextForge external dataplane. initialize is retained as a stateless compatibility request and must not create persistent external-dataplane or backend session state.
  • Protocol-sensitive tests must cover the two direct and two best-effort cross-version combinations. Modern examples should continue to use server/discover and per-request client metadata; compatibility examples may use initialize without relying on later session reuse.

ContextForge Integration Contract

Provisional. No formal contract has been stipulated yet. This section documents the current de-facto integration surface with IBM/mcp-context-forge. Any row may change while the project is early; when a proper contract is agreed, update this section to track it.

AgreementValue today
Client-facing route/servers/{virtual_host_id}/mcp. Front door rewrites modern MCP 2026-07-28 Streamable HTTP traffic to /contextforge-rs/servers/{virtual_host_id}/mcp on the ContextForge external dataplane.
Protocol compatibilityToday the external-dataplane route accepts MCP 2026-07-28; the built-in dataplane handles 2026-07-28 and 2025-11-25, including stateful and stateless behavior and legacy SSE compatibility. The external-dataplane target handles both supported Streamable HTTP versions statelessly, with cross-version adaptation on a best-effort basis.
Unknown virtual host404 with body {"detail":"Server not found"}, matching the control-plane response shape.
Token issuer and audienceiss = mcpgateway, aud = mcpgateway-api.
Claims shapesub, jti, iss, aud, exp, and user required. token_use, iat, teams, scopes, and user.full_name optional. The ContextForge external dataplane routes on sub only.
User config Redis keyMessagePack(User::new(jwt_subject)) — key type plus subject, not the raw subject string.
User config Redis valueMessagePack(UserConfig). JSON schema at schemas/user_config.json.
User key Redis schemaschemas/user.json.
Plugin config keyContextForgeGatewayRuntimePluginConfig, JSON or MessagePack, version: 1 with a cpex section.

Coordination rule: changing any row above is a cross-repo change. The external dataplane, the control-plane publisher (dataplane_publisher.py), and the cf-integration harness all need updating together.

Regenerate both schemas after any struct change to UserConfig, VirtualHost, BackendMCPGateway, or the User key type:

cargo run -p contextforge-data-plane-apis

System topology (current)

All external traffic enters through nginx, which routes management traffic to the control plane and MCP traffic to either the built-in or external dataplane:

flowchart LR
    client(["client"]) --> nginx["nginx"]
    nginx --> external["external dataplane\nRust · this repo"]
    nginx --> builtin["built-in dataplane\nPython repo"]
    nginx --> control["control plane\nPython repo"]
    external --> redis["redis"]
    control --> redis
    control --> postgres["postgres\n(via pgbouncer)"]
    external --> fastts["fast_time_server"]

How the control plane publishes config to the external dataplane

The control plane and external dataplane do not communicate over HTTP. Config is exchanged exclusively through Redis:

  1. The control plane runs dataplane_publisher.py — a publisher script that writes external-dataplane configuration (user config, backend definitions, etc.) into Redis.
  2. The external dataplane reads that config from Redis via the UserConfigStore abstraction (MessagePack-encoded UserConfig).

This means:

  • The external dataplane is a pure reader of Redis config. It never writes back to the control plane’s Redis keys.
  • The control plane is the sole writer of external-dataplane config; the external dataplane has no direct dependency on the control-plane process at runtime.
  • Config changes from the control plane are picked up by the external dataplane through normal cache refresh / Redis reads — no restart or direct RPC required.

Per-component responsibilities

ComponentRolePersistence
nginxTLS termination, routing fan-out
ContextForge external dataplane (contextforge-data-plane)MCP routing, auth enforcement, and backend calls; current session-backed paths are migration state, while the target is statelessRedis (read-only for config)
ContextForge built-in dataplane (IBM/mcp-context-forge)Python MCP request handling for old/new protocols and stateful/stateless clientsPython repository runtime state and stores
ContextForge control plane (IBM/mcp-context-forge)IAM, UI, management APIs, metrics, and external-dataplane config publishingRedis (write) + PostgreSQL (via pgbouncer)
redisRuntime config store, inter-component pub/sub channelIn-memory + persistence
postgres (via pgbouncer)Control-plane relational storeDurable
fast_time_serverHigh-resolution time source used by the ContextForge external dataplane

External dependencies and integration points

  • Redis — runtime config store (MessagePack-encoded UserConfig). Populated by dataplane_publisher.py on the control plane; read by the external dataplane via UserConfigStore.
  • ContextForge control plane (IBM/mcp-context-forge) — owns management workflows and publishes external-dataplane config via dataplane_publisher.py.
  • ContextForge built-in dataplane (IBM/mcp-context-forge) — owns the Python repository’s MCP request paths, including old/new and stateful/stateless handling. Requests sent there do not route through the external dataplane.
  • fast_time_server — high-resolution time source consumed by the ContextForge external dataplane.
  • Tokio + Axum — fixed async runtime and web framework.

Getting Started

Full Docker Stack

make docker-prod    # build contextforge-data-plane:latest from docker/Dockerfile
make compose-up    # start nginx, Python control/built-in components, Redis, Postgres, external dataplane, fast_time_server

Wait for register_fast_time to finish, then allow ~60s config propagation:

docker compose -f docker/docker-compose.yml logs -f register_fast_time
# Look for: Fast Time Server registration complete!
ResourceURL
MCP endpointhttp://localhost:8080/contextforge-rs/servers/{virtual_host_id}/mcp
Bearer tokenGET http://localhost:8080/contextforge-rs/admin/tokens/admin@example.com
fast_time_server virtual host idb8e3f1a2c4d5e6f7a1b2c3d4e5f6a7b8

Critical: /contextforge-rs prefix → ContextForge external dataplane. Without it, MCP routes reach the ContextForge built-in dataplane (you’ll get {"detail":"..."} from mcpgateway, not an external-dataplane response).

Teardown: make compose-down (stops containers; volumes kept).

cf-integration Conformance

cargo binstall cf-integration@0.1.0 --no-confirm
make conformance

This runs the modern client and modern server eras through the committed external-dataplane HEAD, including fixture-direct server comparison and the scoped client suite. Use make conformance-bless to replace all selected baselines transactionally after a fully successful run. Generated checkouts, results, reports, and logs stay under .integration/.

Local Cargo Dev Workflow

For debugger/profiler/rapid iteration, start Redis and the counter/conformance fixtures:

docker compose -f docker/docker-compose-local.yaml up -d
docker compose -f docker/docker-compose-local.yaml ps redis gateway-one gateway-two
ServiceEndpointRole
redis127.0.0.1:6379Runtime configuration store.
gateway-onehttp://127.0.0.1:5555/mcpMCP Rust SDK counter fixture.
gateway-twohttp://127.0.0.1:5556/mcpMCP Rust SDK conformance fixture.

Run the binary with bootstrap helpers:

cargo run -p contextforge-data-plane \
  --features contextforge-data-plane-lib/with_tools \
  --bin contextforge-data-plane -- \
  --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

The client-facing route is http://127.0.0.1:8001/contextforge-rs/servers/{virtual_host_id}/mcp.

Mint a local test token

USER_ID=11111111-1111-1111-1111-111111111111
TOKEN=$(curl --silent --show-error \
  --url "http://127.0.0.1:8001/contextforge-rs/admin/tokens/${USER_ID}?email=admin@example.com")

Seed runtime configuration

VIRTUAL_HOST_ID=c0ffee00f001f00df00ddeadbeefdead
curl --silent --show-error --request POST \
  --url "http://127.0.0.1:8001/contextforge-rs/admin/userconfigs/${USER_ID}" \
  --header 'content-type: application/json' \
  --data '{
    "virtual_hosts": {
      "c0ffee00f001f00df00ddeadbeefdead": {
        "backends": {
          "gateway-one": {
            "name": "gateway-one",
            "url": "http://127.0.0.1:5555/mcp",
            "passthrough_headers": [], "allowed_tool_names": [],
            "allowed_resource_names": [], "allowed_prompt_names": []
          },
          "gateway-two": {
            "name": "gateway-two",
            "url": "http://127.0.0.1:5556/mcp",
            "passthrough_headers": [], "allowed_tool_names": [],
            "allowed_resource_names": [], "allowed_prompt_names": []
          }
        }
      }
    }
  }'

Verify with mcp-inspector

npx @modelcontextprotocol/inspector
FieldValue
URLhttp://127.0.0.1:8001/contextforge-rs/servers/c0ffee00f001f00df00ddeadbeefdead/mcp
TransportStreamable HTTP
Auth token$TOKEN

Modern protocol probe (server/discover)

curl --silent --show-error \
  --url "http://127.0.0.1:8001/contextforge-rs/servers/${VIRTUAL_HOST_ID}/mcp" \
  --header "authorization: Bearer ${TOKEN}" \
  --header 'content-type: application/json' \
  --header 'accept: application/json, text/event-stream' \
  --header 'mcp-protocol-version: 2026-07-28' \
  --header 'mcp-method: server/discover' \
  --data '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0.1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'

Troubleshooting

SymptomLikely cause
401 UnauthorizedMissing/invalid bearer token, wrong issuer/audience, or expired token.
400 Problem occurred retrieving the configurationRedis has no UserConfig for the token subject. Re-run the config POST.
404 {"detail":"Server not found"}The URL virtual-host id does not exist in the user’s config.
400 mentioning request metadataMCP protocol header and _meta version differ, or client metadata missing.
Backend calls failBackend URL wrong, fixture down, or --upstream-connection-mode rejects plain HTTP.

Architecture

This page describes the current Rust implementation, including temporary backend fan-out and session behavior. The tentative configuration-driven end state is in ContextForge 2.0 Target Architecture and Roadmap.

Middleware Stack Order

Tower layers execute outside-in. A request reaches MCP handlers with these extensions already set:

TCP/TLS listener
  -> HttpMetricsLayer
  -> TraceLayer
  -> /contextforge-rs nested router
  -> mcp_origin_layer          → validates Origin                 (403 when invalid/disallowed)
  -> CORS layer
  -> mcp_header_limits_layer   → MCP standard header budgets      (431 when exceeded)
  -> virtual_host_id_layer       → inserts VirtualHostId           (400 on path mismatch)
  -> claims_layer                → inserts ContextForgeClaims      (401 on bad/missing JWT)
  -> session_id_layer            → inserts SessionId if present
  -> user_config_store_layer     → inserts UserConfig              (400 no config, 500 store error)
  -> virtual_host_config_layer   → rejects unknown vhost           (404 "Server not found")
  -> /servers/{virtual_host_name}/mcp RMCP service → validates Host, then dispatches MCP

DNS-rebinding validation is split by behavior. mcp_origin_layer rejects any present Origin that is malformed or not allowlisted; requests without Origin continue. RMCP validates the optional Host allowlist at the MCP service boundary. See Security. mcp_header_limits_layer rejects excessive MCP standard headers before JWT validation, config lookup, session creation, backend fanout, or RMCP body parsing.

MCP handlers read typed extensions and never parse paths or Redis keys directly. tools/call reads the downstream header map from RMCP’s request-context Parts extension for parameter-header validation.

Pipeline Shape

downstream request
  -> Origin validation → MCP header limits → virtual host extraction → JWT validation → session extraction
  -> user config lookup → RMCP request validation → MCP handler validation
  -> request plugin hooks
  -> backend MCP call (concurrent via join_all for initialize/list)

upstream response
  -> response plugin hooks → merge/namespace/passthrough
  -> metrics, tracing, logging → downstream response
flowchart TD
    bin["binary\nCLI · logging · runtime"]
    lib["lib\nrouting · middleware\nsessions · transports"]
    apis["apis\nUserConfig · VirtualHost\nBackendMCPGateway"]
    cpex["cpex\nCPEX hook factories"]
    bin --> lib
    lib --> apis
    lib --> cpex

Hot-path pipeline (each stage must complete before the next):

flowchart TD
    D(["downstream request"])
    A["virtual host · JWT\nsession extract"]
    C["user config lookup\nRMCP · MCP validate"]
    P1["request plugins\ntool_pre_invoke"]
    B["backend MCP call\njoin_all for init/list"]
    P2["response plugins\ntool_post_invoke"]
    M["merge · namespace\npassthrough"]
    T["metrics · tracing · logging"]
    U(["downstream response"])
    D --> A --> C --> P1 --> B --> P2 --> M --> T --> U

RMCP enforces its configured request-body cap and validates modern standard headers before dispatch. The tools/call handler then resolves the request’s backend and original tool name. When UserConfig contains that tool’s input schema, it validates recognized Mcp-Param-* headers against the request body; it does not call backend tools/list. Without a published schema, parameter headers are unrecognized and forwarded without local validation. Published annotations are validated for MCP token, uniqueness, primitive type, and properties-only reachability constraints. Nested annotations read the exact argument path. Present non-null values require a matching header; absent or null values require no header. Parameter headers are forwarded unchanged; request plugins run afterward, so a plugin that changes an annotated argument also owns any resulting upstream mismatch.

Order is invariant: auth/config before backend selection; request plugins before upstream; response plugins before returning.

Module Boundaries (contextforge-data-plane-lib)

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

State Ownership

StateOwnerLifetime
CLI ConfigBinary startup + GatewayProcess
JWT decodersContextForgeDataPlaneAppStateProcess
User configRedisUserConfigStore (LRU + Redis)Request-path consumed; control-plane authored
Request identity / VirtualHostIdRequest extensionsOne HTTP request
Downstream session idRMCP + SessionId extensionMCP session
Backend RMCP services (initialize, list ops)BackendTransports mapLocal process, per principal/backend/session
Backend RMCP services (call_tool)Per-request connectionSingle HTTP request
Local user session mappingLocalUserSessionStoreLocal LRU, 50k entries, 1 hour
Plugin managerCpexRuntimeRegistryProcess, reloadable

Session rule: backend MCP services are local process state. Sticky routing required for load-balanced deployments.

Executor Shapes

--single-runtimeShape
true (default)One multi-thread Tokio runtime, --number-of-cpus workers. All connections share one BackendTransports.
falseOne OS thread per CPU, each with its own current-thread Tokio runtime and own BackendTransports. SO_REUSEPORT spreads connections — no session affinity. Stateful MCP sessions need --single-runtime true.

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

Multi-runtime consequence: each runtime thread builds its own BackendTransports map and user-session store. Backend session state is per-runtime-thread, and SO_REUSEPORT gives no connection affinity — later requests in a streamable HTTP session can land on a thread that does not own the session. Treat single-runtime as the only mode supporting stateful MCP sessions today.

Lock Design

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

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

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. jemalloc holds up better than the system allocator under the many small, short-lived allocations of per-request JSON and header processing.

Fanout And Cancellation

  • initialize opens one backend transport per configured backend concurrently (futures::future::join_all); a failed backend degrades that backend only.
  • List methods fan out to all connected backends concurrently and merge.
  • Targeted calls (except call_tool) resolve exactly one backend service handle from BackendTransports.
  • Targeted tool, prompt, and resource calls run configured pre/post plugin hooks after backend routing. call_tool creates a fresh per-request backend connection via connect_backend_for_request, then explicitly closes it before returning.
  • call_tool watches the downstream cancellation token and forwards a cancel to the backend if the client gives up first; backend progress notifications are forwarded downstream while the call is in flight.

Resource reads carry a concrete, request-owned hook state across backend I/O. It pins the runtime selected before the read, or records that no post hook was configured. Post processing consumes that state without type erasure, downcasts, or a second registry lookup.

Startup And Response Flow

Startup sequence (main.rsGateway::run_gateway):

install rustls crypto provider
  -> Config::parse()
  -> logging::init_tracing_logging(&config)
  -> Runtime::from(&config)          ← sets executor shape
  -> 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)

Response unwind order (Tower layers execute outside-in, so unwind is inside-out):

backend response
  -> response plugin hooks (tool, prompt, and resource calls)
  -> merge / namespace / pass through
  -> virtual_host_config_layer response side
  -> user_config_store_layer response side
  -> session_id_layer response side  ← on DELETE success: remove session + backend transports
  -> claims_layer response side
  -> virtual_host_id_layer response side
  -> CORS, mcp_origin_layer, TraceLayer, HttpMetricsLayer
  -> downstream response

Flow checkpoints — each must exist before the next dependency runs:

CheckpointFact establishedNext dependency
ListenerRequest reached the ContextForge external dataplane over TCP/TLS.Metrics, tracing, nested routing.
Path extractionInner path matched /servers/{virtual_host_id}/mcp.MCP handlers can resolve a VirtualHost.
Claims validationBearer token accepted; ContextForgeClaims exists.Config lookup can use claims.sub.
User config lookupUserConfig exists for the authenticated subject.Virtual host check can run.
Virtual host checkPath’s virtual host id exists in the caller’s config.MCP validators can resolve the selected VirtualHost.
RMCP dispatchStreamable HTTP request mapped to an MCP method.Handler chooses initialize, routed call, or local behavior.

MCP-First, Not MCP-Only

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

auth → config lookup → transport setup → plugin runtime → telemetry → session strategy

Keep protocol-neutral concerns (auth, config ingestion, TLS handling, plugin execution, telemetry, runtime shape, session strategy) reusable. Future A2A or model-provider routing should reuse the gateway shell without copying the MCP routing stack. MCP-specific behavior must remain isolated to the current MCP modules.

Transport Security Split

Transport security is split across two owners; keep this visible:

ConcernStable ownerExpected evolution
Gateway listener certificateProcess config.Stays process config — it belongs to the listener.
JWT verification keysProcess config.Stays process config.
Backend URL, auth headers, pass-through policy, allowed objectsRuntime user config (BackendMCPGateway).Grows as per-backend policy detail increases.
Backend-specific TLS trust and client identityProcess config today.Should move to runtime config or referenced secret material per backend.

Do not bury transport security decisions inside MCP method handlers. They belong in startup assembly or explicit backend transport construction.

Plugin Hook Expansion Requirements

Current supported hooks cover tool, prompt, and resource pre/post lifecycles. Before adding any new hook point, define all of the following:

RequirementWhy
Failure behaviorDoes a plugin error abort the call, degrade gracefully, or log and continue?
Timeout behaviorWhat happens when a plugin takes too long on the hot path?
Cancellation behaviorCan the downstream cancel propagate through the plugin?
Streaming/SSE behaviorDoes the hook fire once or per-chunk? What is the backpressure model?
Telemetry attributionWhich span/metric owns plugin latency and errors?

Avoid ad hoc plugin calls in routing code. New hook points belong at explicit, documented pipeline positions.

Architecture-Change Follow-Through Matrix

Changing a load-bearing choice requires updating more than one file:

ChangeRequired follow-through
Downstream MCP versionCoordinate with the ContextForge control plane and built-in dataplane; update the 2026-07-28/2025-11-25 compatibility matrix, protocol tests, examples, and front-door routing. The ContextForge built-in dataplane handles both stateful and stateless traffic; the ContextForge external dataplane handles both supported Streamable HTTP versions statelessly.
Backend namespace / prefix contractUpdate merge logic, split logic, tests, docs, and control-plane integration if client-facing surface moves.
Session state moves externalUpdate SessionManager, cleanup behavior, load-balancing docs, and failure-mode tests.
Config transport changesKeep UserConfigStore as the boundary; update adapter tests.
Plugin hook surface expandsDocument ordering, failure, timeout, cancellation, streaming, and telemetry before landing.
New protocol joins the gatewayKeep shared shell protocol-neutral; isolate new protocol-specific routing.

MCP Routing Semantics

The external dataplane is a pure stateless router. No session state, no BackendTransports, no sticky-routing requirement.

How a request is routed

  1. validate_stateless extracts VirtualHost from request extensions (set by virtual_host_config layer from the JWT virtual-host ID).
  2. Downstream name is looked up in VirtualHost::tools, ::resources, or ::prompts — an O(1) table lookup.
  3. connect_backend_for_request opens a fresh StreamableHttpClientTransport, runs the call, closes the connection.

The control plane builds and publishes the routing tables to Redis; the dataplane never derives names at call time.

Routing table shape

VirtualHost { backends: HashMap<String, BackendMCPGateway>,
              tools: HashMap<String, ServiceRoute>,
              resources: HashMap<String, ServiceRoute>,
              resource_templates: HashMap<String, ServiceRoute>,
              prompts: HashMap<String, ServiceRoute> }

ServiceRoute { backend_name: String,   // key into VirtualHost::backends
               upstream_name: String } // name/URI forwarded to the backend

Source: user_store.rs

Method quick reference

MethodBehavior
initialize (2026-07-28)INVALID_REQUEST — not supported by this dataplane.
initialize (legacy)Stub InitializeResult; no backend fanout. Supports older clients during migration.
list_tools, list_resources, list_resource_templates, list_promptsINVALID_REQUEST — delegated to control plane.
call_toolLookup in tools map → pre-hook → fresh connection → call → post-hook → close. Forwards cancellation; tracks progress tokens.
read_resourceLookup in resources map → fresh connection → call with upstream URI → close.
get_promptLookup in prompts map → pre-hook → fresh connection → call → post-hook → close.
subscribe, unsubscribe, completeINVALID_REQUEST — delegated to control plane.
pingLocal success; no backend fanout.
DELETERMCP handles; session_id_layer removes the LocalUserSessionStore entry. No backend state to clean up.

Header forwarding

Applied in order per upstream call: Host (from backend URL, HTTPS only) → passthrough (BackendMCPGateway::passthrough_headers) → Mcp-Param-* auto-forward → trace context → add (add_headers, overrides passthrough) → remove (remove_headers, applied last).

Protected headers that config can never touch: Host, Content-Length, Content-Type, all RFC 7230 hop-by-hop headers, Mcp-Session-Id, Accept, Last-Event-Id, and all computed MCP standard headers (Mcp-Method, Mcp-Name, Mcp-Protocol-Version, Mcp-Param-*).

For clients on ≥ 2026-07-28, call_tool validates Mcp-Param-* headers against BackendMCPGateway::tool_schemas before contacting the backend.

Plugin hooks

call_tool, get_prompt, and read_resource run pre/post hooks when a GatewayPluginRuntimeHandle is configured. Pre-hooks can deny a request or edit tool/prompt arguments or the resource URI. Resource URI edits must resolve through the caller’s published routes. Post-hooks can rewrite or reject the response.

The handle selects a runtime before backend I/O. It returns typed request state whose after_* method runs the post-hook on that same runtime, even after a reload or a reload failure. A request that started without post-hooks never gains one mid-flight. Tool state is shared under a mutex so progress notifications and the final response use the same correlation ID and serialize plugin-context updates. Prompt and resource state is owned by a single request and needs no mutex.

The internal CPEX crate separates these responsibilities:

ModuleOwns
registry/Runtime selection, factory registration, config reloads, and the watcher
runtime.rsManager lifecycle, shared pre/post execution, and request context
tools/, prompts/, resources/Typed request state, operation-specific CMF conversion, and conversion tests
cmf.rsHook inventory, message envelopes, and the CmfResponse conversion trait
hooks.rsShared argument edits and pre-hook results
config.rs, factory.rsConfig decoding/storage and compiled-in plugin factories

Each response adapter implements CmfResponse; the runner handles invocation, unchanged payloads, context propagation, and denial. Conversion and rejection rules remain operation-specific: tools, rendered prompts, and resource reads have different MCP representations. See Config for those contracts.

Security Model

Trust Boundaries

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

Authentication And Authorization

PlaneCurrent responsibility
ContextForge control planeOwns login/SSO, users, teams, IAM, API-token issuance and revocation, and external-dataplane configuration publication. dataplane_publisher.py writes visibility-filtered UserConfig snapshots to Redis by user email.
ContextForge built-in dataplaneOwns the Python repository’s MCP request routes, including old/new protocol and stateful/stateless behavior.
ContextForge external dataplaneHas no IAM or user database. It currently verifies modern MCP bearer JWTs locally, loads UserConfig by sub, and requires the requested virtual host to exist. No runtime control-plane call occurs.

External-dataplane request path: control-plane API token (sub = email) → Origin check → claims_layer → Redis config lookup → virtual-host check → RMCP Host check → MCP routing. Browser/login session tokens are management-plane credentials, not the external-dataplane contract.

  • JWT validation accepts RS256/384/512 or HS256/384/512 and requires a valid signature, iss=mcpgateway, aud=mcpgateway-api, and exp. jti and user are required fields; token_use, iat, teams, and scopes are optional.
  • Failures: bad/missing JWT → 401; no user config → 400; unavailable virtual host → 404.
  • Authorization is currently coarse: valid JWT plus published virtual host. JWT scopes/teams and object allowlists are not enforced; publishing a backend exposes all objects returned by it.
  • This coarse current behavior does not meet the tentative Phase 3 target. The target requires principal- and isolation-bound snapshots, per-request scope and compiled-RBAC enforcement, and default denial for missing or unauthorized entries. See Target Authorization Invariants.
  • External-dataplane requests do not consult the control-plane token blocklist. Revoked tokens pass JWT validation until exp or signing-key rotation/restart. Removing a subject’s config eventually blocks all its tokens after publisher and cache expiry.

What Compromise Means

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

Transport Security

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

MCP Origin and Host Validation

mcp_origin_layer validates Origin before authentication. RMCP validates Host at the MCP service boundary. Together they enforce MCP 2026-07-28 DNS-rebinding protection.

Environment variableDefaultContract
CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTSHost check disabledWhen set, RMCP requires request authority from Host (URI fallback) to match. A portless entry matches any port; an explicit port matches exactly.
CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINSOnly requests without Origin passA present Origin must be a strict serialized origin in the allowlist.

Missing Origin is accepted. null, malformed, unlisted, or path/query/fragment/userinfo-bearing origins are rejected with HTTP 403. Default ports are normalized (https://a equals https://a:443). When the Host allowlist is configured, RMCP returns 400 for a missing or malformed authority and 403 for an unlisted authority. There is no same-origin fallback; configure both allowlists for public deployments.

Host validation runs only after the request reaches the RMCP service. Origin, CORS, authentication, user-config, and virtual-host middleware can return a response first, so the Host-specific 400 and 403 statuses apply only after those earlier stages succeed.

mcp_header_limits_layer enforces configurable count, per-value byte, and approximate request-level aggregate byte budgets for MCP standard request headers before JWT validation or RMCP body parsing. The aggregate budget covers all matched header names and values on one request, while the per-value budget still caps each individual header value. That budget covers Mcp-Method, Mcp-Name, Mcp-Protocol-Version, and Mcp-Param-*; the same guardrail also covers the legacy/RMCP transport header Mcp-Session-Id. It is an application-level guard for MCP-related headers only; non-MCP headers remain bounded by the HTTP transport.

Backend header policy cannot add, remove, or replace MCP standard or parameter headers. For modern tools/call, the dataplane resolves the authenticated user, virtual host, backend, and original tool name before validating recognized Mcp-Param-* against the control-plane-published input schema. A recognized missing, malformed, unexpected, conflicting repeated, or mismatched header fails closed with JSON-RPC -32020. Schema annotations also fail closed unless their names are non-empty, case-insensitively unique HTTP tokens, their properties have an allowed primitive type, and their paths are statically reachable through properties only. Nested values are checked at their exact path, and integers must remain in the IEEE 754 safe range. When no schema is published, parameter headers are unrecognized and forwarded without local validation; their absence does not block the tool call. Validation does not call backend tools/list. Parameter values are forwarded unchanged, while RMCP regenerates method, routed-name, and protocol-version headers. If a plugin later changes an annotated argument, the original header remains and the upstream server may reject the mismatch.

Local Bootstrap Helpers (with_tools)

The contextforge-data-plane-lib/with_tools feature compiles in:

  • /contextforge-rs/admin/tokens/{user}
  • /contextforge-rs/admin/userconfigs/{user}
  • /contextforge-rs/health

These routes are registered outside the authentication middleware — 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

  • JWT validation keys are fetched from a remote JWKS endpoint; TLS certificate material is read from disk paths at startup.
  • Never log: tokens, authorization headers, secrets, Redis key/value bytes, full UserConfig documents, or backend credentials.

Failure Modes

Rule: failures come from the layer that owns the missing fact. Identity/config failures are HTTP responses before MCP handling; routing/backend failures are JSON-RPC errors.

HTTP Layer (middleware, before MCP)

FailureResponseLayer
Path doesn’t match /servers/{id}/mcp400virtual_host_id_layer
Missing Authorization / non-Bearer scheme401claims_layer
JWT undecoded, unsupported algorithm, no key401claims_layer
Expired token, wrong issuer/audience401claims_layer
No user config for claims.sub, or claims absent400user_config_store_layer
Config store error (not missing)500user_config_store_layer
Virtual host id absent from caller’s config404 {"detail":"Server not found"}virtual_host_config_layer

MCP Validation (defense-in-depth, normally unreachable)

FailureJSON-RPC error
Missing session id / config / vhost / claims extensionInternal error (Routing problem...)
Virtual host absent from user configRESOURCE_NOT_FOUND No configuration

Routing

FailureBehavior
Prefixed name doesn’t start with backend name + -Internal error
No backend entry matches split nameInternal error (got no responses from backends)
Backend entry exists but no running serviceInternal error (backend failed during initialize)
More than one backend entry matchesINVALID_REQUEST; session backend entries cleaned up
Undecodable pagination cursor-32602 Invalid params

Backend Session

SituationBehavior
Backend unreachable during initializeStored with no running service; initialize still succeeds
Backend unreachable during routed callCall returns internal error; other backends unaffected
Gateway process restartAll session state lost; clients must re-run initialize
Request lands on wrong gateway nodeList returns empty; routed calls fail — need sticky routing

Plugins

FailureBehavior
Plugin denies call/responseBecomes MCP error to caller
Soft plugin errorLogged; call proceeds
Invalid plugin config on reloadRuntime marked failed; plugin calls return internal MCP error until valid config applied

Config Store (Redis)

FailureBehavior
Redis connection lossConnection manager retries (1,000 configured)
User config missing400 from user_config_store_layer
Redis GET errorReported as missing → 400
Undecodable config / key encoding failure500

ContextForge 2.0 Target Architecture and Roadmap

Tentative target: this page records the proposed ContextForge 2.0 end state and delivery phases. It is not a description of the current Rust implementation. See Architecture and MCP Routing Semantics for current behavior.

This is a product-wide view because the ContextForge external dataplane boundary depends on work owned by the ContextForge control plane and built-in dataplane in the Python IBM/mcp-context-forge repository. It does not move control-plane or built-in-dataplane responsibilities into this repository. See Project terminology for the canonical component names.

Vision and Constraints

  • ContextForge supports MCP 2026-07-28 and 2025-11-25 over Streamable HTTP on both the client-facing and backend-facing sides of the ContextForge external dataplane.
  • Same-version client/backend paths are supported directly. Cross-version 2026-07-282025-11-25 and 2025-11-252026-07-28 adaptation is best effort.
  • All external-dataplane request/response handling is stateless for both versions. The target request path does not depend on an MCP session, session affinity, or a retained backend transport.
  • initialize remains supported for compatibility, but it is a stateless request: the external dataplane generates its response from effective configuration and does not use it to establish state required by later requests.
  • Legacy SSE transport is not part of the external-dataplane target.
  • Fan-out and other one-to-many MCP work is limited to the control plane. The built-in and external dataplanes generate discovery, capability, and list responses from control-plane-authored effective configuration.
  • Effective configuration flows one way from the control plane to the built-in and external dataplanes through externally shared state. A process-local cache may speed reads but is never the source of truth.
  • MCP subscriptions and notifications remain Phase 4 work.

Stateless Protocol Compatibility

IBM/mcp-context-forge issue #6327 tracks the first targeted-operation slice for tools/call. The issue calls the incoming/client-facing side “upstream” and the selected backend-facing side “downstream”; this wiki uses the explicit names below.

Incoming clientSelected backendTarget behavior
2026-07-282026-07-28Supported directly as one stateless request.
2026-07-282025-11-25Best-effort protocol adaptation within one stateless request.
2025-11-252026-07-28Best-effort protocol adaptation within one stateless request.
2025-11-252025-11-25Supported directly as one stateless request.

For every row, the external dataplane authenticates and authorizes the request, reads the principal-bound effective configuration, validates that the requested object is visible and permitted, resolves exactly one backend, adapts the protocol when necessary, and closes the request-scoped backend connection after the response. A client may call initialize, but later operations neither require nor reuse state created by it.

“Best effort” never permits hidden session state. If a semantic difference or backend requirement cannot be handled within the current request, the external dataplane returns an explicit error instead of creating affinity or retaining a backend transport for a later request.

For the initial tools/call slice, issue #6327 assumes that the selected backend needs neither application authentication nor mTLS and that its server certificate chains to the system CA. Those are issue-scope assumptions, not a change to the external dataplane’s broader transport-security model.

Target End State

The front door separates management traffic from MCP traffic and chooses the built-in or external dataplane by deployment route and session model, not only by protocol version. The built-in dataplane can handle either supported version in stateful or stateless mode. The external dataplane can handle either supported version only in stateless mode. PostgreSQL remains the durable management store; the shared runtime store carries compiled configuration to both dataplanes.

flowchart TB
    subgraph Clients[Traffic]
        direction LR
        AdminClient([Admin or User])
        CompatClient([MCP 2025-11-25 Client])
        ModernClient([MCP 2026-07-28 Client])
    end

    FrontDoor[Load Balancer and Router]

    subgraph ContextForge[ContextForge 2.0]
        direction LR
        subgraph PythonRepo[IBM mcp-context-forge Python Repository]
            direction TB
            Control[ContextForge Control Plane]
            Builtin[ContextForge Built-In Dataplane]
        end
        External[ContextForge External Dataplane - Rust]
    end

    Postgres[(PostgreSQL Management State)]
    RuntimeStore[(Shared Effective Configuration)]
    Upstreams[MCP 2026-07-28 and 2025-11-25 Servers]

    AdminClient -->|Management API| FrontDoor
    CompatClient -->|Streamable HTTP MCP 2025-11-25| FrontDoor
    ModernClient -->|Streamable HTTP MCP 2026-07-28| FrontDoor

    FrontDoor -->|Management routes| Control
    FrontDoor -->|Stateful or built-in MCP routes| Builtin
    FrontDoor -->|Stateless external MCP routes| External

    Control -->|Persist administrative state| Postgres
    Control -->|Publish effective configuration| RuntimeStore
    RuntimeStore -->|Read shared configuration| Builtin
    RuntimeStore -->|Read-only configuration| External

    Control -->|Discover catalogs and poll liveness| Upstreams
    Builtin -->|Stateful or stateless MCP calls| Upstreams
    External -->|Stateless targeted MCP calls| Upstreams

Redis is the current external-dataplane configuration store and the preferred shared implementation. The built-in dataplane may consume the same compiled configuration from Redis or PostgreSQL. When multiple built-in-dataplane instances are deployed, stateful MCP behavior requires an explicit shared-state or affinity design; stateless behavior must not rely on process memory.

Component Responsibilities

ComponentTarget responsibility
Front doorRoute management APIs to the ContextForge control plane. Route MCP to the built-in dataplane when the built-in route or stateful behavior is required, and to the external dataplane when the configured stateless external route is selected. Protocol version alone does not identify the component.
ContextForge control planeManage the virtual-server lifecycle and upstream assignments; connect to heterogeneous upstreams; retrieve and page through capabilities, tools, resources, prompts, completions, and other catalogs; normalize and persist them; let administrators select exposed objects and rules; compile effective runtime configuration; poll upstream liveness and changes.
PostgreSQLPersist administrative source data such as virtual servers, upstream definitions, normalized catalogs, selections, and policies. It is not on the external-dataplane request path.
Configuration synchronizationPublish effective configuration one way from the control plane to externally shared state. The built-in and external dataplanes should consume the same shape where practical.
ContextForge built-in dataplaneHandle 2026-07-28 and 2025-11-25 MCP requests in Python, including stateful and stateless behavior. It is the MCP request path shipped in the same repository as the control plane, not the control plane itself.
ContextForge external dataplaneHandle 2026-07-28 and 2025-11-25 Streamable HTTP requests statelessly in Rust. Read effective configuration, serve aggregate and initialize responses locally, and route a targeted method to exactly one selected backend. Cross-version adaptation is best effort. It does not own IAM, UI, management APIs, or durable metrics storage.
Backend MCP serversMay use 2026-07-28 or 2025-11-25, independently of the incoming client version. Connections and any required negotiation are request-scoped and leave no reusable session; the architecture does not require backend session affinity.

Administrative State and Effective Configuration

The control plane owns two distinct forms of state:

StateContentsOwner and consumers
Administrative source stateVirtual servers, upstream registrations, raw and normalized catalogs, exposure selections, policies, and liveness.Written by the control plane to PostgreSQL; used by management workflows and reconciliation.
Effective runtime configurationEffective server identity and capabilities, visible tools/resources/prompts/completions, downstream paging material, backend resolution, required scopes/roles, and applicable runtime policy for a tenant or isolation domain, user, team, or other principal.Compiled and published by the control plane; read by the built-in and external dataplanes.

The control plane must exhaust upstream pagination while reconciling catalogs. The compiled snapshot must contain enough information for either the built-in or external dataplane to produce downstream paging without contacting every upstream. Publication must be atomic or revisioned so neither the built-in nor external dataplane combines partial catalog and policy state.

Target Authorization Invariants

The effective-configuration model requires identity isolation as well as catalog precomputation. A cached snapshot is data, not an authorization grant. Every downstream request must independently establish and enforce its trusted authorization context.

  • The external dataplane derives the authorization key only from verified JWT claims and the validated server route. MCP params and client metadata must not supply or override a principal, team, tenant, virtual server, backend, or cache key.
  • Snapshot and cache partitions include the applicable trust or tenant boundary, authenticated sub, effective team or other principal, virtual server, and configuration revision. Entries must never be reused across authorization contexts.
  • The control plane maps verified identity attributes to an effective principal and compiles its visible objects and RBAC policy. The built-in and external dataplanes enforce required token scopes or roles and the compiled policy on every discovery, list, and targeted operation.
  • Missing, unmapped, ambiguous, expired, or unauthorized snapshots and objects are denied by default. A targeted denial makes no upstream call, and errors must not disclose another principal’s catalog or backend mapping.
  • The exact tenant/team claim mapping and token-scope-to-RBAC rules are a cross-repository contract that the control plane, publisher, schemas, external dataplane, and integration tests must define together. The current coarse sub-only implementation is not the Phase 3 target.

MCP Work Allocation

WorkTarget owner and behavior
Virtual-server creation and upstream assignmentControl plane persists management state and connects to assigned upstreams.
Upstream discovery, initialization where required, catalog pagination, capability aggregation, filtering, and liveness pollingControl plane only; this is the intentional fan-out boundary.
server/discover, initialize, and effective capabilitiesAfter per-request authorization, the built-in or external dataplane generates the response from principal-bound effective configuration. The built-in dataplane may support a stateful flow; the external dataplane treats initialize as stateless compatibility and creates no state required by later requests.
tools/list, resources/list, prompts/list, resource-template listing, and similar aggregate methodsAfter method-scope and compiled-RBAC enforcement, the built-in or external dataplane generates the visible response from principal-bound effective configuration with no live upstream fan-out.
tools/call, resources/read, prompts/get, completion, and similar targeted methodsThe built-in or external dataplane resolves the effective entry under the trusted authorization key, applies default-deny scope and object policy, and calls exactly one selected backend only when authorized. The external dataplane adapts protocol versions when necessary and leaves no reusable session; the built-in dataplane may use its stateful or stateless execution model.
Plugins for trusted aggregate responsesPrefer policy compiled by the control plane; avoid mandatory per-request plugin calls for a response already produced from trusted effective configuration.
Plugins for targeted callsMay run on the external-dataplane request path when request or response inspection is required. Exact hook allocation remains an implementation decision.
Subscriptions, server notifications, and downstream list-change notificationsDeferred to Phase 4 because their state and delivery model do not fit the request/response simplification.

Delivery Roadmap

PhaseScope
1. Separate control-plane and built-in-dataplane responsibilitiesEstablish a clear boundary between the ContextForge control plane and built-in dataplane inside the Python repository. The control plane writes effective configuration per user, team, or other principal to shared state; the built-in dataplane reads it and handles MCP requests.
2. Route targeted calls through the external dataplaneMake the built-in and external dataplanes follow the same configuration-driven contract. Send selected targeted operations such as tools/call, resources/read, prompts/get, and completion to the external dataplane. For each operation, support both same-version 2026-07-28/2025-11-25 paths and attempt both cross-version paths on a best-effort basis, always without reusable session state. The tools/call slice is tracked by #6327.
3. Route all stateless request/response MCP methods through the external dataplaneServe discovery, stateless initialize, capabilities, aggregate lists, and targeted calls for both supported protocol versions from the external dataplane. Aggregate responses come from effective configuration; targeted calls reach exactly one backend. The built-in dataplane continues to support both stateful and stateless behavior.
4. Implement subscriptions and notificationsAdd the state, routing, and delivery model for upstream subscriptions, resource notifications, and list-change notifications after the request/response architecture is complete.

Phase 3 Reference Flows

The examples below use tools, but the same ownership applies to resources, prompts, completions, and other aggregate or targeted request/response methods. “Supported MCP client” and “supported MCP server” mean either 2026-07-28 or 2025-11-25; when the two sides differ, adaptation is best effort.

1. Create a Virtual Server and Select Capabilities

sequenceDiagram
    autonumber
    actor User
    participant UI as Admin UI or API
    participant CP as ContextForge Control Plane
    participant DB as Control Plane DB
    participant MCP1 as MCP 2026-07-28 Server
    participant MCP2 as MCP 2025-11-25 Server
    participant Store as Shared Config Store (Redis)
    participant DP as ContextForge External Dataplane

    User->>UI: Create virtual server
    UI->>CP: Submit virtual server
    CP->>DB: Store virtual server

    User->>UI: Assign MCP Server 1 and MCP Server 2
    UI->>CP: Update backend associations
    CP->>DB: Store backend associations

    par Inspect 2026-07-28 backend
        CP->>MCP1: Discover capabilities and retrieve catalogs
        MCP1-->>CP: Capabilities and catalog
    and Inspect 2025-11-25 backend
        CP->>MCP2: Initialize or discover and retrieve catalogs
        MCP2-->>CP: Capabilities and catalog
    end

    CP->>DB: Reconcile normalized catalog
    User->>UI: View available catalog entries
    UI->>CP: Request reconciled catalog
    CP->>DB: Read catalog
    DB-->>CP: inc, sum, dec, diff
    CP-->>UI: Display available catalog entries

    User->>UI: Allow inc and sum
    UI->>CP: Update virtual server policy
    CP->>DB: Store selected tools and policy

    CP->>CP: Compile snapshot by tenant, principal and vhost
    CP->>Store: Atomically publish revision N
    Store-->>DP: Configuration revision available
    DP->>Store: Load revision N
    DP->>DP: Replace local cache atomically

    Note over CP,MCP2: Control Plane handles upstream protocol and pagination
    Note over CP,DP: Effective configuration flows one way from CP to DP
    Note over CP,DP: Snapshot carries compiled scopes, RBAC and visible objects

2. Initialize or Discover the Server and List Tools

sequenceDiagram
    autonumber
    participant Client as Supported MCP Client
    participant Ingress
    participant DP as ContextForge External Dataplane
    participant Cache as Local Cache
    participant Store as Shared Config Store (Redis)

    Client->>Ingress: initialize or server/discover
    Ingress->>DP: Forward supported MCP request
    DP->>DP: Verify JWT, metadata and server route
    DP->>DP: Derive authorization key from trusted context
    DP->>Cache: Get snapshot by authorization key

    alt Snapshot available
        Cache-->>DP: Snapshot revision N
    else Snapshot missing or expired
        Cache-->>DP: Cache miss
        DP->>Store: Read by authorization key
        Store-->>DP: Snapshot revision N or not found
        opt Authorized snapshot returned
            DP->>Cache: Store under authorization key
        end
    end

    DP->>DP: Enforce discovery scope and compiled RBAC
    alt Snapshot mapped and authorized
        DP-->>Client: Version-appropriate identity and visible capabilities
    else Missing, unmapped or denied
        DP-->>Client: Authorization error without catalog details
    end

    Client->>Ingress: tools/list as independent request
    Ingress->>DP: Forward supported MCP request
    DP->>DP: Reverify and derive authorization key
    DP->>DP: Enforce tools/list scope and compiled RBAC
    alt Snapshot mapped and authorized
        DP->>Cache: Read visible tools by authorization key
        Cache-->>DP: inc and sum
        DP-->>Client: tools/list result
    else Missing, unmapped or denied
        DP-->>Client: Authorization error without catalog details
    end

    Note over DP,Store: The shared store distributes compiled state
    Note over DP: No live upstream call for discovery or aggregate lists
    Note over Client,DP: initialize does not create required session state
    Note over Client,DP: Client-supplied identity or routing metadata is untrusted

3. Call a Tool

sequenceDiagram
    autonumber
    participant Client as Supported MCP Client
    participant Ingress
    participant DP as ContextForge External Dataplane
    participant Cache as Local Cache
    participant CPEX as Policy and CPEX
    participant MCP as Selected Supported MCP Server

    Client->>Ingress: tools/call name inc
    Ingress->>DP: Forward supported MCP request
    DP->>DP: Verify JWT, metadata and server route
    DP->>DP: Derive authorization key from trusted context
    DP->>Cache: Resolve inc under authorization key
    Cache-->>DP: Backend mapping, protocol version and policy or missing
    DP->>DP: Enforce tools/call scope and compiled RBAC

    alt Tool mapped and authorized
        DP->>CPEX: Run pre-call policy
        CPEX-->>DP: Allow or modify request
        DP->>DP: Adapt client protocol to backend protocol
        opt Backend negotiation is required
            DP->>MCP: Request-scoped initialize
            MCP-->>DP: Initialize result
        end
        DP->>MCP: tools/call name inc
        MCP-->>DP: Tool result
        DP->>MCP: Close request-scoped connection
        DP->>CPEX: Run post-call policy
        CPEX-->>DP: Allow or modify result
        DP-->>Client: Return version-appropriate tool result
    else Missing, unmapped or denied
        DP-->>Client: Authorization error with no upstream call
    end

    Note over DP,MCP: Exactly one backend is called
    Note over DP,MCP: Client and backend versions are independently 2026-07-28 or 2025-11-25
    Note over DP,MCP: No durable backend MCP session is required
    Note over DP: Control Plane, DB and Redis are not on this result path
    Note over Client,DP: Client-supplied identity or backend selection is untrusted

4. Reconcile an Upstream Catalog Change

sequenceDiagram
    autonumber
    participant MCP as Supported MCP Server
    participant CP as Control Plane Reconciler
    participant DB as Control Plane DB
    participant Store as Shared Config Store (Redis)
    participant DP as ContextForge External Dataplane
    participant Client as Supported MCP Client

    CP->>MCP: Poll liveness and refresh discovery and lists
    MCP-->>CP: Updated catalog
    CP->>DB: Reconcile catalog changes
    CP->>CP: Recompile affected snapshots
    CP->>Store: Atomically publish revision N plus 1

    Store-->>DP: Configuration revision available
    DP->>Store: Load revision N plus 1
    DP->>DP: Replace local cache atomically

    Client->>DP: tools/list
    DP-->>Client: Updated list from local snapshot

    Note over DP,Client: Phase 4 owns MCP list-change notifications

Configuration Reference

Minimum Required Flags

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

Plus at least: --address or --tls-address, --token-verification-public-key or --token-verification-secret.

Complete CLI and Environment Reference

The binary parses both CLI flags and environment variables with clap; a CLI flag wins when both forms are supplied. Use the binary for the always-current generated reference:

cargo run -p contextforge-data-plane --bin contextforge-data-plane -- --help

Most environment variables use the CONTEXTFORGE_DATA_PLANE_ prefix. The MCP Origin and Host settings retain the explicitly configured CONTEXTFORGE_GATEWAY_RS_ names shown below.

Listeners and JWT

FlagEnvironment variableDefault / requirementPurpose
--address <host:port>CONTEXTFORGE_DATA_PLANE_ADDRESSOptionalPlain HTTP listener.
--tls-address <host:port>CONTEXTFORGE_DATA_PLANE_TLS_ADDRESSOptionalTLS listener; requires server certificate and key.
--server-certificate <path>CONTEXTFORGE_DATA_PLANE_TLS_SERVER_CERTIFICATEWith --tls-addressPEM certificate chain for downstream TLS.
--server-private-key <path>CONTEXTFORGE_DATA_PLANE_TLS_SERVER_PRIVATE_KEYWith --tls-addressPEM private key for downstream TLS.
--token-verification-public-key <path>CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PUBLIC_KEYFor RSA tokensVerifies RS256, RS384, and RS512 tokens.
--token-verification-secret <secret>CONTEXTFORGE_DATA_PLANE_TOKEN_SECRETFor HMAC tokensVerifies HS256, HS384, and HS512 tokens.
--token-verification-private-key <path>CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEYRequired when built with with_toolsSigns tokens for the optional local bootstrap helper.

MCP request validation

FlagEnvironment variableDefaultPurpose
--mcp-allowed-origins <origin,...>CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINSNoneBrowser Origin allowlist. Without it, requests lacking Origin pass and every request carrying Origin receives HTTP 403.
--mcp-allowed-hosts <authority,...>CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTSNoneOptional RMCP request-authority allowlist. For requests that reach the RMCP service, missing or malformed authorities receive HTTP 400; unlisted authorities receive HTTP 403. Earlier middleware may return first.
--mcp-standard-header-max-count <n>CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_COUNT32Maximum MCP standard headers accepted on one request.
--mcp-standard-header-max-value-bytes <n>CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_VALUE_BYTES8192Maximum byte length accepted for one MCP standard header value.
--mcp-standard-header-max-total-bytes <n>CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES65536Approximate request-level aggregate bytes across all matched MCP standard header names and values.

Values are comma-separated. Origin entries must be fully qualified serialized origins such as https://app.example.com; Host entries are authorities such as gateway.example.com or gateway.example.com:8443. See Security. The MCP standard header limits apply to Mcp-Method, Mcp-Name, Mcp-Protocol-Version, and Mcp-Param-*. The same guardrail also covers the legacy/RMCP transport header Mcp-Session-Id. A configured value of 0 is treated as the documented default. The byte totals are application-level aggregate budgets based on all matched header name and value lengths on one request; they do not allow a single oversized value, which is still capped by --mcp-standard-header-max-value-bytes. They are not exact wire-size accounting and do not model HTTP/2 header compression. Non-MCP headers remain bounded by the HTTP transport.

Redis

FlagEnvironment variableDefault / requirementPurpose
--redis-address <host>CONTEXTFORGE_DATA_PLANE_REDIS_HOSTNAMERequiredRedis host name or IP.
--redis-port <port>CONTEXTFORGE_DATA_PLANE_REDIS_PORTRequiredRedis port.
--redis-mode <mode>CONTEXTFORGE_DATA_PLANE_REDIS_CONNECTION_MODERequiredplain-text, tls, or mtls.
--redis-tls-trust-bundle <path>CONTEXTFORGE_DATA_PLANE_REDIS_TLS_REDIS_TRUST_BUNDLETLS and mTLSPEM trust bundle.
--redis-tls-client-certificate <path>CONTEXTFORGE_DATA_PLANE_REDIS_TLS_REDIS_CLIENT_CERTIFICATEmTLSPEM client certificate.
--redis-tls-client-private-key <path>CONTEXTFORGE_DATA_PLANE_REDIS_TLS_REDIS_CLIENT_PRIVATE_KEYmTLSPEM client private key.
--user-config-cache-expiry-seconds <n>CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS60In-process cache expiry; 0 reads Redis on every request.

Upstream connections

FlagEnvironment variableDefault / requirementPurpose
--upstream-connection-mode <mode>CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODEtls-onlyPermits HTTPS only, HTTP and HTTPS, or an mTLS mode.
--upstream-trust-bundle <path>CONTEXTFORGE_DATA_PLANE_TLS_UPSTREAM_TRUST_BUNDLEOptionalAdditional PEM trust bundle for HTTPS backends.
--upstream-certificate <path>CONTEXTFORGE_DATA_PLANE_TLS_UPSTREAM_CERTIFICATEmTLS modesPEM client certificate.
--upstream-private-key <path>CONTEXTFORGE_DATA_PLANE_TLS_UPSTREAM_PRIVATE_KEYmTLS modesPEM client private key.

Runtime and plugins

FlagEnvironment variableDefaultPurpose
--number-of-cpus <n>CONTEXTFORGE_DATA_PLANE_NUMBER_OF_CPUSHost CPU countTokio worker/runtime thread count.
--single-runtime <bool>CONTEXTFORGE_DATA_PLANE_SINGLE_RUNTIMEtruefalse creates per-CPU runtimes without session affinity.
--runtime-plugins-enabled <bool>CONTEXTFORGE_DATA_PLANE_RUNTIME_PLUGINS_ENABLEDfalseEnables compiled-in CPEX hooks and Redis plugin config loading.

Telemetry and logging

FlagEnvironment variableDefaultPurpose
--enable-open-telemetry <bool>CONTEXTFORGE_DATA_PLANE_ENABLE_OPEN_TELEMETRYfalseEnables OTLP trace export.
--enable-otel-metrics <bool>CONTEXTFORGE_DATA_PLANE_ENABLE_OTEL_METRICSfalseEnables OTLP HTTP-server metric export.
--otlp-protocol <protocol>CONTEXTFORGE_DATA_PLANE_OTEL_EXPORTER_OTLP_PROTOCOLgrpcgrpc or http-protobuf.
--otlp-endpoint <uri>CONTEXTFORGE_DATA_PLANE_OTEL_EXPORTER_OTLP_ENDPOINTProtocol-specificTrace endpoint; defaults to http://127.0.0.1:4317 for gRPC or http://127.0.0.1:4318/v1/traces for HTTP.
--otlp-metrics-endpoint <uri>CONTEXTFORGE_DATA_PLANE_OTEL_EXPORTER_OTLP_METRICS_ENDPOINTProtocol-specificMetrics endpoint; defaults to http://127.0.0.1:4317 for gRPC or http://127.0.0.1:4318/v1/metrics for HTTP.
--otlp-headers <headers>CONTEXTFORGE_DATA_PLANE_OTEL_EXPORTER_OTLP_HEADERSNoneComma-separated key=value exporter headers.
--otlp-service-name <name>CONTEXTFORGE_DATA_PLANE_OTEL_SERVICE_NAMECONTEXTFORGE-DATA-PLANEOpenTelemetry service.name.
--log-name <name>CONTEXTFORGE_DATA_PLANE_LOG_NAMEcontextforge-data-plane.logFile log name in the current directory.
--log-rotation <mode>CONTEXTFORGE_DATA_PLANE_LOG_ROTATIONhourlyminutely, hourly, daily, or never.

JWT Claims (validated by claims_layer)

ClaimRequired value
issmcpgateway
audmcpgateway-api
exppresent, not expired
sub→ selects Redis user config key

Optional: token_use, iat, teams, scopes, user.full_name.

No revocation: a leaked token is valid until exp. Rotate the signing key and restart to invalidate all outstanding tokens.

UserConfig Shape (from contextforge-data-plane-apis)

UserConfig
  virtual_hosts: HashMap<String, VirtualHost>

VirtualHost
  backends: HashMap<String, BackendMCPGateway>   ← map key = routing prefix

BackendMCPGateway
  name: String
  url: Url
  passthrough_headers: Vec<String>                ← snapshotted at initialize; session-scoped
  add_headers: HashMap<String, String>            ← injected after passthrough
  remove_headers: Vec<String>                     ← stripped after add
  allowed_tool_names: Vec<String>                 ← model exists, NOT currently enforced
  tool_schemas: HashMap<String, JsonObject>        ← optional, defaults to {}; upstream name → input schema
  tool_name_aliases: HashMap<String, String>      ← downstream_alias → upstream_original
  allowed_resource_names: Vec<String>             ← model exists, NOT currently enforced
  allowed_prompt_names: Vec<String>               ← model exists, NOT currently enforced

tool_schemas lets the dataplane recognize and validate x-mcp-header annotations without calling backend tools/list. The control plane may omit the field or individual unannotated tools. Without a published schema, parameter headers are forwarded as unrecognized intermediary headers and are not locally validated. A published annotation must name a non-empty, case-insensitively unique HTTP token on a string, integer, or boolean property reachable from the schema root through properties keys only. Nested properties use their exact property path. For a recognized annotation, a non-null argument requires an equal header; an absent or null argument requires the header to be absent. Integer values are limited to the IEEE 754 safe range.

Header apply order: passthrough_headersadd_headers (override passthrough) → remove_headers (applied last).

passthrough_headers is session-scoped. Values are snapshotted from the initialize request and baked into the backend transport for the session lifetime. Post-initialize calls (tool calls, list calls) reuse those headers. Request-scoped propagation requires per-request transport reconstruction (future work).

Protected headers — silently skipped in all three phases (passthrough/add/remove):

CategoryHeaders
Body-framingContent-Length, Content-Type
Hop-by-hopConnection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, Proxy-Connection, TE, Trailer, Trailers, Transfer-Encoding, Upgrade
RMCP-reservedMcp-Session-Id, Accept, Last-Event-Id
Gateway-managedHost (set from backend URL host + port; never overridden by config)
MCP standardMcp-Method, Mcp-Name, Mcp-Protocol-Version, Mcp-Param-*

Authorization and Cookie are not protected here because backend authentication through passthrough_headers or add_headers is intentional runtime configuration.

Redis storage: MessagePack(User::new(sub))MessagePack(UserConfig).

Two schemas are generated — both must be regenerated and committed when UserConfig, VirtualHost, BackendMCPGateway, or the User key type changes:

Schema fileCovers
schemas/user_config.jsonUserConfig routing document written to Redis.
schemas/user.jsonUser key type used as the Redis key.
cargo run -p contextforge-data-plane-apis

Plugin Config (Redis key: ContextForgeGatewayRuntimePluginConfig)

RuntimePluginConfigDocument
  version: 1
  cpex: CpexConfig

Supported: tool, prompt, and resource pre/post CMF hooks. Rejected: routing-based selection, plugin dirs, global policies, LLM hooks, plugin conditions. Config validation and CmfPluginFactory registration must agree on that list: a hook accepted by validation but not registered leaves the plugin loaded and silently inert. Reload watcher: 10-minute interval. Invalid reload → runtime marked failed.

Tool Call Hook 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, or deny the call.

After the upstream backend returns, the post hook can leave the result unchanged, rewrite the result payload, or 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.

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.

Prompt Fetch Hook Behavior

For get_prompt, the pre hook runs after backend routing, so the plugin sees the backend-local prompt name and the owning backend separately rather than the gateway-prefixed identifier. It can leave the arguments unchanged, replace them, or deny the fetch before the backend renders anything.

The post hook receives the rendered prompt as one CMF message per rendered MCP message, each carrying its role and its content block: text, image, audio, embedded resource, or resource link. A plugin can inspect or rewrite any of them, so a policy can act on a file interpolated into a prompt rather than only on the surrounding text.

Writing plugin edits back follows three rules:

  • A message the plugin left unchanged is returned exactly as the backend sent it, so annotations, _meta, and binary resource blobs survive untouched.
  • A message the plugin changed is rebuilt from CMF. CMF does not model MCP annotations or _meta, so an edited message loses them.
  • Edits that cannot be applied faithfully fail the call rather than falling back to the backend’s original. A changed message count, anything other than exactly one prompt result in the payload, a role MCP prompts cannot express, or a resource whose text the plugin removed all return an error. Silently restoring the backend’s content would undo a redaction.

MCP prompt results carry no error flag, so a plugin setting is_error on the CMF prompt result is rejecting the prompt rather than describing it. The gateway turns that into an MCP error carrying the plugin’s error_message, and the rendered content never reaches the client. This differs from tools, where is_error is a field on CallToolResult and is forwarded as a successful response.

Binary resources embedded in prompts reach plugins by URI and MIME type but not by content. A plugin can deny such a message; editing one fails the write-back. Resource-read hooks below have their own binary conversion.

Resource Read Hook Behavior

For resources/read, the pre hook receives the canonical backend-local URI and may allow, deny or rewrite it. A rewritten URI must resolve unambiguously through the caller’s published virtual-host resources before a backend connection is opened. Aliases for the same backend target do not create ambiguity.

The post hook may replace each returned resource’s text or binary content, URI and MIME type, including converting text to a blob or a blob to text. Existing MCP _meta is preserved. CMF-only envelope and descriptive fields do not restrict these changes. Each resource still needs a valid MCP content representation; binary resource reads are decoded for CPEX and re-encoded after edits, while unchanged blob bytes retain their original wire value. This resource path does not add prompt-wide payload validation.

The pre call returns an opaque, concrete ResourceHookState consumed by the post call. It captures both the runtime and the decision to run or skip post hooks before backend I/O. A reload only affects subsequent requests, including when it enables or disables resource hooks. Callers cannot construct missing or mismatched active state, and requests without a post hook allocate no correlation state.

Demo Plugin Workflow

The optional test-plugins feature compiles demo factories from the cpex-plugins-rs repository. Redis configuration activates factories already present in the binary; it never loads new Rust code into a running process.

Start lightweight dependencies:

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

Register payload-marker configuration before starting the ContextForge external dataplane:

docker compose -f docker/docker-compose-local.yaml exec -T redis \
  redis-cli SET ContextForgeGatewayRuntimePluginConfig '{
    "version": 1,
    "cpex": {
      "plugins": [
        {
          "name": "payload-marker",
          "kind": "contextforge/payload-marker",
          "hooks": ["cmf.tool_post_invoke"]
        }
      ]
    }
  }'

Build and run with demo factories and runtime execution enabled:

cargo run -p contextforge-data-plane \
  --features 'contextforge-data-plane-lib/with_tools,test-plugins' \
  --bin contextforge-data-plane -- \
  --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 \
  --runtime-plugins-enabled true

Startup should log successful CPEX initialization. The payload marker appends [cpex:payload-marker] to successful tool results. The hook path is also covered by:

cargo nextest run --locked -p contextforge-data-plane-lib --test gateway_plugins

Startup Validation (fails fast)

Invalid comboReason
--tls-address without cert or keyRustls needs both
Same address for --address and --tls-addressCannot bind same socket twice
--redis-mode tls without trust bundleRequired
--redis-mode mtls without trust bundle + client cert + keyAll three required
mTLS upstream without cert and keyreqwest identity cannot be built
HTTP backend URL with default upstream mode (HTTPS-only)Calls fail before reaching backend

Upstream Connection Modes

ModeBehavior
omitted / tls-onlyHTTPS backends only (safe default)
plain-text-or-tlsHTTP or HTTPS (use for local Compose backends)
plain-text-or-m-tlsHTTP or HTTPS + client identity
mtls-onlyHTTPS + client cert/key required

Logging Env Vars

VarDefaultControls
RUST_LOGdebugConsole filter
RUST_FILE_LOGdebugFile filter
RUST_TRACE_LOGinfoOTLP span filter (debug for local trace verification)

Telemetry Debugging Notes

RUST_TRACE_LOG=debug is required for trace export. The default (info) drops HTTP spans before they reach the OTLP exporter — nothing arrives at the trace backend.

Metrics are pushed by a PeriodicReader every 30 seconds. Allow ~35s after the first request before data appears downstream.

Stable log prefixes for grepping (use these to scope log searches by boundary):

PrefixBoundary
claims_layerJWT validation failures
user_config_store_layerConfig lookup / Redis errors
virtual_host_config_layerUnknown virtual host
AuthorizedCallValidator::validatePost-session MCP validation
initialize:Backend session creation
call_toolTool routing and backend invocation

Debugging by symptom:

SymptomWhere to look
401claims_layer logs: missing/invalid token, unsupported algorithm, no decoder key
400 config erroruser_config_store_layer logs + Redis content for the JWT subject
404 Server not foundvirtual_host_config_layer debug: requested vhost id vs caller’s config
MCP routing errorsAuthorizedCallValidator::validate debug, then call_tool/read_resource/get_prompt warns
Backend failuresinitialize: warns for failed backends; routed-call warns name the failing backend
Plugin problemsCPEX pipeline error logs; invalid reload marks runtime failed

Local Telemetry Verification Stack

A complete local observability pipeline ships under docker/ as overlays:

ComponentRoleEndpoint
LangfuseTrace backend and span viewer.http://localhost:3100, login admin@example.com / changeme, project ContextForge Data Plane.
OTel CollectorReceives OTLP from the gateway; fans traces and metrics out.OTLP/HTTP on :4318, Prometheus exposition on :8889.
PrometheusScrapes the collector for browsable PromQL.http://localhost:9090.
flowchart LR
    GW["Gateway\n(contextforge-data-plane)"]

    subgraph Local["Local Observability Stack (docker/)"]
        COL["OTel Collector\nOTLP/HTTP :4318\nPrometheus :8889"]
        LF["Langfuse\n:3100\nspan viewer + trace backend"]
        PR["Prometheus\n:9090\nPromQL browser"]
    end

    GW -->|"OTLP/HTTP traces\n(RUST_TRACE_LOG=debug required)"| COL
    GW -->|"OTLP/HTTP metrics\n(PeriodicReader every 30s)"| COL
    COL -->|"fan-out traces"| LF
    COL -->|"scrape target :8889"| PR

    OP(["operator"]) -->|"PromQL queries"| PR
    OP -->|"span viewer\nlogin: admin@example.com"| LF

Debugging by symptom:

flowchart TD
    SYM["Symptom"] --> S401["401 Unauthorized"]
    SYM --> S400["400 config error"]
    SYM --> S404["404 Server not found"]
    SYM --> SMCP["MCP routing error"]
    SYM --> SBACK["Backend failure"]
    SYM --> SPLUG["Plugin problem"]

    S401 --> L401["grep: claims_layer\nmissing/invalid token\nbad algorithm / no decoder key"]
    S400 --> L400["grep: user_config_store_layer\n+ Redis content for JWT subject"]
    S404 --> L404["grep: virtual_host_config_layer\nrequested vhost vs caller config"]
    SMCP --> LMCP["grep: AuthorizedCallValidator::validate\nthen call_tool / read_resource / get_prompt warns"]
    SBACK --> LBACK["grep: initialize: warns\nrouted-call warns name failing backend"]
    SPLUG --> LPLUG["CPEX pipeline error logs\ninvalid reload marks runtime failed"]

Start:

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

Run the gateway with export enabled (RUST_TRACE_LOG=debug required for trace export):

RUST_TRACE_LOG=debug \
cargo run --release --bin contextforge-data-plane -- \
  --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-metrics-endpoint http://127.0.0.1:4318/v1/metrics \
  --otlp-service-name contextforge-data-plane

Prometheus Starter Queries

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

Known Telemetry Gaps

Tracked upstream, not yet implemented in the ContextForge external dataplane:

GapIssue
W3C trace-context propagation across gateway hopsmcp-context-forge#4723
MCP-semantic spans with tool names and JSON-RPC method attributesmcp-context-forge#4722

Deployment

This page describes current deployment requirements, including session affinity. The tentative target removes live aggregate fan-out and durable upstream-session dependence; see ContextForge 2.0 Target Architecture and Roadmap.

Checklist

  1. Front door routes only /contextforge-rs to the ContextForge external dataplane.
  2. JWT verification key/secret matches the control plane’s signing material; clients use control-plane API tokens whose sub matches the published user-config key.
  3. Redis reachable; TLS/mTLS across trust zones; write access restricted to the control plane; DATAPLANE_PUBLISHER=true on the control plane.
  4. Upstream connection mode matches backend URL schemes.
  5. One replica per Mcp-session-id (single replica or sticky routing).
  6. with_tools feature disabled in the production build.
  7. Telemetry export pointed at the collector.
  8. System limits raised: nofile 65535, TCP tuning (tcp_fin_timeout=15, widened local port range).

Health Endpoint

/contextforge-rs/health is a with_tools bootstrap helper only. Production builds compile it out. Use TCP-level liveness checks or the exported metrics until a real health endpoint exists.

nginx Front-Door Routing

Reference docker/nginx.conf split:

  • location ^~ /contextforge-rs → proxies to the ContextForge external dataplane.
  • UI and management traffic → ContextForge control plane.
  • Other MCP routes, including stateful and legacy/SSE compatibility routes → ContextForge built-in dataplane.
  • Upstream retries on error timeout http_502/503/504: 2 tries, 10-second window. Non-idempotent MCP POST bodies are not re-sent after they reached an upstream — only connection-stage failures retry.

Session Affinity And Failover

Backend MCP sessions are local process state — see routing.md.

  • 1 replica requires sticky routing by Mcp-session-id. The reference nginx config does not provide this; safe shapes today are a single replica or a front door with stickiness.

  • On restart or failover, all sessions are lost. Design clients to treat session-not-found as “reinitialize”, not “retry”.

Redis Availability

  • Redis is required at startup and on every uncached config lookup.
  • Connection manager retries 1,000 times (rather than failing fast).
  • In-process cache (default 60s) rides out short Redis blips for warm subjects.
  • A cold subject during a Redis outage fails at user_config_store_layer400 until Redis returns.

Images

  • CI builds docker/Dockerfile on every push to main and publishes both ghcr.io/<owner>/contextforge-data-plane:v<version> and ghcr.io/<owner>/contextforge-data-plane:latest, where <version> is the Cargo package version.
  • Pin the v-prefixed tag for reproducible deployments. latest tracks main.
  • Builder: rust:1.96.1 in docker/Dockerfile.
  • The reference Compose stack runs the gateway with raised limits worth copying to real deployments: nofile 65535 and TCP tuning (tcp_fin_timeout=15, widened local port range).

TLS Choices

LegOptions
Front door to gatewayPlain HTTP on a trusted private network (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.
Gateway to backendsHTTPS-only by default; opt into plain HTTP or mTLS with --upstream-connection-mode.

Config Propagation Delay

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

Both default to ~60s. For functional tests, shorten the publisher interval and disable the cache. For throughput benchmarks, keep both at 60s.

Security Posture

ConcernCurrent state
JWT revocationNone. A leaked token is valid until exp. Rotate the key and restart to invalidate.
CORS / OriginCORS response headers are permissive. mcp_origin_layer validates Origin before authentication, and RMCP validates Host at the MCP service boundary. Configure both --mcp-allowed-hosts and --mcp-allowed-origins for production.
Local bootstrap routes/contextforge-rs/admin/tokens/{user}, /admin/userconfigs/{user}, /health are outside auth middleware — unauthenticated by design. Only exist with with_tools. Production builds must not enable with_tools.
Redis trustWhoever can write Redis controls routing (arbitrary backend URLs receive caller traffic) AND which registered plugin hooks execute on payloads. Protect with TLS/mTLS and restrict write access to the control plane.
Downstream TLSOptional. Plain HTTP is acceptable only behind a trusted front door on a private network. Identity is always the bearer JWT, not mTLS.
Plugin codeFully trusted, in-process. Redis config activates compiled-in factories only — it cannot inject new Rust code.

Performance And Load Testing

Full-Stack Load (Locust via cf-integration)

Performance testing uses the control-plane Locust suite through cf-integration. It measures the nginx → external dataplane → backend request path while the ContextForge control plane publishes configuration.

CommandWhat it runs
scripts/cf-integration.sh smoke1 user for 10 s — quick sanity pass.
scripts/cf-integration.sh locustFull 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 — target a UI-created virtual server instead of the auto-registered Fast Time one.
  • MCP_TOOL_NAMES — pick the tools to call.
  • Output: .integration/mcp-context-forge/reports/ (HTML and CSV).

Headless vs Web UI

The harness runs Locust headless by default (LOCUST_MODE=headless). Set LOCUST_MODE=web to switch to interactive mode (master + web UI on port 8089). The one-off locust command does not publish container ports; for the web UI, start via the stack’s testing Compose profile which maps 8089:8089.

Benchmark Settings

Restore both to 60 before measuring throughput — fast publish + per-request Redis reads distort numbers:

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

Built-In-Dataplane Baseline

Compare against the stock Python repository, where MCP traffic uses the ContextForge built-in dataplane and the ContextForge external dataplane is absent:

scripts/cf-integration.sh down                # free shared ports
scripts/cf-integration.sh controlplane-locust

CONTROLPLANE_LOCUST_CLASSES=all adds admin/UI/mutating surfaces. LOCUST_USERS, LOCUST_SPAWN_RATE, and LOCUST_RUN_TIME apply here too.

Testing

Verification Rings

  • Workspace checks — code compiles and unit behavior holds.
  • In-repo integration tests — MCP routing against mock backends.
  • cf-integration harness — full control-plane publication and external-dataplane request path end to end.
  • Load and benchmark — see Performance.

Workspace Validation

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

cargo fmt --all --check
cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
cargo nextest run --locked --workspace --all-features
cargo shear --check-test-targets --deny-warnings --locked

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

New protocol-sensitive tests target MCP 2026-07-28, connect through server/discover, and send the required per-request client metadata. A small compatibility module retains the active 2025-11-25/initialize cases until that production compatibility surface is removed in a dedicated change; do not add new behavior to that lane. Every case must remain request-independent, with no required Mcp-Session-Id, session affinity, or retained backend transport. SSE remains outside the external-dataplane contract.

In-Repo Integration Tests

crates/contextforge-data-plane-lib/tests/gateway.rs is the single library integration target. It exercises the public gateway API against in-process MCP backends without recompiling a shared support tree for every feature file.

AreaCovers
gateway/{tools,prompts,resources,subscriptions}.rsActive routed operations and exact routing failures.
gateway/plugins.rsGateway-owned CPEX ordering, mutation, denial, progress, and prompt seams using deterministic recording plugins. Resource coverage includes direct and aliased URIs, text/blob conversion, canonical pre-hook URIs, published-target rewrites, rejection of unpublished targets, metadata preservation, and pre/post denial. Concrete plugin behavior stays in each plugin crate.
gateway/harness/Authentication, modern and compatibility clients, in-memory configuration, concrete mock backends, and owned server fixtures.
gateway/future_contracts/Deferred fanout, pagination, TLS, completions, subscriptions, and cancellation contracts.

TestServer binds 127.0.0.1:0 before spawning, uses cooperative cancellation, and has a Drop fallback. GatewayFixture owns the gateway and all backend servers. Tests should request the minimum topology: one virtual host and one backend by default, with extra backends declared explicitly by the case.

The workspace currently keeps 13 ignored tests: 11 library future contracts and two real-process Redis/binary E2E tests. Ignored tests are not dead tests: keep them compiling, keep their intended assertions, give each a concrete blocker reason, and list them with:

cargo nextest list --locked --workspace --all-features --run-ignored only

The two binary E2E tests and tests/conformance/ remain separate infrastructure boundaries. Active in-process tests run with no Docker or Redis dependency. Resource policy coverage belongs in this active harness, not in ignored binary tests or new legacy-client cases. Runtime unit tests verify that enabling or disabling hooks during a resource read preserves its original policy decision.

Parameter-header integration tests verify that calls without a published tool schema skip local Mcp-Param-* validation and still reach the backend. Unit and integration coverage also includes missing, malformed, unexpected, repeated, and mismatched recognized headers; Base64 encoding; nested paths; numerically equivalent integers; and invalid annotation names, types, duplicates, and non-properties paths.

MCP Conformance

cf-integration owns the official fixture, control-plane registration, Compose topology, server and client runners, result rendering, and transactional baseline handling. This repository keeps only the CI invocation, Make targets, and expected findings.

Comment exactly /conformance on a pull request to run the Conformance Actions workflow. Only repository owners, members, and collaborators can start it. The workflow acknowledges the command, tests the pull request head commit, and reports the final result back to the pull request. CI builds and names the conformance binary artifact using that same head SHA and retains it for 90 days, so changes to main do not invalidate the artifact. It runs the modern client and modern server eras through the external dataplane in standalone mode. This starts Redis, the dataplane, nginx, and the official fixture without the control plane. The harness discovers the fixture’s tools, resources, templates, and prompts and publishes their routes and actual tool schemas through the dataplane serializer. Selecting that lane also runs the fixture-direct server leg and the scoped external-dataplane client leg:

cargo binstall cf-integration@0.3.1 --no-confirm
make conformance

The Make target tests the committed data-plane HEAD. It rejects tracked uncommitted changes because the CLI clones the selected repository and commit into .integration/. To use another local CLI binary:

CF_INTEGRATION=/path/to/cf-integration \
  make conformance

Update every selected baseline atomically only after all operational work and baseline evaluation succeeds:

make conformance-bless

Baselines are partitioned beneath tests/conformance/baselines/<client-version>/<server-era>/. Server findings use fixture-direct.yml and external-data-plane.yml; scoped client findings use client/external-data-plane.yml. Operational failures are always failures and cannot be blessed. Runtime checkouts, logs, results, and reports remain beneath .integration/.

A strict baseline match means the observed findings have not changed; it does not mean complete protocol coverage. Inspect the report’s individual checks before blessing changes. In particular, the external dataplane deliberately rejects catalog list methods owned by the control plane. Scenarios that need tools/list for setup cannot exercise the subsequent schema or parameter-header checks through this lane; NotObserved findings record missing coverage, not successful validation. The pinned fixture also lacks an x-mcp-header-annotated tool for the custom-header server scenario. Parameter-header validation remains covered by the active in-repo tests described above. Keep these setup gaps distinct from observed protocol failures when interpreting conformance results.

Working Preferences and Standards

Validation gate — definition of “done”

A change is not done until:

  1. cargo fmt --all --check passes.
  2. cargo clippy --locked --workspace --all-targets -- -D warnings is clean.
  3. cargo nextest run --locked --workspace passes (fallback: cargo test).
  4. cargo deny check advisories licenses passes (pre-commit + CI).
  5. cargo build --locked --workspace succeeds.
  6. If the change touches the hot path, update the matching wiki page in _context/wiki/ in the same change.

CI additionally runs cargo shear --check-test-targets --deny-warnings --locked.

By change type:

Change typeMinimum extra validation
Docs onlyRun mdbook build _context/wiki and mdbook test _context/wiki; inspect affected headings, tables, and code blocks in the rendered output
Routing or session behaviorNew/updated integration tests in crates/contextforge-data-plane-lib/tests/ against mock backends
Config shapeSchema regeneration (cargo run -p contextforge-data-plane-apis) + control-plane compatibility check
Plugin behaviorgateway_plugins.rs coverage for the new hook path
Performance-sensitive pathsLoad-test run before and after

Code style

  • Idiomatic Rust — no unnecessary clones, heap allocations, Arc, or Mutex unless justified by the design.
  • Most ContextForge external-dataplane behavior lives in contextforge-data-plane-lib. Do not let external-dataplane logic accumulate in the binary crate.
  • Typed errors — propagate errors rather than swallowing them silently.
  • Keep change size minimal. Every changed line must trace directly to the task at hand.

Logging (tracing)

  • Use tracing for all log output.
  • Prefer message-embedded fields: level!("method_name - event field = {val} other_field = {other}"). Do not use structured field syntax (, field = val) for ContextForge external-dataplane logs.
  • Keep method/event prefixes stable and reuse the same field names and order for related events.
  • warn! is for unexpected conditions that need operator attention. Expected user/config misses → debug! or info!.
  • Never log: tokens, authorization headers, secrets, Redis key/value bytes, full UserConfig, or backend credentials.

Change discipline

  • Make the minimal change that solves the problem. No speculative refactors, no added abstractions beyond the task scope.
  • Do not clean up surrounding code that is unrelated to the task.
  • Do not add error handling for scenarios that cannot happen.
  • Always read relevant code before suggesting or making changes. Never speculate about code that hasn’t been opened.

Architectural rules (non-negotiable)

  • The ContextForge external dataplane is pure routing logic. No IAM, UI, or metrics-storage concerns.
  • Config access goes through UserConfigStore only — never push Redis details into routing code.
  • The backend prefix naming contract must not change without updating merge logic, split logic, and tests.
  • Legacy SSE transport and stateful session behavior are being removed. initialize remains supported as a stateless compatibility method; do not use it to create affinity, persist client state, or retain backend transports between requests.
  • Prefer the right architecture over backward compatibility; this project has no external users yet.

Protocol target

  • The ContextForge external-dataplane target supports MCP 2026-07-28 and 2025-11-25 over Streamable HTTP.
  • Every request is independent for both versions. Do not require Mcp-Session-Id, session affinity, or a previously retained backend transport.
  • Retain initialize for clients that use it, but generate its response from effective configuration and do not treat it as session establishment. 2026-07-28 tests and examples should continue to exercise server/discover and per-request client metadata.
  • Protocol-sensitive tests cover both same-version paths and the best-effort cross-version paths (2026-07-282025-11-25 and the reverse). Do not add SSE or versions earlier than 2025-11-25 without a separate architecture decision.

AI interaction preferences

  • Read before acting: always investigate relevant files before making suggestions or edits.
  • Minimal scope: stay tightly scoped to the task — no unsolicited refactors or cleanups.
  • Plan first for complex tasks: for changes with multiple moving parts, propose the approach before implementing.
  • Run validation: run cargo test and cargo clippy after changes and report results before declaring done.
  • Update the wiki: when hot-path behavior changes, include the wiki page update in the same task.
  • No hallucination: if something is unclear, ask rather than guess.

Branch naming

Format: user/<github-username>/<kebab-case-summary> — e.g. user/alice/fix-session-cleanup. Open PRs as draft; mark ready only when implementation, tests, and wiki updates are complete.