diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index 2f1570e..597fcea 100644 --- a/_context/wiki/architecture.md +++ b/_context/wiki/architecture.md @@ -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 diff --git a/_context/wiki/security.md b/_context/wiki/security.md index bf0ac53..30d164f 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -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: diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs index 4b86148..68821f6 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs @@ -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)) @@ -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", @@ -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"], @@ -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"))); } diff --git a/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs b/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs index b038cd3..bc4101d 100644 --- a/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs +++ b/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs @@ -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)) diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 51560f6..7cdf4d2 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -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", @@ -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)] @@ -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"))); diff --git a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs index 32d88fa..212f9dc 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -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::{ @@ -48,6 +48,7 @@ pub(crate) struct BackendObservation { #[derive(Clone, Default)] pub(crate) struct BackendState { pub(crate) calls: Arc>>, + pub(crate) request_headers: Arc>>, pub(crate) prompts: Arc>>, pub(crate) cancellations: Arc>>, pub(crate) events: Arc>>, @@ -112,6 +113,13 @@ impl ServerHandler for TestBackend { request: CallToolRequestParams, cx: RequestContext, ) -> Result { + if let Some(parts) = cx.extensions.get::() { + self.state + .request_headers + .lock() + .expect("backend request headers lock poisoned") + .push(parts.headers.clone()); + } self.state .calls .lock() diff --git a/tests/conformance/client-expected-failures.yml b/tests/conformance/client-expected-failures.yml index 288387c..5f19d93 100644 --- a/tests/conformance/client-expected-failures.yml +++ b/tests/conformance/client-expected-failures.yml @@ -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