Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ with the external ContextForge control plane in
`https://github.com/IBM/mcp-context-forge`, but it must not become a
control-plane, UI, IAM, or metrics-storage app.

## MCP Protocol Support

- The downstream dataplane contract targets modern MCP clients using protocol
version `2026-07-28` over Streamable HTTP.
- Do not add new dataplane compatibility for older MCP protocol versions,
legacy `initialize`/session behavior, or the legacy SSE transport. Replace
remaining legacy paths with their `2026-07-28` equivalents as that migration
proceeds.
- The external ContextForge control plane owns and serves legacy clients.
Older MCP versions and SSE remain on control-plane routes and must not be
routed through this dataplane.
- Tests, examples, and new protocol behavior should use `server/discover`,
per-request client metadata, and the `2026-07-28` protocol version.
- Temporary compatibility shims in the current implementation are migration
details, not supported client contracts. Do not build new behavior on them.

## Architecture

Architecture documentation lives in The ContextForge Gateway Book under
Expand Down
10 changes: 6 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ repository = "https://github.com/contextforge-gateway-rs/contextforge-gateway-rs
# Keep dependencies here only when at least two workspace members inherit them.
contextforge-gateway-rs-cpex = { path = "./crates/contextforge-gateway-rs-cpex" }
contextforge-gateway-rs-apis = { path = "./crates/contextforge-gateway-rs-apis"}
rmcp = { version = "3.0.0-beta.2", features = [
rmcp = { version = "3.0.0-beta.3", features = [
"server",
"client",
"auth",
"transport-streamable-http-server",
"reqwest",
"transport-streamable-http-client-reqwest",
"elicitation",
], git = "https://github.com/contextforge-gateway-rs/mcp-rust-sdk", rev = "cd847d0a5b17138eec926872152a5f9f5d83a59b" }
] }
serde = {version= "1.0"}
serde_json = "1.0"
tracing = "0.1"
Expand Down
1 change: 1 addition & 0 deletions crates/contextforge-gateway-rs-lib/src/const_values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ pub const LRU_CACHE_EXPIRY_DURATION: Duration = Duration::from_hours(1);
pub const CONTEXT_FORGE_GATEWAY_AUDIENCE: &str = "mcpgateway-api";
pub const CONTEXT_FORGE_GATEWAY_ISSUER: &str = "mcpgateway";
pub const MCP_SESSION_ID: &str = "mcp-session-id";
pub const MOCK_SESSION_ID: &str = "contextforge-mock-session";
pub const REDIS_RETRIES: usize = 1000; // keep re-trying forver
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
use contextforge_gateway_rs_apis::user_store::{UserConfig, VirtualHost};
use http::request::Parts;
use rmcp::{
ErrorData, RoleServer, model::ErrorCode, service::RequestContext,
transport::streamable_http_server::tower::DownstreamSessionId,
};
use rmcp::{ErrorData, RoleServer, model::ErrorCode, service::RequestContext};
use tracing::debug;

