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
2 changes: 1 addition & 1 deletion _context/wiki/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ The binary sets `tikv_jemallocator` as the global allocator. jemalloc holds up b
- `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`.
- `call_tool` creates a fresh per-request backend connection via `connect_backend_for_request`, runs pre/post plugin hooks, executes the call, then explicitly closes the connection before returning.
- `call_tool` creates a fresh per-request backend connection via `connect_backend_for_request`, forwards downstream `Mcp-Param-*` headers unchanged, runs pre/post plugin hooks, executes the call, then explicitly closes the connection before returning. Plugins can rewrite the payload but not the forwarded headers; RMCP regenerates the method, routed name, and protocol-version headers.
- `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.

## Startup And Response Flow
Expand Down
8 changes: 8 additions & 0 deletions _context/wiki/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ 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. Downstream `Mcp-Param-*` values are forwarded unchanged, while RMCP
regenerates method, routed-name, and protocol-version headers. The dataplane
does not interpret parameter headers, resolve tool schemas, or call backend
`tools/list` as part of `tools/call`; the upstream MCP server owns validation.
If a plugin 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
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 @@ -461,15 +469,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 +490,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
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ fn is_exact(name: &HeaderName, expected: &str) -> bool {
name.as_str().eq_ignore_ascii_case(expected)
}

fn is_param(name: &HeaderName) -> bool {
pub(crate) fn is_param(name: &HeaderName) -> bool {
name.as_str()
.get(..HEADER_MCP_PARAM_PREFIX.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(HEADER_MCP_PARAM_PREFIX))
Expand Down
40 changes: 36 additions & 4 deletions crates/contextforge-data-plane-lib/tests/gateway_plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ fn raw_mcp_request(
request
}

fn client_with_parameter_headers(a: &'static str, b: &'static str) -> reqwest::Client {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
http::HeaderValue::from_str(&format!("Bearer {}", token(TEST_USER_ID))).expect("valid auth header"),
);
headers.insert("Mcp-Param-A", http::HeaderValue::from_static(a));
headers.insert("Mcp-Param-B", http::HeaderValue::from_static(b));
reqwest::Client::builder().default_headers(headers).build().expect("client builds")
}

fn last_backend_request_headers(gateway: &RunningGateway) -> http::HeaderMap {
gateway
.backend_state
.request_headers
.lock()
.expect("backend request headers lock poisoned")
.last()
.cloned()
.expect("backend received a request")
}

fn raw_tool_call(tool_name: &str, request_id: i64, progress_token: &str) -> Value {
serde_json::json!({
"method": "tools/call",
Expand Down Expand Up @@ -375,16 +397,20 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() {
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn stateless_tool_call_reaches_backend_without_session() {
async fn stateless_tool_call_forwards_parameter_headers_without_interpretation() {
let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await;
let service = support::connect_modern_client(
gateway.gateway_url(),
support::create_client(TEST_USER_ID),
client_with_parameter_headers("9", "2"),
support::modern_client_info(),
)
.await;
let result = service.call_tool(sum_request("sum", 1, 2)).await.expect("stateless tool call succeeds");

assert_eq!("3", text(&result));
let headers = last_backend_request_headers(&gateway);
assert_eq!("9", headers["Mcp-Param-A"]);
assert_eq!("2", headers["Mcp-Param-B"]);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
Expand Down Expand Up @@ -597,16 +623,22 @@ async fn secrets_detection_pre_hook_respects_field_allowlist() {
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn pre_hook_modifies_backend_arguments_without_rerouting_tool() {
async fn pre_hook_rewrites_payload_without_changing_forwarded_parameter_headers() {
let plugin = Arc::new(TestPlugin::new("pre", vec![cmf_hook_names::TOOL_PRE_INVOKE]).with_pre_rewrite());
let observations = plugin.observations();
let runtime = runtime_with_pre(plugin).await;

let gateway = start_gateway(TEST_USER_ID, true, runtime).await;
let service = gateway.connect(TEST_USER_ID).await;
let service = support::connect_modern_client(
gateway.gateway_url(),
client_with_parameter_headers("1", "2"),
support::modern_client_info(),
)
.await;
let result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap();

assert_eq!((REWRITTEN_SUM_A + REWRITTEN_SUM_B).to_string(), text(&result));
assert_eq!("1", last_backend_request_headers(&gateway)["Mcp-Param-A"]);
let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned");
assert_eq!("sum", backend_calls[0].tool_name);
assert_eq!(Some(&Value::from(REWRITTEN_SUM_A)), backend_calls[0].args.as_ref().and_then(|args| args.get("a")));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use contextforge_data_plane_apis::{
use contextforge_data_plane_cpex::CpexRuntimeRegistry;
use contextforge_data_plane_lib::{Config, Gateway, UpstreamConnectionMode, UserConfigStore, UserConfigStoreType};
use futures::FutureExt;
use http::{HeaderMap, HeaderValue};
use http::{HeaderMap, HeaderValue, request::Parts};
use rmcp::{
ErrorData, RoleClient, RoleServer, ServerHandler, ServiceExt,
model::{
Expand Down Expand Up @@ -48,6 +48,7 @@ pub(crate) struct BackendObservation {
#[derive(Clone, Default)]
pub(crate) struct BackendState {
pub(crate) calls: Arc<StdMutex<Vec<BackendObservation>>>,
pub(crate) request_headers: Arc<StdMutex<Vec<HeaderMap>>>,
pub(crate) prompts: Arc<StdMutex<Vec<BackendObservation>>>,
pub(crate) cancellations: Arc<StdMutex<Vec<String>>>,
pub(crate) events: Arc<StdMutex<Vec<&'static str>>>,
Expand Down Expand Up @@ -112,6 +113,13 @@ impl ServerHandler for TestBackend {
request: CallToolRequestParams,
cx: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
if let Some(parts) = cx.extensions.get::<Parts>() {
self.state
.request_headers
.lock()
.expect("backend request headers lock poisoned")
.push(parts.headers.clone());
}
self.state
.calls
.lock()
Expand Down
6 changes: 3 additions & 3 deletions tests/conformance/client-expected-failures.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Dataplane-owned upstream MCP client findings for the scoped client lane.
# OAuth scenarios are control-plane responsibilities and are not run here.
client:
# The upstream client does not yet mirror x-mcp-header tool arguments into
# Mcp-Param-* request headers. Keep the null/omission checks as required
# passes by baselining only the affected checks, not the whole scenario.
# The shell adapter drives the dataplane's outbound client path but is not a
# full MCP client: it does not discover x-mcp-header annotations or generate
# Mcp-Param-* headers. Header forwarding is covered by gateway integration tests.
- http-custom-headers:sep-2243-client-supports-custom-headers
- http-custom-headers:sep-2243-client-mirrors-designated-params
- http-custom-headers:sep-2243-client-encode-values
Expand Down