diff --git a/.github/workflows/mcp_conformance.yml b/.github/workflows/mcp_conformance.yml index 712bccab..3feeb579 100644 --- a/.github/workflows/mcp_conformance.yml +++ b/.github/workflows/mcp_conformance.yml @@ -12,7 +12,6 @@ env: MCP_CONFORMANCE_SOURCE_SHA: c321dd32035556e6769d3724a8ee97d87c3faaac # pragma: allowlist secret MCP_CONFORMANCE_SPEC_VERSION: 2026-07-28 MCP_CONFORMANCE_SERVER_ID: 3f33286667d34b65a31c3bafd30e4c21 - CF_CONTROLPLANE_IMAGE: ghcr.io/ibm/mcp-context-forge:latest CF_DATAPLANE_IMAGE: contextforge-data-plane:conformance jobs: @@ -78,6 +77,11 @@ jobs: working-directory: .conformance-suite run: git apply ../tests/conformance/disable-flaky-progress.patch + - name: Resolve latest control-plane main image + env: + GITHUB_TOKEN: ${{ github.token }} + run: echo "CF_CONTROLPLANE_IMAGE=$(tests/conformance/resolve-control-plane-image.sh)" >> "${GITHUB_ENV}" + - name: Pull external stack images env: MCP_CONFORMANCE_TOKEN: pull-only diff --git a/Cargo.lock b/Cargo.lock index a1f9d8c9..bc874d35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -615,6 +615,7 @@ dependencies = [ "axum", "axum-otel-metrics", "axum-server", + "base64 0.22.1", "chrono", "clap", "contextforge-data-plane-apis", diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index 2f1570ed..8bfe4c9f 100644 --- a/_context/wiki/architecture.md +++ b/_context/wiki/architecture.md @@ -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 ``` @@ -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) @@ -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"] @@ -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`) diff --git a/_context/wiki/config.md b/_context/wiki/config.md index 33a55bcf..5f9dbd21 100644 --- a/_context/wiki/config.md +++ b/_context/wiki/config.md @@ -128,8 +128,9 @@ BackendMCPGateway 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 + tool_schemas: HashMap ← required; upstream name → input schema + tool_name_aliases: HashMap ← downstream_alias → upstream_original allowed_resource_names: Vec ← model exists, NOT currently enforced allowed_prompt_names: Vec ← model exists, NOT currently enforced ``` @@ -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 diff --git a/_context/wiki/security.md b/_context/wiki/security.md index bf0ac535..73d98884 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -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: diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 270668d8..b13fa439 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -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. @@ -39,8 +39,9 @@ These run in `cargo nextest run` with no Docker dependencies. `.github/workflows/mcp_conformance.yml` runs the pinned official conformance suite `0.2.0-alpha.11` for MCP `2026-07-28` in both directions. The server leg is official client → nginx → checked-out dataplane → fixture proxy → official -server, with the published `latest` control plane registering and publishing -the fixture through Redis. The backend-only proxy rewrites `Host` to +server, with the newest available image built from the control plane's `main` +branch registering and publishing the fixture through Redis. The backend-only +proxy rewrites `Host` to `localhost:3000`, which the official fixture's DNS-rebinding protection requires, while leaving dataplane header protections unchanged. The control plane uses ephemeral SQLite, so PostgreSQL is unnecessary. The harness lives @@ -57,6 +58,11 @@ responsibility. Server and client results are written below `server/` and `client/`, with separate `expected-failures.yml` and `client-expected-failures.yml` baselines. +The official fixture keeps some diagnostic tools out of `tools/list`. Because +the dataplane fails closed unless the control plane publishes a tool schema, +checks that require those hidden tools remain explicit server-leg baseline +entries rather than bypassing schema validation in the harness. + `make conformance` runs both legs locally, while `make conformance-bless` runs both and refreshes both expected-failure baselines from that run. diff --git a/crates/contextforge-data-plane-apis/src/user_store.rs b/crates/contextforge-data-plane-apis/src/user_store.rs index eddf8451..a903abbc 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -25,6 +25,8 @@ pub struct BackendMCPGateway { #[serde(default)] pub remove_headers: Vec, pub allowed_tool_names: Vec, + /// Input schemas keyed by the original upstream tool name. + pub tool_schemas: HashMap>, #[serde(default)] pub tool_name_aliases: HashMap, pub allowed_resource_names: Vec, diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 6505887b..681dabab 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -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" diff --git a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs index c02b9946..663fa859 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -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>( +pub(crate) fn resolve_tool_route<'a, N: AsRef>( virtual_host: &'a VirtualHost, name: &'a str, backend_names: &'a [N], @@ -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" @@ -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": [] }, @@ -223,6 +225,7 @@ mod tests { "url": "http://other:9000/mcp", "passthrough_headers": [], "allowed_tool_names": [], + "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] } diff --git a/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs b/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs index deeb2430..e33fbaed 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs @@ -268,6 +268,7 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": [], + "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] } 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 4b861486..632446f9 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", @@ -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![], @@ -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"], @@ -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"))); } diff --git a/crates/contextforge-data-plane-lib/src/gateway/mod.rs b/crates/contextforge-data-plane-lib/src/gateway/mod.rs index 8bf5f23c..d1e46f52 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mod.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mod.rs @@ -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}; diff --git a/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs b/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs new file mode 100644 index 00000000..4c556e0e --- /dev/null +++ b/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs @@ -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, + request: http::Request, + 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 { + let message = serde_json::from_slice::(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::()?; + let virtual_host_id = parts.extensions.get::()?; + 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"), + ) +} diff --git a/crates/contextforge-data-plane-lib/src/layers/mod.rs b/crates/contextforge-data-plane-lib/src/layers/mod.rs index 83af1e2f..ead44cc5 100644 --- a/crates/contextforge-data-plane-lib/src/layers/mod.rs +++ b/crates/contextforge-data-plane-lib/src/layers/mod.rs @@ -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; diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index 88045209..14e0a05c 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -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, @@ -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)?; @@ -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)) 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 b038cd36..06cd8d0e 100644 --- a/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs +++ b/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs @@ -1,7 +1,13 @@ -use http::HeaderName; +use base64::{Engine, prelude::BASE64_STANDARD}; +use http::{HeaderMap, HeaderName}; +use rmcp::model::ProtocolVersion; use rmcp::transport::common::http_header::{ - HEADER_MCP_METHOD, HEADER_MCP_NAME, HEADER_MCP_PARAM_PREFIX, HEADER_MCP_PROTOCOL_VERSION, HEADER_SESSION_ID, + BASE64_HEADER_PREFIX, BASE64_HEADER_SUFFIX, HEADER_MCP_METHOD, HEADER_MCP_NAME, HEADER_MCP_PARAM_PREFIX, + HEADER_MCP_PROTOCOL_VERSION, HEADER_SESSION_ID, }; +use serde_json::{Map, Value}; + +type JsonObject = Map; pub(crate) fn is_limited(name: &HeaderName) -> bool { is_exact(name, HEADER_MCP_METHOD) @@ -18,12 +24,133 @@ pub(crate) fn is_computed(name: &HeaderName) -> bool { || is_param(name) } +pub(crate) fn required_for(headers: &HeaderMap) -> bool { + headers + .get(HEADER_MCP_PROTOCOL_VERSION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS.as_str()) +} + 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)) } + +/// Validates SEP-2243 parameter headers against a routed tool call. +pub(crate) fn validate_tool_params( + headers: &HeaderMap, + arguments: Option<&JsonObject>, + input_schema: &JsonObject, +) -> Result<(), String> { + for (property, annotation) in param_header_annotations(input_schema) { + let header_name = format!("{HEADER_MCP_PARAM_PREFIX}{annotation}"); + let header_value = headers.get(&header_name).and_then(|value| value.to_str().ok()); + let body_value = arguments + .and_then(|arguments| arguments.get(&property)) + .filter(|value| !value.is_null()) + .and_then(primitive_to_string); + + match (header_value, body_value) { + (None, None) => {}, + (Some(_), None) => { + return Err(format!("unexpected {header_name} header for absent or null `{property}`")); + }, + (None, Some(_)) => return Err(format!("missing {header_name} header for `{property}`")), + (Some(raw), Some(expected)) => { + let decoded = + decode_header_value(raw).ok_or_else(|| format!("{header_name} header is not valid Base64"))?; + if decoded != expected { + return Err(format!("{header_name} header `{decoded}` does not match body value `{expected}`")); + } + }, + } + } + Ok(()) +} + +fn param_header_annotations(input_schema: &JsonObject) -> Vec<(String, String)> { + input_schema + .get("properties") + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter_map(|(property, schema)| { + schema + .get("x-mcp-header") + .and_then(Value::as_str) + .filter(|annotation| !annotation.is_empty()) + .map(|annotation| (property.clone(), annotation.to_owned())) + }) + .collect() +} + +fn primitive_to_string(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Bool(value) => Some(value.to_string()), + Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn decode_header_value(value: &str) -> Option { + match value.strip_prefix(BASE64_HEADER_PREFIX).and_then(|inner| inner.strip_suffix(BASE64_HEADER_SUFFIX)) { + Some(inner) => String::from_utf8(BASE64_STANDARD.decode(inner).ok()?).ok(), + None => Some(value.to_owned()), + } +} + +#[cfg(test)] +mod tests { + use http::HeaderValue; + use serde_json::json; + + use super::*; + + fn schema() -> JsonObject { + json!({ + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "count": { "type": "integer", "x-mcp-header": "Count" }, + "dryRun": { "type": "boolean", "x-mcp-header": "Dry-Run" }, + }, + }) + .as_object() + .expect("object schema") + .clone() + } + + #[test] + fn matching_parameter_headers_are_validated() { + let arguments = json!({ "region": " leading snowman ☃", "count": 3, "dryRun": false }); + let arguments = arguments.as_object().expect("object arguments"); + let encoded = + format!("{BASE64_HEADER_PREFIX}{}{BASE64_HEADER_SUFFIX}", BASE64_STANDARD.encode(" leading snowman ☃")); + let headers = HeaderMap::from_iter([ + (HeaderName::from_static("mcp-param-region"), HeaderValue::from_str(&encoded).expect("encoded header")), + (HeaderName::from_static("mcp-param-count"), HeaderValue::from_static("3")), + (HeaderName::from_static("mcp-param-dry-run"), HeaderValue::from_static("false")), + ]); + + validate_tool_params(&headers, Some(arguments), &schema()).expect("headers match arguments"); + } + + #[test] + fn null_parameter_is_omitted_and_rejected_when_present() { + let arguments = json!({ "region": null }); + let arguments = arguments.as_object().expect("object arguments"); + validate_tool_params(&HeaderMap::new(), Some(arguments), &schema()).expect("null parameter needs no header"); + + let headers = HeaderMap::from_iter([( + HeaderName::from_static("mcp-param-region"), + HeaderValue::from_static("unexpected"), + )]); + assert!(validate_tool_params(&headers, Some(arguments), &schema()).is_err()); + } +} diff --git a/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs b/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs index 558f98d5..4fad052b 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs @@ -28,6 +28,7 @@ fn paginating_backend(port: u16) -> BackendMCPGateway { add_headers: HashMap::new(), remove_headers: Vec::new(), allowed_tool_names: Vec::new(), + tool_schemas: HashMap::new(), tool_name_aliases: HashMap::new(), allowed_resource_names: Vec::new(), allowed_prompt_names: Vec::new(), diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 51560f6a..4f405218 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -22,7 +22,8 @@ use support::{ PROMPT_POST_DENY_ERROR_CODE, PromptBehavior, PromptTestPlugin, REWRITTEN_PROMPT_RESOURCE, REWRITTEN_PROMPT_TEXT, REWRITTEN_PROMPT_TOPIC, REWRITTEN_SUM_A, REWRITTEN_SUM_B, RunningGateway, TEST_USER_ID, TestPlugin, error_code, error_parts, runtime_with_post, runtime_with_pre, runtime_with_pre_and_post, runtime_with_prompt_plugin, - start_gateway, start_gateway_with_events, start_gateway_with_json_backend_responses, sum_request, text, token, + start_gateway, start_gateway_with_events, start_gateway_with_json_backend_responses, + start_gateway_with_parameter_headers, sum_request, text, token, }; type Recorded = Arc>>; @@ -115,6 +116,54 @@ fn raw_mcp_request( request } +fn raw_stateless_tool_call(gateway: &RunningGateway, tool_name: &str, arguments: &Value) -> reqwest::RequestBuilder { + support::create_client(TEST_USER_ID) + .post(gateway.gateway_url()) + .header(http::header::ACCEPT, "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("MCP-Method", "tools/call") + .header("MCP-Name", tool_name) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "parameter-header-test", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) +} + +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 +424,55 @@ 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() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; +async fn stateless_tool_call_forwards_validated_parameter_headers_unchanged() { + let gateway = + start_gateway_with_parameter_headers(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("1", "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!("1", headers["Mcp-Param-A"]); + assert_eq!("2", headers["Mcp-Param-B"]); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_tool_call_with_mismatched_parameter_header_returns_http_400() { + let gateway = + start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let response = raw_stateless_tool_call(&gateway, "sum", &json!({ "a": 1, "b": 2 })) + .header("Mcp-Param-A", "9") + .header("Mcp-Param-B", "2") + .send() + .await + .expect("request reaches gateway"); + + assert_eq!(http::StatusCode::BAD_REQUEST, response.status()); + let body: Value = response.json().await.expect("gateway returns JSON-RPC error"); + assert_eq!(ErrorCode::HEADER_MISMATCH.0, body["error"]["code"]); + assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_tool_call_without_published_schema_fails_closed() { + 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), + support::modern_client_info(), + ) + .await; + let error = service.call_tool(CallToolRequestParams::new("missing_schema_tool")).await.unwrap_err(); + let rmcp::service::ServiceError::McpError(error) = error else { + panic!("expected backend MCP error, got {error:?}"); + }; + assert_eq!(ErrorCode::HEADER_MISMATCH, error.code); + assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -597,16 +685,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 gateway = start_gateway_with_parameter_headers(TEST_USER_ID, true, runtime).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/list_tools_gateway.rs b/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs index f283bd23..559fd0d9 100644 --- a/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs @@ -224,6 +224,10 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap>>, + pub(crate) request_headers: Arc>>, pub(crate) prompts: Arc>>, pub(crate) cancellations: Arc>>, pub(crate) events: Arc>>, + parameter_headers: bool, } #[derive(Clone)] @@ -58,6 +60,31 @@ struct TestBackend { state: BackendState, } +fn published_tool_schemas(parameter_headers: bool) -> HashMap> { + let mut schemas = + ["sum", "reflect_text", "progress_sum", "progress_counter_tokens", "wait_for_cancellation", "missing_tool"] + .into_iter() + .map(|name| (name.to_owned(), Map::new())) + .collect::>(); + if parameter_headers { + schemas.insert( + "sum".to_owned(), + json!({ + "type": "object", + "properties": { + "a": { "type": "integer", "x-mcp-header": "A" }, + "b": { "type": "integer", "x-mcp-header": "B" } + }, + "required": ["a", "b"] + }) + .as_object() + .expect("sum schema is an object") + .clone(), + ); + } + schemas +} + impl ServerHandler for TestBackend { fn initialize( &self, @@ -112,6 +139,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() @@ -266,13 +300,27 @@ pub(crate) async fn start_gateway( start_gateway_with_runtime(user, runtime_plugins_enabled, plugin_runtime, false).await } +pub(crate) async fn start_gateway_with_parameter_headers( + user: &str, + runtime_plugins_enabled: bool, + plugin_runtime: Arc, +) -> RunningGateway { + start_gateway_with_state( + user, + runtime_plugins_enabled, + plugin_runtime, + false, + BackendState { parameter_headers: true, ..Default::default() }, + ) + .await +} + pub(crate) async fn start_gateway_with_events( user: &str, plugin_runtime: Arc, events: Arc>>, ) -> RunningGateway { - start_gateway_with_state(user, true, plugin_runtime, false, BackendState { events, ..BackendState::default() }) - .await + start_gateway_with_state(user, true, plugin_runtime, false, BackendState { events, ..Default::default() }).await } pub(crate) async fn start_gateway_with_json_backend_responses( @@ -313,6 +361,7 @@ async fn start_gateway_with_state( let backend_port = backend_listener.local_addr().expect("backend address").port(); let backend_name = format!("backend-{backend_port}"); let virtual_host_id = "vh-cpex-test"; + let parameter_headers = backend_state.parameter_headers; let backend_service = StreamableHttpService::new( { @@ -341,6 +390,7 @@ async fn start_gateway_with_state( add_headers: HashMap::default(), remove_headers: Vec::new(), allowed_tool_names: Vec::new(), + tool_schemas: published_tool_schemas(parameter_headers), tool_name_aliases: HashMap::new(), allowed_resource_names: Vec::new(), allowed_prompt_names: Vec::new(), diff --git a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs index 388b5a6e..d0d4f452 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -374,6 +374,10 @@ async fn write_redis_config(redis_port: u16, backend: &RunningBackend) { add_headers: HashMap::new(), remove_headers: Vec::new(), allowed_tool_names: Vec::new(), + tool_schemas: HashMap::from([ + ("sum".to_owned(), Map::new()), + ("reflect_text".to_owned(), Map::new()), + ]), tool_name_aliases: HashMap::new(), allowed_resource_names: Vec::new(), allowed_prompt_names: Vec::new(), diff --git a/schemas/user_config.json b/schemas/user_config.json index 4afe3ab9..530d51cd 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -67,6 +67,14 @@ "type": "string" } }, + "tool_schemas": { + "description": "Input schemas keyed by the original upstream tool name.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, "tool_name_aliases": { "type": "object", "additionalProperties": { @@ -92,6 +100,7 @@ "url", "passthrough_headers", "allowed_tool_names", + "tool_schemas", "allowed_resource_names", "allowed_prompt_names" ] diff --git a/tests/conformance/client-expected-failures.yml b/tests/conformance/client-expected-failures.yml index 288387c7..5f19d937 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 diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml index f39c3734..c38bf4cd 100644 --- a/tests/conformance/expected-failures.yml +++ b/tests/conformance/expected-failures.yml @@ -12,6 +12,7 @@ server: - input-required-result-basic-list-roots:sep-2322-list-roots-incomplete - input-required-result-basic-sampling:sep-2322-sampling-incomplete - input-required-result-capability-check:sep-2322-respect-client-capabilities + - input-required-result-ignore-extra-params:sep-2322-ignore-unexpected-params - input-required-result-missing-input-response:sep-2322-missing-response-rerequests - input-required-result-multi-round:sep-2322-multi-round-r1 - input-required-result-multiple-input-requests:sep-2322-multiple-inputs-incomplete @@ -23,6 +24,9 @@ server: - resources-list:resources-list - server-stateless:sep-2575-discover-capabilities-match-handlers - server-stateless:sep-2575-http-server-no-independent-requests-on-stream + - server-stateless:sep-2575-missing-capability-http-400 - server-stateless:sep-2575-server-declares-prompts-in-discover + - server-stateless:sep-2575-server-no-log-without-loglevel + - server-stateless:sep-2575-server-rejects-undeclared-capability - tools-call-with-progress:tools-call-with-progress - tools-list:tools-list diff --git a/tests/conformance/resolve-control-plane-image.sh b/tests/conformance/resolve-control-plane-image.sh new file mode 100755 index 00000000..c1b87cd7 --- /dev/null +++ b/tests/conformance/resolve-control-plane-image.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository="IBM/mcp-context-forge" +image_repository="ghcr.io/ibm/mcp-context-forge" +api_url="https://api.github.com/repos/${repository}/commits?sha=main&per_page=100" +curl_args=( + --fail + --silent + --show-error + --retry 3 + --retry-all-errors + --header "Accept: application/vnd.github+json" + --header "X-GitHub-Api-Version: 2022-11-28" +) +if [ -n "${GITHUB_TOKEN:-}" ]; then + curl_args+=(--header "Authorization: Bearer ${GITHUB_TOKEN}") +fi + +commit_shas="$(curl "${curl_args[@]}" "${api_url}" | jq --exit-status --raw-output \ + '.[] | .sha | select(test("^[0-9a-f]{40}$"))')" + +while IFS= read -r commit_sha; do + image="${image_repository}:${commit_sha}" + if docker manifest inspect "${image}" > /dev/null 2>&1; then + echo "Resolved latest control-plane main image: ${image}" >&2 + echo "${image}" + exit 0 + fi +done <<< "${commit_shas}" + +echo "No published control-plane image found in the latest 100 main commits" >&2 +exit 1 diff --git a/tests/conformance/run-local.sh b/tests/conformance/run-local.sh index c1674024..ea26ab0e 100755 --- a/tests/conformance/run-local.sh +++ b/tests/conformance/run-local.sh @@ -10,7 +10,6 @@ export MCP_CONFORMANCE_SOURCE_SHA="${MCP_CONFORMANCE_SOURCE_SHA:-c321dd32035556e export MCP_CONFORMANCE_SPEC_VERSION="${MCP_CONFORMANCE_SPEC_VERSION:-2026-07-28}" export MCP_CONFORMANCE_SERVER_ID="${MCP_CONFORMANCE_SERVER_ID:-3f33286667d34b65a31c3bafd30e4c21}" export MCP_CONFORMANCE_SUITE_DIR="${MCP_CONFORMANCE_SUITE_DIR:-${repo_root}/.conformance-suite}" -export CF_CONTROLPLANE_IMAGE="${CF_CONTROLPLANE_IMAGE:-ghcr.io/ibm/mcp-context-forge:latest}" export CF_DATAPLANE_IMAGE="${CF_DATAPLANE_IMAGE:-contextforge-data-plane:conformance}" export MCP_CONFORMANCE_COLOR="${MCP_CONFORMANCE_COLOR:-auto}" @@ -22,6 +21,11 @@ for command in curl docker git jq node npm; do done docker compose version > /dev/null +if [ -z "${CF_CONTROLPLANE_IMAGE:-}" ]; then + CF_CONTROLPLANE_IMAGE="$("${script_dir}/resolve-control-plane-image.sh")" + export CF_CONTROLPLANE_IMAGE +fi + if [ -e "${MCP_CONFORMANCE_SUITE_DIR}" ] && [ ! -d "${MCP_CONFORMANCE_SUITE_DIR}/.git" ]; then echo "MCP_CONFORMANCE_SUITE_DIR is not a git checkout: ${MCP_CONFORMANCE_SUITE_DIR}" >&2 exit 1 diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index 5d67a7fe..b31e8b11 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -51,6 +51,7 @@ def main() -> None: "add_headers": {}, "remove_headers": [], "allowed_tool_names": tool_names, + "tool_schemas": {name: {} for name in tool_names}, "tool_name_aliases": {}, "allowed_resource_names": [], "allowed_prompt_names": [],