From 17ebb52b12ac4aa7842447b90146cf4b59e49204 Mon Sep 17 00:00:00 2001 From: Lang-Akshay Date: Thu, 30 Jul 2026 14:53:58 +0100 Subject: [PATCH 1/2] feat: create agent context in line with bob ai training Signed-off-by: Lang-Akshay --- AGENTS.md | 13 +++++ _context/wiki/index.md | 21 ++++++++ _context/wiki/preferences.md | 54 +++++++++++++++++++ _context/wiki/project.md | 100 +++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 _context/wiki/index.md create mode 100644 _context/wiki/preferences.md create mode 100644 _context/wiki/project.md diff --git a/AGENTS.md b/AGENTS.md index ef70deb..11cec08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,18 @@ # AGENTS.md +## Start with the wiki + +At the start of each task, check `_context/wiki/index.md` to decide +whether wiki context is needed before acting. Don't read the wiki in +full. Use the index and follow links only when they are relevant to +the task. + +## Update the wiki + +After completing a task, offer to update the wiki if the task yielded durable knowledge that could benefit future work, then wait for user approval. This includes new processes, architecture decisions, or insights that go beyond the immediate task. + +--- + Guidance for agents working on `contextforge-gateway-rs`. This repo is the Rust dataplane part of ContextForge. It must stay compatible diff --git a/_context/wiki/index.md b/_context/wiki/index.md new file mode 100644 index 0000000..3546167 --- /dev/null +++ b/_context/wiki/index.md @@ -0,0 +1,21 @@ +# ContextForge Data Plane — 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 + +| File | What it covers | +| --- | --- | +| [project.md](project.md) | What the project is, goals, stakeholders, key modules, active work | +| [preferences.md](preferences.md) | Working standards, code style, logging rules, AI interaction preferences | + +## Quick orientation + +- **Repo**: `contextforge-gateway-rs` — the Rust dataplane for ContextForge. +- **Core invariant**: this crate is pure routing logic. No IAM, UI, or metrics storage. +- **Protocol target**: MCP `2026-07-28` over Streamable HTTP. Legacy SSE paths are being removed. +- **Architecture book**: [`docs/book/src/`](../../docs/book/src/) — read the relevant page before touching the hot path. +- **Validation gate**: `cargo test` + `cargo clippy` must be clean before any change is done. +- **System topology**: `client → nginx → [dataplane | control-plane]`; config flows from control-plane via `dataplane_publisher.py` → Redis → dataplane. See [project.md § System topology](project.md#system-topology). diff --git a/_context/wiki/preferences.md b/_context/wiki/preferences.md new file mode 100644 index 0000000..0025990 --- /dev/null +++ b/_context/wiki/preferences.md @@ -0,0 +1,54 @@ +# Working Preferences and Standards + +## Validation gate — definition of "done" + +A change is not done until: +1. `cargo test` passes with no failures. +2. `cargo clippy` is clean — no new warnings. +3. If the change touches the hot path, the matching book page in [`docs/book/src/`](../../docs/book/src/) is updated in the same change. + +## Code style + +- **Idiomatic Rust** — no unnecessary clones, heap allocations, `Arc`, or `Mutex` unless justified by the design. +- Most product behavior lives in `contextforge-gateway-rs-lib`. Do not let 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 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 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 old `initialize`/session behavior are being **removed** — do not build on temporary shims. +- Prefer the right architecture over backward compatibility; this project has no external users yet. + +## Protocol target + +- All new behavior targets MCP protocol version **`2026-07-28`** over **Streamable HTTP**. +- New tests and examples use `server/discover`, per-request client metadata, and the `2026-07-28` version. +- Do not add new compatibility for older MCP protocol versions. + +## 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 book**: when hot-path behavior changes, include the book page update in the same task. +- **No hallucination**: if something is unclear, ask rather than guess. diff --git a/_context/wiki/project.md b/_context/wiki/project.md new file mode 100644 index 0000000..8b5ab4a --- /dev/null +++ b/_context/wiki/project.md @@ -0,0 +1,100 @@ +# Project Overview + +## What this project is + +`contextforge-gateway-rs` is a Rust-based MCP (Model Context Protocol) gateway — the **dataplane** component of ContextForge. It acts as a scalable, secure proxy layer that routes AI tool calls from MCP clients to one or more backend MCP servers. + +It is paired with the external ContextForge control plane at [`IBM/mcp-context-forge`](https://github.com/IBM/mcp-context-forge). The two components have a strict division of responsibility: + +| Layer | Owns | +| --- | --- | +| **This repo (dataplane)** | Request routing, auth enforcement, backend fan-out, session ownership | +| **Control plane** | IAM, UI, metrics storage, legacy MCP client compatibility | + +The dataplane must never take on control-plane concerns. + +## Goals and objectives + +- Provide a **production-grade, low-latency routing layer** between MCP clients and backend MCP servers. +- Target **MCP protocol version `2026-07-28`** over Streamable HTTP as the sole downstream contract. +- Enforce a clean **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 dataplane toward the `2026-07-28` protocol target. + +## Key modules and architecture + +The architecture book at [`docs/book/src/`](../../docs/book/src/) is the authoritative reference. Key pages: + +| Page | Covers | +| --- | --- | +| [`system-shape.md`](../../docs/book/src/system-shape.md) | Crate layout, pipeline shape, state ownership, module boundaries | +| [`request-flow.md`](../../docs/book/src/request-flow.md) | Startup, middleware order, fan-out, response path | +| [`mcp-routing-semantics.md`](../../docs/book/src/mcp-routing-semantics.md) | Backend prefix namespace and routing contract | +| [`authentication-and-user-config.md`](../../docs/book/src/authentication-and-user-config.md) | JWT validation, config keying, cache behavior | +| [`architectural-choices.md`](../../docs/book/src/architectural-choices.md) | Invariants and tradeoffs that must not change accidentally | + +**Crate structure:** +- `contextforge-gateway-rs-lib` — all product/routing logic lives here. +- Binary crate — thin entrypoint only. Dataplane logic must not accumulate here. + +**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 book page must be updated in the same change. + +## Active work (near-term) + +- **Protocol migration**: replacing all remaining legacy MCP paths (SSE transport, `initialize`/session shims) with `2026-07-28` equivalents over Streamable HTTP. +- Legacy SSE transport and old session behavior are **being removed**, not maintained. Do not build new behavior on temporary shims. +- New tests and examples should use `server/discover`, per-request client metadata, and protocol version `2026-07-28`. + +## System topology + +All external traffic enters through **nginx**, which fans out to either the dataplane or the control plane: + +```mermaid +flowchart LR + client(["client"]) --> nginx["nginx"] + nginx --> dataplane["data-plane"] + nginx --> controlplane["control-plane"] + dataplane --> redis["redis"] + controlplane --> redis + controlplane --> postgres["postgres\n(via pgbouncer)"] + dataplane --> fastts["fast_time_server"] +``` + +### How the control plane publishes config to the dataplane + +The control plane and 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 dataplane configuration (user config, backend definitions, etc.) into Redis. +2. The dataplane reads that config from Redis via the **`UserConfigStore`** abstraction (MessagePack-encoded `UserConfig`). + +This means: +- The 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 dataplane config; the dataplane has no direct dependency on the control-plane process at runtime. +- Config changes from the control plane are picked up by the dataplane through normal cache refresh / Redis reads — no restart or direct RPC required. + +### Per-component responsibilities + +| Component | Role | Persistence | +| --- | --- | --- | +| **nginx** | TLS termination, routing fan-out | — | +| **dataplane** (`contextforge-gateway-rs`) | MCP routing, auth enforcement, fan-out to backends | Redis (read-only for config) | +| **control-plane** (`IBM/mcp-context-forge`) | IAM, UI, metrics, legacy MCP clients, config publishing | Redis (write) + PostgreSQL (via pgbouncer) | +| **redis** | Runtime config store, inter-component pub/sub channel | In-memory + persistence | +| **postgres** (via pgbouncer) | Control-plane relational store | Durable | +| **fast_time_server** | High-resolution time source used by the 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 dataplane via `UserConfigStore`. +- **Control plane** (`IBM/mcp-context-forge`) — owns legacy MCP client routes and publishes dataplane config via `dataplane_publisher.py`. Does not route through this dataplane at runtime. +- **fast_time_server** — high-resolution time source consumed by the dataplane. +- **Tokio + Axum** — fixed async runtime and web framework. From c2345bc639dadec0b6a99c04870896547a029e60 Mon Sep 17 00:00:00 2001 From: Lang-Akshay Date: Fri, 7 Aug 2026 17:02:29 +0100 Subject: [PATCH 2/2] feat: combined books with _context/wiki Signed-off-by: Lang-Akshay --- Makefile | 6 +- _context/wiki/architecture.md | 75 +++++++++++++++ _context/wiki/config.md | 154 +++++++++++++++++++++++++++++++ _context/wiki/deployment.md | 63 +++++++++++++ _context/wiki/failure-modes.md | 58 ++++++++++++ _context/wiki/getting-started.md | 38 ++++++++ _context/wiki/index.md | 12 ++- _context/wiki/preferences.md | 27 +++++- _context/wiki/project.md | 11 ++- _context/wiki/routing.md | 96 +++++++++++++++++++ 10 files changed, 528 insertions(+), 12 deletions(-) create mode 100644 _context/wiki/architecture.md create mode 100644 _context/wiki/config.md create mode 100644 _context/wiki/deployment.md create mode 100644 _context/wiki/failure-modes.md create mode 100644 _context/wiki/getting-started.md create mode 100644 _context/wiki/routing.md diff --git a/Makefile b/Makefile index 03a6f86..6153ff9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help docker-prod testing-up testing-down +.PHONY: help docker-prod compose-up compose-down help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' @@ -6,12 +6,12 @@ help: ## Show this help docker-prod: ## Build production Docker image (dataplane:latest) from docker/Dockerfile docker build -t dataplane:latest -f docker/Dockerfile . -testing-up: ## Launch testing stack: nginx, control plane, redis, postgres, pgbouncer, dataplane, fast_time_server +compose-up: ## Launch stack: nginx, control plane, redis, postgres, pgbouncer, dataplane, fast_time_server @docker image inspect dataplane:latest >/dev/null 2>&1 || { \ echo "Image dataplane:latest not found. Run 'make docker-prod' first."; \ exit 1; \ } docker compose -f docker/docker-compose.yml up -d nginx control-plane redis postgres pgbouncer data-plane fast_time_server register_fast_time -testing-down: ## Tear down the testing stack +compose-down: ## Tear down the stack docker compose -f docker/docker-compose.yml stop nginx control-plane redis postgres pgbouncer data-plane fast_time_server register_fast_time diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md new file mode 100644 index 0000000..fc809e1 --- /dev/null +++ b/_context/wiki/architecture.md @@ -0,0 +1,75 @@ +# Architecture + +## 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 + -> CORS layer + -> 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 +``` + +MCP handlers read typed extensions — they never parse headers, paths, or Redis keys directly. + +## Pipeline Shape + +``` +downstream request + -> virtual host extraction → JWT validation → session extraction + -> user config lookup → 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 +``` + +Order is invariant: auth/config before backend selection; request plugins before upstream; response plugins before returning. + +## Module Boundaries (`contextforge-gateway-rs-lib`) + +| Module | Owns | +| --- | --- | +| `common.rs` | CLI 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.rs` | Local bootstrap helpers (`with_tools` feature only) | + +## State Ownership + +| State | Owner | Lifetime | +| --- | --- | --- | +| CLI `Config` | Binary startup + `Gateway` | Process | +| JWT decoders | `ContextForgeGatewayAppState` | Process | +| User config | `RedisUserConfigStore` (LRU + Redis) | Request-path consumed; control-plane authored | +| Request identity / VirtualHostId | Request extensions | One HTTP request | +| Downstream session id | RMCP + `SessionId` extension | MCP session | +| Backend RMCP services | `BackendTransports` map | Local process, per principal/backend/session | +| Local user session mapping | `LocalUserSessionStore` | Local LRU, 50k entries, 1 hour | +| Plugin manager | `CpexRuntimeRegistry` | Process, reloadable | + +> **Session rule:** backend MCP services are local process state. Sticky routing required for load-balanced deployments. + +## Executor Shapes + +| `--single-runtime` | Shape | +| --- | --- | +| `true` (default) | One multi-thread Tokio runtime, `--number-of-cpus` workers. All connections share one `BackendTransports`. | +| `false` | One 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`**. | + +## Lock Design + +Locks guard maps of handles, not I/O. Backend calls, Redis reads, and plugin hooks run outside any gateway lock. `borrow_transports()` clones `Arc` so the lock is not held across calls. diff --git a/_context/wiki/config.md b/_context/wiki/config.md new file mode 100644 index 0000000..850fbdf --- /dev/null +++ b/_context/wiki/config.md @@ -0,0 +1,154 @@ +# 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`. + +## Key CLI Flags (env var: `CONTEXTFORGE_GATEWAY_RS_*`) + +| Flag | Env suffix | Default | Note | +| --- | --- | --- | --- | +| `--address` | `ADDRESS` | — | Plain HTTP listener | +| `--tls-address` | `TLS_ADDRESS` | — | Requires cert + key | +| `--server-certificate` | `TLS_SERVER_CERTIFICATE` | — | With `--tls-address` | +| `--server-private-key` | `TLS_SERVER_PRIVATE_KEY` | — | With `--tls-address` | +| `--token-verification-public-key` | `TOKEN_VERIFICATION_PUBLIC_KEY` | — | RSA (RS256/384/512) | +| `--token-verification-secret` | `TOKEN_SECRET` | — | HMAC (HS256/384/512) | +| `--redis-address` | `REDIS_HOSTNAME` | **required** | | +| `--redis-port` | `REDIS_PORT` | **required** | | +| `--redis-mode` | `REDIS_CONNECTION_MODE` | **required** | `plain-text` \| `tls` \| `mtls` | +| `--user-config-cache-expiry-seconds` | `USER_CONFIG_CACHE_EXPIRY_SECONDS` | `60` | `0` = no cache | +| `--upstream-connection-mode` | `UPSTREAM_CONNECTION_MODE` | HTTPS-only | `plain-text-or-tls` for local HTTP backends | +| `--number-of-cpus` | `GATEWAY_CPUS` | host CPU count | Tokio worker threads | +| `--single-runtime` | `SINGLE_RUNTIME` | `true` | `false` = multi-runtime (no session affinity) | +| `--runtime-plugins-enabled` | `RUNTIME_PLUGINS_ENABLED` | `false` | Enables CPEX hooks | +| `--enable-open-telemetry` | `ENABLE_OPEN_TELEMETRY` | `false` | OTLP traces | +| `--enable-otel-metrics` | `ENABLE_OTEL_METRICS` | `false` | OTLP metrics | + +## JWT Claims (validated by `claims_layer`) + +| Claim | Required value | +| --- | --- | +| `iss` | `mcpgateway` | +| `aud` | `mcpgateway-api` | +| `exp` | present, 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-gateway-rs-apis`) + +``` +UserConfig + virtual_hosts: HashMap + +VirtualHost + backends: HashMap ← map key = routing prefix + +BackendMCPGateway + name: String + url: Url + transport: STREAMABLEHTTP | SSE | STDIO ← only STREAMABLEHTTP used today + passthrough_headers: Vec ← snapshotted at initialize; session-scoped + add_headers: HashMap ← injected after passthrough + remove_headers: Vec ← stripped after add + tool_name_aliases: HashMap ← downstream_alias → upstream_original + allowed_tool_names: Vec ← model exists, NOT currently enforced + allowed_resource_names: Vec ← model exists, NOT currently enforced + allowed_prompt_names: Vec ← model exists, NOT currently enforced +``` + +**Header apply order:** `passthrough_headers` → `add_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): + +| Category | Headers | +| --- | --- | +| Body-framing | `Content-Length`, `Content-Type` | +| Hop-by-hop | `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `Proxy-Connection`, `TE`, `Trailer`, `Trailers`, `Transfer-Encoding`, `Upgrade` | +| RMCP-reserved | `Mcp-Session-Id`, `Accept`, `Last-Event-Id` | +| Gateway-managed | `Host` (set from backend URL host + port; never overridden by config) | + +Redis storage: `MessagePack(User::new(sub))` → `MessagePack(UserConfig)`. + +Schema: `schemas/user_config.json`. Regenerate after any struct change: +```bash +cargo run -p contextforge-gateway-rs-apis +``` + +## Plugin Config (Redis key: `ContextForgeGatewayRuntimePluginConfig`) + +``` +RuntimePluginConfigDocument + version: 1 + cpex: CpexConfig +``` + +Supported: `cmf.tool_pre_invoke`, `cmf.tool_post_invoke` only. +Rejected: routing-based selection, plugin dirs, global policies, other hook types. +Reload watcher: 10-minute interval. Invalid reload → runtime marked failed. + +## Startup Validation (fails fast) + +| Invalid combo | Reason | +| --- | --- | +| `--tls-address` without cert or key | Rustls needs both | +| Same address for `--address` and `--tls-address` | Cannot bind same socket twice | +| `--redis-mode tls` without trust bundle | Required | +| `--redis-mode mtls` without trust bundle + client cert + key | All three required | +| mTLS upstream without cert and key | reqwest identity cannot be built | +| HTTP backend URL with default upstream mode (HTTPS-only) | Calls fail before reaching backend | + +## Upstream Connection Modes + +| Mode | Behavior | +| --- | --- | +| omitted / `tls-only` | HTTPS backends only (safe default) | +| `plain-text-or-tls` | HTTP or HTTPS (use for local Compose backends) | +| `plain-text-or-m-tls` | HTTP or HTTPS + client identity | +| `mtls-only` | HTTPS + client cert/key required | + +## Logging Env Vars + +| Var | Default | Controls | +| --- | --- | --- | +| `RUST_LOG` | `debug` | Console filter | +| `RUST_FILE_LOG` | `debug` | File filter | +| `RUST_TRACE_LOG` | `info` | OTLP 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): + +| Prefix | Boundary | +| --- | --- | +| `claims_layer` | JWT validation failures | +| `user_config_store_layer` | Config lookup / Redis errors | +| `virtual_host_config_layer` | Unknown virtual host | +| `AuthorizedCallValidator::validate` | Post-session MCP validation | +| `initialize:` | Backend session creation | +| `call_tool` | Tool routing and backend invocation | + +**Debugging by symptom:** + +| Symptom | Where to look | +| --- | --- | +| `401` | `claims_layer` logs: missing/invalid token, unsupported algorithm, no decoder key | +| `400` config error | `user_config_store_layer` logs + Redis content for the JWT subject | +| `404 Server not found` | `virtual_host_config_layer` debug: requested vhost id vs caller's config | +| MCP routing errors | `AuthorizedCallValidator::validate` debug, then `call_tool`/`read_resource`/`get_prompt` warns | +| Backend failures | `initialize:` warns for failed backends; routed-call warns name the failing backend | +| Plugin problems | CPEX pipeline error logs; invalid reload marks runtime failed | \ No newline at end of file diff --git a/_context/wiki/deployment.md b/_context/wiki/deployment.md new file mode 100644 index 0000000..c15fa1f --- /dev/null +++ b/_context/wiki/deployment.md @@ -0,0 +1,63 @@ +# Deployment + +## Checklist + +1. Front door routes only `/contextforge-rs` to the dataplane. +2. JWT verification key/secret in place and rotated with the control plane's signing key. +3. Redis reachable; TLS/mTLS across trust zones; write access restricted to the control plane; `DATAPLANE_PUBLISHER=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 gateway. +- All other traffic (UI, management, SSE, legacy MCP) → control-plane. +- 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](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_layer` → `400` until Redis returns. + +## Images + +- CI publishes `ghcr.io//contextforge-gateway-rs:` (Cargo package version as tag). +- **No `latest` tag — always pin the version.** +- Builder: `rust:1.96.1` in `docker/Dockerfile`. + +## Config Propagation Delay + +```text +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 + +| Concern | Current state | +| --- | --- | +| JWT revocation | None. A leaked token is valid until `exp`. Rotate the key and restart to invalidate. | +| CORS | Wide open (any origin, method, header). Bearer-token based + cookie-free → no CSRF risk, but expect tightening as policy work lands. | +| 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 trust | Whoever 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 TLS | Optional. Plain HTTP is acceptable only behind a trusted front door on a private network. Identity is always the bearer JWT, not mTLS. | +| Plugin code | Fully trusted, in-process. Redis config activates compiled-in factories only — it cannot inject new Rust code. | \ No newline at end of file diff --git a/_context/wiki/failure-modes.md b/_context/wiki/failure-modes.md new file mode 100644 index 0000000..94b2541 --- /dev/null +++ b/_context/wiki/failure-modes.md @@ -0,0 +1,58 @@ +# 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) + +| Failure | Response | Layer | +| --- | --- | --- | +| Path doesn't match `/servers/{id}/mcp` | `400` | `virtual_host_id_layer` | +| Missing `Authorization` / non-`Bearer` scheme | `401` | `claims_layer` | +| JWT undecoded, unsupported algorithm, no key | `401` | `claims_layer` | +| Expired token, wrong issuer/audience | `401` | `claims_layer` | +| No user config for `claims.sub`, or claims absent | `400` | `user_config_store_layer` | +| Config store error (not missing) | `500` | `user_config_store_layer` | +| Virtual host id absent from caller's config | `404` `{"detail":"Server not found"}` | `virtual_host_config_layer` | + +## MCP Validation (defense-in-depth, normally unreachable) + +| Failure | JSON-RPC error | +| --- | --- | +| Missing session id / config / vhost / claims extension | Internal error (`Routing problem...`) | +| Virtual host absent from user config | `RESOURCE_NOT_FOUND` `No configuration` | + +## Routing + +| Failure | Behavior | +| --- | --- | +| Prefixed name doesn't start with backend name + `-` | Internal error | +| No backend entry matches split name | Internal error (`got no responses from backends`) | +| Backend entry exists but no running service | Internal error (backend failed during initialize) | +| More than one backend entry matches | `INVALID_REQUEST`; session backend entries cleaned up | +| Undecodable pagination cursor | `-32602 Invalid params` | + +## Backend Session + +| Situation | Behavior | +| --- | --- | +| Backend unreachable during `initialize` | Stored with no running service; initialize still succeeds | +| Backend unreachable during routed call | Call returns internal error; other backends unaffected | +| Gateway process restart | All session state lost; clients must re-run `initialize` | +| Request lands on wrong gateway node | List returns empty; routed calls fail — need sticky routing | + +## Plugins + +| Failure | Behavior | +| --- | --- | +| Plugin denies call/response | Becomes MCP error to caller | +| Soft plugin error | Logged; call proceeds | +| Invalid plugin config on reload | Runtime marked failed; plugin calls return internal MCP error until valid config applied | + +## Config Store (Redis) + +| Failure | Behavior | +| --- | --- | +| Redis connection loss | Connection manager retries (1,000 configured) | +| User config missing | `400` from `user_config_store_layer` | +| Redis `GET` error | Reported as missing → `400` | +| Undecodable config / key encoding failure | `500` | diff --git a/_context/wiki/getting-started.md b/_context/wiki/getting-started.md new file mode 100644 index 0000000..818e0b5 --- /dev/null +++ b/_context/wiki/getting-started.md @@ -0,0 +1,38 @@ +# Getting Started + +## Full Docker Stack + +```bash +make docker-prod # build dataplane:latest from docker/Dockerfile +make compose-up # start nginx, control-plane, redis, postgres, dataplane, fast_time_server +``` + +Wait for `register_fast_time` to finish, then allow ~60s config propagation: + +```bash +docker compose -f docker/docker-compose.yml logs -f register_fast_time +# Look for: Fast Time Server registration complete! +``` + +| Resource | URL | +| --- | --- | +| MCP endpoint | `http://localhost:8080/contextforge-rs/servers/{virtual_host_id}/mcp` | +| Bearer token | `GET http://localhost:8080/contextforge-rs/admin/tokens/admin@example.com` | +| fast_time_server virtual host id | `b8e3f1a2c4d5e6f7a1b2c3d4e5f6a7b8` | + +> **Critical**: `/contextforge-rs` prefix → dataplane. Without it → control-plane (you'll get `{"detail":"..."}` from mcpgateway, not a dataplane response). + +Teardown: `make compose-down` (stops containers; volumes kept). + +## cf-integration Harness (full end-to-end) + +```bash +scripts/cf-integration.sh up # checkout control-plane, pull dataplane image, start full stack +scripts/cf-integration.sh probe # smoke: 401 check → initialize → tools/list → tools/call +scripts/cf-integration.sh test-all # all lanes: live-mcp, live-rbac, live-protocol +scripts/cf-integration.sh down +``` + +Admin UI (control-plane): `http://localhost:8080/admin` — `admin@example.com` / `changeme` + +Key env overrides: `CF_DATAPLANE_IMAGE`, `CF_DATAPLANE_VERSION`, `NGINX_PORT` (default `8080`). diff --git a/_context/wiki/index.md b/_context/wiki/index.md index 3546167..eb10665 100644 --- a/_context/wiki/index.md +++ b/_context/wiki/index.md @@ -8,8 +8,14 @@ then follow only the links that are relevant. | File | What it covers | | --- | --- | -| [project.md](project.md) | What the project is, goals, stakeholders, key modules, active work | -| [preferences.md](preferences.md) | Working standards, code style, logging rules, AI interaction preferences | +| [getting-started.md](getting-started.md) | Full docker stack, local cargo dev, cf-integration — commands and URIs | +| [project.md](project.md) | What the project is, goals, stakeholders, key modules, crate ownership, active work | +| [preferences.md](preferences.md) | Working standards, code style, logging rules, branch naming, AI interaction preferences | +| [architecture.md](architecture.md) | Middleware stack order, pipeline shape, module boundaries, state ownership, executor shapes | +| [routing.md](routing.md) | Backend prefix contract, list/routed ops, federated pagination, session state, capability merge | +| [failure-modes.md](failure-modes.md) | HTTP/MCP/routing/backend/plugin failure table — exact HTTP codes and JSON-RPC errors | +| [config.md](config.md) | Key CLI flags, JWT claims, UserConfig shape, plugin config, telemetry debugging, startup validation | +| [deployment.md](deployment.md) | Deployment checklist, health endpoint caveat, nginx routing, session affinity, Redis availability, image pinning | ## Quick orientation @@ -17,5 +23,5 @@ then follow only the links that are relevant. - **Core invariant**: this crate is pure routing logic. No IAM, UI, or metrics storage. - **Protocol target**: MCP `2026-07-28` over Streamable HTTP. Legacy SSE paths are being removed. - **Architecture book**: [`docs/book/src/`](../../docs/book/src/) — read the relevant page before touching the hot path. -- **Validation gate**: `cargo test` + `cargo clippy` must be clean before any change is done. +- **Validation gate**: `cargo fmt` + `cargo clippy` + `cargo nextest` + `cargo deny` must be clean; CI also runs `cargo shear`. See [preferences.md](preferences.md) for by-change-type requirements. - **System topology**: `client → nginx → [dataplane | control-plane]`; config flows from control-plane via `dataplane_publisher.py` → Redis → dataplane. See [project.md § System topology](project.md#system-topology). diff --git a/_context/wiki/preferences.md b/_context/wiki/preferences.md index 0025990..5275ac7 100644 --- a/_context/wiki/preferences.md +++ b/_context/wiki/preferences.md @@ -3,9 +3,24 @@ ## Validation gate — definition of "done" A change is not done until: -1. `cargo test` passes with no failures. -2. `cargo clippy` is clean — no new warnings. -3. If the change touches the hot path, the matching book page in [`docs/book/src/`](../../docs/book/src/) is updated in the same change. +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, the matching book page in [`docs/book/src/`](../../docs/book/src/) is updated in the same change. + +CI additionally runs `cargo shear --check-test-targets --deny-warnings --locked`. + +**By change type:** + +| Change type | Minimum extra validation | +| --- | --- | +| Docs only | `mdbook build docs/book` + `mdbook test docs/book` | +| Routing or session behavior | New/updated integration tests in `crates/contextforge-gateway-rs-lib/tests/` against mock backends | +| Config shape | Schema regeneration (`cargo run -p contextforge-gateway-rs-apis`) + control-plane compatibility check | +| Plugin behavior | `gateway_plugins.rs` coverage for the new hook path | +| Performance-sensitive paths | Load-test run before and after | ## Code style @@ -52,3 +67,9 @@ A change is not done until: - **Run validation**: run `cargo test` and `cargo clippy` after changes and report results before declaring done. - **Update the book**: when hot-path behavior changes, include the book page update in the same task. - **No hallucination**: if something is unclear, ask rather than guess. + + +## Branch naming + +Format: `user//` — e.g. `user/alice/fix-session-cleanup`. +Open PRs as draft; mark ready only when implementation, tests, and book updates are complete. \ No newline at end of file diff --git a/_context/wiki/project.md b/_context/wiki/project.md index 8b5ab4a..fd63ae4 100644 --- a/_context/wiki/project.md +++ b/_context/wiki/project.md @@ -39,9 +39,14 @@ The architecture book at [`docs/book/src/`](../../docs/book/src/) is the authori | [`authentication-and-user-config.md`](../../docs/book/src/authentication-and-user-config.md) | JWT validation, config keying, cache behavior | | [`architectural-choices.md`](../../docs/book/src/architectural-choices.md) | Invariants and tradeoffs that must not change accidentally | -**Crate structure:** -- `contextforge-gateway-rs-lib` — all product/routing logic lives here. -- Binary crate — thin entrypoint only. Dataplane logic must not accumulate here. +## Crate ownership + +| Crate | Purpose | +| --- | --- | +| `contextforge-gateway-rs-lib` | All dataplane behavior: routing, middleware, sessions, transports. Almost everything goes here. | +| `contextforge-gateway-rs` (binary) | Process shell only: CLI flags, logging, runtime shape. No dataplane logic. | +| `contextforge-gateway-rs-apis` | Shared config shapes (`UserConfig`, `User`, plugin config). Regenerate JSON schemas with `cargo run -p contextforge-gateway-rs-apis` after any change. | +| `contextforge-gateway-rs-cpex` | Plugin integration (CPEX hook factories). | **Key invariants:** - Redis/config access goes through `UserConfigStore` only — never leak Redis details into routing code. diff --git a/_context/wiki/routing.md b/_context/wiki/routing.md new file mode 100644 index 0000000..e9d1196 --- /dev/null +++ b/_context/wiki/routing.md @@ -0,0 +1,96 @@ +# MCP Routing Semantics + +## Backend Prefix Contract + +Backend map keys become public identifiers only for **multi-backend virtual hosts without an explicit tool alias**: + +``` +backend tool "increment" on backend "gateway-one" → "gateway-one-increment" +backend resource "counter" on backend "gateway-one" → "gateway-one-counter" +``` + +Single-backend virtual hosts: identifiers pass through **unchanged**. + +> **Breaking change rule:** changing a backend map key changes downstream identifiers for multi-backend virtual hosts. Do not rename without updating merge logic, split logic, and tests. + +## Tool Aliases + +`BackendMCPGateway.tool_name_aliases` maps `{downstream_alias: upstream_original}`. Aliases take precedence over prefix fallback. They are advertised and routed exactly as published (case, dots, underscores preserved). + +## List Operations (fan-out) + +All four list methods fan out to all connected backends concurrently and merge results: + +``` +list_tools / list_resources / list_prompts / list_resource_templates + → all connected backends → merged sorted output +``` + +Failed/unavailable backends are logged and skipped. Single-backend: identifiers unchanged. Multi-backend: prefixed with backend map key. + +## Routed Operations (single backend) + +Calls targeting one object use the inverse rule. The name splitter walks configured backend names and requires a `-` immediately after the backend name: + +``` +gateway-one-increment → backend: gateway-one, tool: increment +gateway-oneincrement → rejected (no - separator) +``` + +`call_tool` resolves explicit alias first, then falls back to single/multi-backend logic. + +Methods using the same conditional routing: `read_resource`, `subscribe`, `unsubscribe`, `get_prompt`, `complete`. + +## Federated Pagination + +The gateway wraps per-backend cursors inside its own opaque token (JSON, treated as opaque by MCP clients). First request: all backends queried. Resume: cursor decoded, exhausted backends skipped. New cursor emitted when any backend has more pages. + +**Known limitation:** if backend set changes between pages, removed backend's cursor is silently dropped. + +## Session State (local process) + +Backend RMCP services are stored in `BackendTransports` keyed by: +``` +principal (claims.sub) + backend_name (map key) + downstream_session_id +``` + +This is **local process state only**. Implications: +- After `initialize`, later requests must reach the same process. +- Sticky routing required for load-balanced deployments. +- Gateway restart → all sessions lost → clients must re-run `initialize`. +- Multi-runtime mode (`--single-runtime false`): each runtime thread has its own `BackendTransports` with no cross-thread affinity. + +## Capability Merge + +On `initialize`, the gateway builds one downstream `InitializeResult` — not a passthrough of any one backend. It uses gateway-aware merge: +- Enable a top-level capability when ≥1 backend supports it **and** the gateway has a routing story for it. +- `resources.subscribe` preserved if any backend advertises it. +- `listChanged` not yet advertised (gateway doesn't emit downstream list-changed notifications). +- Single-backend passthrough is not a stable contract (`HashMap` iteration order). + +## Cleanup + +`DELETE` with `Mcp-session-id`: +``` +→ RMCP handles request +→ on success: remove LocalUserSessionStore entry + BackendTransports entries for principal+session +``` +If RMCP rejects the delete, local state is untouched. + + +## MCP Method Quick Reference + +| Method | Group | Behavior | +| --- | --- | --- | +| `initialize` | Session | Concurrent fanout to all backends; failure of one backend is non-fatal (stored with no service). Returns merged capability set. Requires `DownstreamSessionId`, `UserConfig`, `VirtualHostId`, `ContextForgeClaims`. | +| `list_tools` | List | Fan-out all connected backends → merged sorted result. Cursor-based pagination across backends. | +| `list_resources` | List | Same as list_tools. | +| `list_prompts` | List | Same as list_tools. | +| `list_resource_templates` | List | Same — both name and URI template get prefixed for multi-backend. | +| `call_tool` | Targeted | Resolves alias → single/multi-backend fallback. Runs pre/post plugin hooks. Forwards downstream cancellation to backend. Tracks backend progress tokens. | +| `read_resource` | Targeted | Single-backend: URI unchanged. Multi-backend: strips prefix. | +| `subscribe` / `unsubscribe` | Targeted | Same resource-URI routing; forwards/stops resource-update notifications. | +| `get_prompt` | Targeted | Single-backend: name unchanged. Multi-backend: strips prefix. | +| `complete` | Targeted | Routes on prompt name or resource URI inside `ref`. | +| `ping` | Local | Returns success; no backend fanout. | +| `DELETE` | Session | RMCP handles first; on success `session_id_layer` removes local session + backend transports. | \ No newline at end of file