use crate::{
Expand Down Expand Up @@ -95,31 +92,23 @@ impl<'a> InitializeCallValidator<'a> {
pub fn new(ctx: &'a RequestContext<RoleServer>) -> Self {
Self { ctx }
}
pub fn validate(self) -> Result<(&'a VirtualHost, &'a DownstreamSessionId, &'a ContextForgeClaims), ErrorData> {
pub fn validate(self) -> Result<(&'a VirtualHost, SessionId, &'a ContextForgeClaims), ErrorData> {
let maybe_parts = self.ctx.extensions.get::<Parts>();

let maybe_downstream_session = self.ctx.extensions.get::<DownstreamSessionId>();
let downstream_session_id = SessionId::mock();
let maybe_user_config = maybe_parts.and_then(|parts| parts.extensions.get::<UserConfig>());
let maybe_virtual_host_id = maybe_parts.and_then(|parts| parts.extensions.get::<VirtualHostId>());
let maybe_claims = maybe_parts.and_then(|parts| parts.extensions.get::<ContextForgeClaims>());
let call_name = "initialize";
let has_user_config = maybe_user_config.is_some();
let virtual_hosts = maybe_user_config.map_or(0, |user_config| user_config.virtual_hosts.len());
let has_session_id = maybe_downstream_session.is_some();
let has_session_id = true;
let has_claims = maybe_claims.is_some();
let virtual_host_id = maybe_virtual_host_id.map_or("<missing>", |id| id.value().as_str());
debug!(
"InitializeCallValidator::validate - mcp call validation call_name = {call_name} has_user_config = {has_user_config} virtual_hosts = {virtual_hosts} has_session_id = {has_session_id} has_claims = {has_claims} virtual_host_id = {virtual_host_id}"
);

let Some(downstream_session_id) = maybe_downstream_session else {
return Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: "Routing problem... downstream session id not created".into(),
data: None,
});
};

let Some(user_config) = maybe_user_config else {
return Err(ErrorData {
code: ErrorCode::INTERNAL_ERROR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ where
let (virtual_host, downstream_session_id, claims) = call_validator.validate()?;
let session_mapping = if let Ok(maybe_session_mapping) = mcp_service
.user_session_store
.get_session(&UserSession::new(claims.sub.clone(), Arc::clone(&downstream_session_id.session_id)))
.get_session(&UserSession::new(claims.sub.clone(), Arc::from(downstream_session_id.value().as_str())))
.await
{
maybe_session_mapping.unwrap_or_default()
Expand Down Expand Up @@ -113,7 +113,7 @@ where
if mcp_service
.user_session_store
.set_session(
&UserSession::new(claims.sub.clone(), Arc::clone(&downstream_session_id.session_id)),
&UserSession::new(claims.sub.clone(), Arc::from(downstream_session_id.value().as_str())),
&session_mapping,
)
.await
Expand All @@ -129,7 +129,11 @@ where
let mut transports = mcp_service.transports.inner().lock().await;
for (name, service) in backend_services {
transports
.entry(BackendTransportKey::from((name.as_str(), downstream_session_id.value(), claims.sub.as_str())))
.entry(BackendTransportKey::from((
name.as_str(),
downstream_session_id.value().as_str(),
claims.sub.as_str(),
)))
.insert_entry(service);
}
drop(transports);
Expand Down
13 changes: 10 additions & 3 deletions crates/contextforge-gateway-rs-lib/src/layers/session_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use tracing::info;

use crate::{
common::ContextForgeClaims,
const_values::MCP_SESSION_ID,
const_values::{MCP_SESSION_ID, MOCK_SESSION_ID},
gateway::{BackendTransports, UserSession, UserSessionStore},
};

Expand All @@ -16,6 +16,10 @@ pub struct SessionId {
}

impl SessionId {
pub(crate) fn mock() -> Self {
Self { value: MOCK_SESSION_ID.to_owned() }
}

pub fn value(&self) -> &String {
&self.value
}
Expand All @@ -28,8 +32,11 @@ pub struct SessionIdState {
}

pub async fn session_id_layer(State(state): State<SessionIdState>, mut request: Request<Body>, next: Next) -> Response {
let session_id =
request.headers().get(MCP_SESSION_ID).and_then(|session_id| session_id.to_str().ok()).map(str::to_owned);
let session_id = request
.headers()
.get(MCP_SESSION_ID)
.and_then(|session_id| session_id.to_str().ok())
.map(|_| MOCK_SESSION_ID.to_owned());

if let Some(session_id) = &session_id {
info!("MCP Session ID {session_id}");
Expand Down
22 changes: 22 additions & 0 deletions docs/book/src/architectural-choices.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ They describe the shape of the current Rust dataplane, not just preferences.
| Choice | Current decision | Why it matters |
| --- | --- | --- |
| Dataplane, not control plane | This repo consumes runtime config and handles traffic. It does not own UI, IAM lifecycle, management APIs, or durable observability storage. | Keeps the hot path small and prevents product workflows from leaking into request routing. |
| Modern downstream MCP only | The target dataplane contract is MCP `2026-07-28` over Streamable HTTP. The control plane serves older versions and SSE without routing them through the dataplane. | Keeps legacy negotiation and transport compatibility out of the hot dataplane. |
| Config access is abstracted | MCP routing depends on `UserConfig`, `VirtualHost`, and `UserConfigStore`, not Redis commands. | Keeps future xDS/gRPC or another config stream possible. |
| Backend names are public | The backend map key is part of tool/resource/prompt names. | Backend renames are client-visible behavior changes. |
| Sessions are local today | Backend RMCP services live in `BackendTransports` inside one process. | Load-balanced deployments need sticky routing or a new session ownership design. |
Expand All @@ -39,6 +40,26 @@ If it is a workflow, it probably belongs outside this repo. The Rust gateway
should enforce the result of management decisions, not become the place where
those decisions are authored.

## Modern Downstream MCP Only

The dataplane is moving to one downstream protocol contract:

```text
MCP 2026-07-28
-> Streamable HTTP
-> server/discover
-> required per-request client context
```

The Rust gateway should not grow adapters for older MCP versions, legacy
session initialization, or SSE. The external control plane serves those
clients on control-plane routes. Legacy traffic does not enter the dataplane;
only clients using the modern contract are routed here.

Some current code and diagrams still describe session-oriented implementation
details. Treat them as migration inventory. Replace them with the modern
protocol path rather than preserving them as public compatibility behavior.

## Config Access Is Abstracted

Redis is the current storage and transport adapter. It is not the routing
Expand Down Expand Up @@ -158,6 +179,7 @@ Changing one of these choices should update more than one file.

| Change | Expected follow-through |
| --- | --- |
| Downstream MCP version changes | Coordinate with the control plane, update modern protocol tests and examples, and keep legacy traffic on control-plane routes. |
| Backend namespace changes | Update merge logic, split logic, tests, docs, and migration notes. |
| Session state moves external | Update `SessionManager`, cleanup behavior, load-balancing docs, and failure-mode tests. |
| Config transport changes | Keep `UserConfigStore` as the boundary and update adapter tests. |
Expand Down
10 changes: 7 additions & 3 deletions docs/book/src/capability-flow.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Capability Flow

> **Migration note:** this page documents the current `initialize` capability
> path. The target downstream contract is MCP `2026-07-28`, where
> `server/discover` replaces this client-facing lifecycle.

This page explains where MCP capabilities come from during `initialize`, how
the gateway stores them, and what the downstream client sees today.

Expand Down Expand Up @@ -32,15 +36,15 @@ runs this capability-related flow:

The code path that reads the upstream capabilities is:

```rust
```rust,ignore
let server_capabilities = running_service
.as_ref()
.and_then(|rs| rs.peer().peer_info().as_ref().map(|pi| pi.capabilities.clone()));
```

Those values are then stored here:

```rust
```rust,ignore
BackendTransportService::from((server_capabilities, running_service.map(Arc::new)))
```

Expand All @@ -49,7 +53,7 @@ BackendTransportService::from((server_capabilities, running_service.map(Arc::new
The source of truth is the upstream backend server. For example, a backend test
server can return:

```rust
```rust,ignore
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
```

Expand Down
16 changes: 11 additions & 5 deletions docs/book/src/control-plane-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ These are the values both sides currently rely on:

| Agreement | Value today |
| --- | --- |
| Client-facing route | `/servers/{virtual_host_id}/mcp` behaves like the legacy ContextForge MCP endpoint. The front door rewrites it to `/contextforge-rs/servers/{virtual_host_id}/mcp` on the dataplane. |
| Client-facing route | The public route remains `/servers/{virtual_host_id}/mcp`. The front door rewrites modern MCP `2026-07-28` Streamable HTTP traffic to `/contextforge-rs/servers/{virtual_host_id}/mcp` on the dataplane. |
| Protocol compatibility | The dataplane target is MCP `2026-07-28` only. The control plane serves older MCP versions, legacy session initialization, and SSE on its own routes; that traffic is not forwarded to the dataplane. |
| Unknown virtual host | `404` with body `{"detail":"Server not found"}`, matching the control-plane response shape. |
| Token issuer and audience | `iss = mcpgateway`, `aud = mcpgateway-api` — the values the control plane mints. |
| Claims shape | `sub`, `jti`, `iss`, `aud`, `exp`, and `user` are required. `token_use`, `iat`, `teams`, and `scopes` are optional, as is `user.full_name`. The dataplane routes on `sub` only. |
Expand All @@ -24,6 +25,10 @@ These are the values both sides currently rely on:
Changing any of these is a cross-repo change: the dataplane, the control-plane
publisher, and the integration harness all need updating together.

The protocol boundary is a target contract while the implementation migration
is in progress. Temporary session-oriented code inside the dataplane does not
move legacy compatibility ownership back into this repository.

## Config Publishing

The control plane owns durable config and publishes runtime snapshots to
Expand Down Expand Up @@ -58,10 +63,11 @@ commit them whenever `UserConfig`, `VirtualHost`, `BackendMCPGateway`, or the

## Front-Door Split

Only MCP dataplane traffic comes to this process. The repository's reference
`docker/nginx.conf` proxies `location ^~ /contextforge-rs` to the gateway;
all UI, management API, and other ContextForge traffic stays on the existing
control-plane paths.
Only modern MCP `2026-07-28` Streamable HTTP traffic comes to this process.
The repository's reference `docker/nginx.conf` proxies
`location ^~ /contextforge-rs` to the gateway. Legacy MCP and SSE traffic,
plus all UI, management API, and other ContextForge traffic, stays on the
control-plane paths and does not enter the dataplane.

## Verifying The Integration

Expand Down
19 changes: 18 additions & 1 deletion docs/book/src/mcp-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,26 @@
This section describes what the gateway exposes as an MCP server and how it
maps downstream MCP calls onto configured backend MCP servers.

## Protocol Support Direction

The downstream dataplane contract is MCP `2026-07-28` over Streamable HTTP.
Modern clients use `server/discover` and provide the required client context on
each request.

Older MCP versions, legacy session initialization, and the legacy SSE transport
are not target dataplane compatibility surfaces. The external ContextForge
control plane serves those clients on its own routes. Legacy traffic does not
enter the Rust dataplane.

The implementation is still being migrated to this boundary. Pages that
describe `initialize`, `Mcp-Session-Id`, or local session state document
temporary current internals, not a client contract to preserve. New code,
tests, and examples should use MCP `2026-07-28`; remaining legacy paths should
be replaced or removed rather than extended.

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

| Page | What it covers |
| --- | --- |
Expand Down
5 changes: 5 additions & 0 deletions docs/book/src/mcp-method-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
> gateway today, from the client's point of view. For how identifiers are
> preserved, aliased, or namespaced, see [MCP Routing Semantics](mcp-routing-semantics.md).

> **Migration note:** the supported target uses MCP `2026-07-28`,
> `server/discover`, and per-request client context. The legacy `initialize`,
> session, and subscription paths below are implementation inventory to replace,
> not compatibility contracts.

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

Expand Down
5 changes: 5 additions & 0 deletions docs/book/src/request-flow.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Request Flow

> **Migration note:** the flow below documents the current session-oriented
> implementation. The downstream target is MCP `2026-07-28` with
> `server/discover` and per-request client context. Older clients and SSE remain
> on control-plane routes and do not enter this dataplane.

> 🎯 **Flow invariant:** Axum builds request context before RMCP handlers route MCP
> methods. MCP handlers should read typed extensions, not parse headers, paths,
> or Redis keys directly.
Expand Down
9 changes: 8 additions & 1 deletion docs/book/src/running-the-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,14 @@ JWT subject admin@example.com
-> backend MCP URLs
```

## 5. Initialize an MCP Session
## 5. Temporary Legacy Smoke Flow

> This section exercises the current migration-era implementation. It is not
> the supported downstream client contract. New clients must target MCP
> `2026-07-28` over Streamable HTTP with `server/discover` and per-request
> client context. Legacy clients and SSE remain on control-plane routes and do
> not use this dataplane endpoint; this smoke flow will be replaced as the
> dataplane migration lands.

Open a streamable HTTP MCP session and save the returned `mcp-session-id`
header:
Expand Down
11 changes: 9 additions & 2 deletions docs/book/src/session-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ The current gateway keeps backend MCP client services in memory. That choice is
simple and fast, but it defines how initialized MCP sessions can be routed in a
deployment.

> **Temporary RMCP v3 compatibility:** the gateway currently maps initialize
> and every downstream `Mcp-Session-Id` header to one fixed internal session
> key. RMCP still manages the real transport session; the fixed key only keeps
> the existing gateway-owned backend lookup working until that state is
> removed. It is not part of the supported MCP `2026-07-28` client contract;
> legacy clients remain on control-plane routes and do not enter the dataplane.

## Owned State

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

| State | Key | Owner today | Lifetime |
| --- | --- | --- | --- |
| RMCP downstream session | RMCP `DownstreamSessionId` and later `Mcp-session-id`. | RMCP `LocalSessionManager`. | Local process. |
| RMCP downstream session | RMCP `Mcp-Session-Id`; fixed internal gateway session key. | RMCP `LocalSessionManager`. | Local process. |
| User session mapping | `UserSession { principal, downstream_session_id }`. | `LocalUserSessionStore`. | Local LRU cache, 50,000 entries, 1 hour. |
| Backend running service | `principal + backend_name + session_id`. | `BackendTransports`. | Local process. |
| Backend upstream MCP session | Managed inside RMCP running client service. | Backend service handle. | Local process and backend server. |
Expand All @@ -34,7 +41,7 @@ unless that architecture changes too.
InitializeCallValidator
-> selected VirtualHost
-> claims.sub
-> RMCP DownstreamSessionId
-> fixed internal gateway session key
-> one upstream client service per backend
-> BackendTransports entries
```
Expand Down
Loading
Loading