Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

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

12 changes: 10 additions & 2 deletions _context/wiki/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ TCP/TLS listener
-> 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")
-> mcp_param_validation_layer → validates tools/call params (400/-32020 on mismatch)
-> /servers/{virtual_host_name}/mcp RMCP service → validates Host, then dispatches MCP
```

Expand All @@ -40,7 +41,7 @@ MCP handlers read typed extensions — they never parse headers, paths, or Redis
```text
downstream request
-> Origin validation → MCP header limits → virtual host extraction → JWT validation → session extraction
-> user config lookup → RMCP Host validation → MCP handler validation
-> user config lookup → parameter-header validation → RMCP Host validation → MCP handler validation
-> request plugin hooks
-> backend MCP call (concurrent via join_all for initialize/list)

Expand All @@ -66,7 +67,7 @@ flowchart TD
flowchart TD
D(["downstream request"])
A["virtual host · JWT\nsession extract"]
C["user config lookup\nMCP validate"]
C["user config lookup\nparameter headers · MCP validate"]
P1["request plugins\ntool_pre_invoke"]
B["backend MCP call\njoin_all for init/list"]
P2["response plugins\ntool_post_invoke"]
Expand All @@ -77,6 +78,13 @@ flowchart TD
```


For modern `tools/call`, the parameter-header layer resolves the request's
backend and original tool name, then validates `Mcp-Param-*` against the input
schema published in `UserConfig`. It does not call backend `tools/list`.
Validated headers are forwarded unchanged; request plugins run afterward, so a
plugin that changes an annotated argument also owns the 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`)
Expand Down
5 changes: 3 additions & 2 deletions _context/wiki/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ BackendMCPGateway
passthrough_headers: Vec<String> ← snapshotted at initialize; session-scoped
add_headers: HashMap<String, String> ← injected after passthrough
remove_headers: Vec<String> ← stripped after add
tool_name_aliases: HashMap<String, String> ← downstream_alias → upstream_original
allowed_tool_names: Vec<String> ← model exists, NOT currently enforced
tool_schemas: HashMap<String, JsonObject> ← required; 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
```
Expand All @@ -146,7 +147,7 @@ BackendMCPGateway
| 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) |
| Computed MCP standard | `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*` |
| MCP standard | `Mcp-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
Expand Down
10 changes: 10 additions & 0 deletions _context/wiki/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ 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
`Mcp-Param-*` against the control-plane-published input schema. A missing schema
or header/body mismatch fails closed with HTTP `400` and JSON-RPC `-32020`.
Validation does not call backend `tools/list`. Validated 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:
Expand Down
2 changes: 1 addition & 1 deletion _context/wiki/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Protocol tests and fixtures should target MCP `2026-07-28`, use `server/discover
| `gateway_list_tools.rs` | List fanout, prefixing, and merged output. |
| `gateway_prompts.rs` | Prompt listing and prefixed `get_prompt` routing. |
| `gateway_resource_templates.rs` | Template fanout with prefixed names and URI templates, plus `read_resource` round-trips. |
| `gateway_plugins.rs` | CPEX pre/post tool hooks around `call_tool` and stream events, and prompt hooks around `get_prompt`. |
| `gateway_plugins.rs` | Request-scoped parameter-header validation/forwarding, CPEX pre/post tool hooks around `call_tool` and stream events, and prompt hooks around `get_prompt`. |

These run in `cargo nextest run` with no Docker dependencies.

Expand Down
2 changes: 2 additions & 0 deletions crates/contextforge-data-plane-apis/src/user_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub struct BackendMCPGateway {
#[serde(default)]
pub remove_headers: Vec<String>,
pub allowed_tool_names: Vec<String>,
/// Input schemas keyed by the original upstream tool name.
pub tool_schemas: HashMap<String, serde_json::Map<String, serde_json::Value>>,
#[serde(default)]
pub tool_name_aliases: HashMap<String, String>,
pub allowed_resource_names: Vec<String>,
Expand Down
1 change: 1 addition & 0 deletions crates/contextforge-data-plane-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ clap.workspace = true
thiserror.workspace = true
rmp-serde.workspace = true
async-trait.workspace = true
base64 = "0.22.1"
reqwest.workspace = true
uuid.workspace = true
lru_time_cache = "0.11.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub(crate) fn prefixed_name(backend_name: &str, rest: &str) -> String {

/// Resolves an exact control-plane alias to its backend and upstream name. Without an alias,
/// single-backend hosts preserve the upstream name and multi-backend hosts use the legacy prefix.
pub(super) fn resolve_tool_route<'a, N: AsRef<str>>(
pub(crate) fn resolve_tool_route<'a, N: AsRef<str>>(
virtual_host: &'a VirtualHost,
name: &'a str,
backend_names: &'a [N],
Expand Down Expand Up @@ -184,6 +184,7 @@ mod tests {
"url": "http://upstream:9000/mcp",
"passthrough_headers": [],
"allowed_tool_names": ["get_stats", "echo"],
"tool_schemas": {},
"tool_name_aliases": {
"Public.Tool": "get_stats",
"Echo_Tool": "echo"
Expand Down Expand Up @@ -215,6 +216,7 @@ mod tests {
"url": "http://upstream:9000/mcp",
"passthrough_headers": [],
"allowed_tool_names": ["get_stats"],
"tool_schemas": {},
"allowed_resource_names": [],
"allowed_prompt_names": []
},
Expand All @@ -223,6 +225,7 @@ mod tests {
"url": "http://other:9000/mcp",
"passthrough_headers": [],
"allowed_tool_names": [],
"tool_schemas": {},
"allowed_resource_names": [],
"allowed_prompt_names": []
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ mod tests {
"url": "http://upstream:9000/mcp",
"passthrough_headers": [],
"allowed_tool_names": [],
"tool_schemas": {},
"allowed_resource_names": [],
"allowed_prompt_names": []
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,12 @@ fn apply_header_config(
headers.insert(name, value.clone());
}
}
headers.extend(
downstream
.iter()
.filter(|(name, _)| mcp_standard_headers::is_param(name))
.map(|(name, value)| (name.clone(), value.clone())),
);
}
for (name, value) in &backend.add_headers {
let (Ok(name), Ok(value)) = (http::HeaderName::from_bytes(name.as_bytes()), http::HeaderValue::from_str(value))
Expand Down Expand Up @@ -301,6 +307,8 @@ fn apply_header_config(
/// - Non-standard hop-by-hop: `Proxy-Connection` (must not cross gateway boundary)
/// - RMCP transport-reserved: `Mcp-Session-Id`, `Accept`, `Last-Event-Id`
/// - MCP standard computed headers: `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*`
///
/// Downstream `Mcp-Param-*` headers are forwarded automatically and cannot be changed by backend config.
fn is_protected_header(name: &http::HeaderName) -> bool {
const PROTECTED: &[&str] = &[
"host",
Expand Down Expand Up @@ -353,6 +361,7 @@ mod tests {
add_headers: add.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(),
remove_headers: remove.iter().map(|s| (*s).to_owned()).collect(),
allowed_tool_names: vec![],
tool_schemas: HashMap::new(),
tool_name_aliases: HashMap::new(),
allowed_resource_names: vec![],
allowed_prompt_names: vec![],
Expand Down Expand Up @@ -461,15 +470,14 @@ mod tests {
}

#[test]
fn computed_mcp_headers_cannot_be_passed_through_added_or_removed() {
fn mcp_param_headers_are_forwarded_but_cannot_be_changed_by_backend_config() {
let mut headers = HashMap::new();
headers.insert(http::HeaderName::from_static("mcp-method"), http::HeaderValue::from_static("tools/call"));
headers.insert(http::HeaderName::from_static("mcp-param-user"), http::HeaderValue::from_static("computed"));
let ds = downstream(&[
("Mcp-Method", "wrong/method"),
("Mcp-Name", "wrong-tool"),
("Mcp-Protocol-Version", "2020-01-01"),
("Mcp-Param-User", "wrong-user"),
("Mcp-Param-User", "client-user"),
]);
let cfg = backend(
&["mcp-method", "mcp-name", "mcp-protocol-version", "mcp-param-user"],
Expand All @@ -483,9 +491,8 @@ mod tests {
);

apply_header_config(&mut headers, &cfg, Some(&ds));

assert_eq!(headers[&http::HeaderName::from_static("mcp-method")], "tools/call");
assert_eq!(headers[&http::HeaderName::from_static("mcp-param-user")], "computed");
assert_eq!(headers[&http::HeaderName::from_static("mcp-param-user")], "client-user");
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-name")));
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-protocol-version")));
}
Expand Down
1 change: 1 addition & 0 deletions crates/contextforge-data-plane-lib/src/gateway/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ mod session_manager;
mod session_store;

pub use backend_transports::BackendTransports;
pub(crate) use identifier_routing::resolve_tool_route;
pub use mcp_service::McpService;
pub use session_store::{LocalUserSessionStore, UserSession, UserSessionStore};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use axum::{
body::{Body, to_bytes},
extract::State,
middleware::Next,
response::Response,
};
use contextforge_data_plane_apis::user_store::UserConfig;
use http::{Method, StatusCode, header};
use rmcp::model::{ClientJsonRpcMessage, ClientRequest, ErrorData, JsonRpcError};

use crate::{gateway::resolve_tool_route, layers::virtual_host_id::VirtualHostId, mcp_standard_headers};

pub async fn mcp_param_validation_layer(
State(max_request_body_bytes): State<usize>,
request: http::Request<Body>,
next: Next,
) -> Response {
if request.method() != Method::POST || !mcp_standard_headers::required_for(request.headers()) {
return next.run(request).await;
}

let (parts, body) = request.into_parts();
let Ok(body) = to_bytes(body, max_request_body_bytes).await else {
return Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.body(Body::from("Payload Too Large"))
.expect("payload-too-large response builds");
};

if let Some(response) = validation_error(&parts, &body) {
return response;
}

next.run(http::Request::from_parts(parts, Body::from(body))).await
}

fn validation_error(parts: &http::request::Parts, body: &[u8]) -> Option<Response> {
let message = serde_json::from_slice::<ClientJsonRpcMessage>(body).ok()?;
let ClientJsonRpcMessage::Request(request) = message else {
return None;
};
let ClientRequest::CallToolRequest(tool_call) = &request.request else {
return None;
};
let user_config = parts.extensions.get::<UserConfig>()?;
let virtual_host_id = parts.extensions.get::<VirtualHostId>()?;
let virtual_host = user_config.virtual_hosts.get(virtual_host_id.value())?;
let backend_names: Vec<&str> = virtual_host.backends.keys().map(String::as_str).collect();
let (backend_name, tool_name) = resolve_tool_route(virtual_host, &tool_call.params.name, &backend_names)?;
let backend = virtual_host.backends.get(backend_name)?;
let reason = match backend.tool_schemas.get(tool_name) {
Some(tool_schema) => {
mcp_standard_headers::validate_tool_params(&parts.headers, tool_call.params.arguments.as_ref(), tool_schema)
.err()?
},
None => format!("Missing published schema for tool '{tool_name}'"),
};

let error = JsonRpcError::new(Some(request.id), ErrorData::header_mismatch(reason, None));
let body = serde_json::to_vec(&error).expect("JSON-RPC header mismatch serializes");
Some(
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.expect("header mismatch response builds"),
)
}
1 change: 1 addition & 0 deletions crates/contextforge-data-plane-lib/src/layers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod claims_id;
pub mod mcp_header_limits;
pub mod mcp_origin;
pub mod mcp_param_validation;
pub mod session_id;
pub mod user_config_store;
pub mod virtual_host_config;
Expand Down
3 changes: 3 additions & 0 deletions crates/contextforge-data-plane-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ use crate::{
claims_id::claims_layer,
mcp_header_limits::{McpStandardHeaderLimits, mcp_header_limits_layer},
mcp_origin::mcp_origin_layer,
mcp_param_validation::mcp_param_validation_layer,
session_id::{SessionIdState, session_id_layer},
user_config_store::user_config_store_layer,
virtual_host_config::virtual_host_config_layer,
Expand Down Expand Up @@ -112,6 +113,7 @@ impl Gateway {
} else {
StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins()
};
let max_request_body_bytes = streamable_config.max_request_body_bytes;

let reqwest_backend_client = reqwest::Client::try_from(&config)?;

Expand Down Expand Up @@ -161,6 +163,7 @@ impl Gateway {

let app = axum::Router::new()
.nest_service("/servers/{virtual_host_name}/mcp", mcp_service)
.layer(middleware::from_fn_with_state(max_request_body_bytes, mcp_param_validation_layer))
.layer(middleware::from_fn(virtual_host_config_layer))
.layer(middleware::from_fn_with_state(mcp_add_state.clone(), user_config_store_layer))
.layer(middleware::from_fn_with_state(session_id_state, session_id_layer))
Expand Down
Loading
Loading