From a2ab5ab8a055b43a4061ea4948d1a6ef43c88558 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 10:19:43 +0100 Subject: [PATCH 01/16] fix: generate SEP-2243 parameter headers Signed-off-by: lucarlig --- _context/wiki/architecture.md | 2 +- _context/wiki/security.md | 16 +++++ _context/wiki/testing.md | 5 ++ .../src/gateway/mcp_service/tools.rs | 11 +++ crates/contextforge-data-plane-lib/src/lib.rs | 9 +-- .../tests/gateway_plugins.rs | 72 ++++++++++++++++++- .../tests/support/plugin_gateway.rs | 67 ++++++++++++++++- .../conformance/client-expected-failures.yml | 9 +-- 8 files changed, 174 insertions(+), 17 deletions(-) diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index c9146b2..c18fa9b 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`, lists the backend's tools on that same connection so RMCP can cache `x-mcp-header` annotations, runs pre/post plugin hooks, executes the call, then explicitly closes the connection before returning. RMCP derives the upstream `Mcp-Param-*` headers from the final routed arguments rather than forwarding downstream computed 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 38a5b72..81abb5e 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -91,6 +91,22 @@ 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. +For stateless requests, the RMCP service requires `MCP-Protocol-Version` and +the matching per-request protocol metadata before handler dispatch. RMCP also +validates `Mcp-Method` and `Mcp-Name` against the JSON-RPC body. Computed MCP +headers are never accepted through backend pass-through/add/remove policy. The +stateless tool client discovers schemas on its per-request backend connection, +so RMCP regenerates annotated `Mcp-Param-*` values from the final routed tool +arguments. + +Tenant-safe inbound `Mcp-Param-*` value validation is not yet enabled. RMCP +3.1.x resolves server tool schemas by bare tool name before request extensions +are available and caches that result globally inside the Streamable HTTP +service. A gateway `get_tool(name)` implementation would therefore allow one +subject or virtual host to select another tenant's schema. This requires a +request-aware RMCP schema resolver keyed by subject, virtual host, and exposed +tool name; do not add a bare-name cache as a workaround. + ## 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 b780fac..bf2d89d 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -58,6 +58,11 @@ a control-plane responsibility. Server and client results are written below `server/` and `client/`, with separate `expected-failures.yml` and `client-expected-failures.yml` baselines. +The client lane has no expected failures. Before each stateless upstream tool +call, the dataplane lists tools on the same RMCP connection; this primes RMCP's +schema cache and exercises its native `x-mcp-header` generation, including +omission, primitive conversion, and Base64 wrapping. + `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-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index 950e727..c6d1abe 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -43,6 +43,17 @@ pub(super) async fn call_tool( ToolPreCallResult::unchanged() }; let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; + // The per-request RMCP client starts with an empty tool-schema cache. Prime + // that same connection so RMCP can derive Mcp-Param-* from x-mcp-header + // annotations after gateway routing and plugin argument rewrites. + if let Err(error) = backend_service.peer().list_all_tools().await { + if let Err(close_error) = backend_service.close().await { + warn!( + "call_tool: backend cleanup after schema discovery failed backend_name = {service_name} error = {close_error:?}" + ); + } + return Err(backend_forward_error("list_tools", &service_name, &error)); + } let post_state = pre_result.state; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &tool_name); diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index a5d72ea..e6f9431 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -107,12 +107,13 @@ impl Gateway { // RMCP owns Host validation. Keep its Origin validator disabled because // mcp_origin_layer enforces exact origin tuples and returns 403 for every // invalid present Origin, including when no allowlist is configured. + let streamable_config = StreamableHttpServerConfig::default() + .with_stateless_protocol_metadata_required(true) + .disable_allowed_origins(); let streamable_config = if let Some(ref hosts) = config.mcp_allowed_hosts { - StreamableHttpServerConfig::default() - .with_allowed_hosts(hosts.iter().map(Authority::as_str)) - .disable_allowed_origins() + streamable_config.with_allowed_hosts(hosts.iter().map(Authority::as_str)) } else { - StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins() + streamable_config.disable_allowed_hosts() }; let reqwest_backend_client = reqwest::Client::try_from(&config)?; diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 51560f6..8d49276 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -375,7 +375,7 @@ 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_primes_rmcp_schema_before_forwarding() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let service = support::connect_modern_client( gateway.gateway_url(), @@ -387,6 +387,76 @@ async fn stateless_tool_call_reaches_backend_without_session() { assert_eq!("3", text(&result)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_tool_call_lets_rmcp_encode_unsafe_parameter_headers() { + 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 unsafe_value = " leading snowman ☃"; + let request = CallToolRequestParams::new("reflect_text") + .with_arguments(Map::from_iter([("text".to_owned(), Value::from(unsafe_value))])); + + let result = service.call_tool(request).await.expect("RMCP encodes the annotated argument"); + + assert_eq!(unsafe_value, text(&result)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_tool_call_lets_rmcp_omit_null_parameter_headers() { + 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 request = + CallToolRequestParams::new("optional_text").with_arguments(Map::from_iter([("text".to_owned(), Value::Null)])); + + let result = service.call_tool(request).await.expect("RMCP omits the annotated null argument"); + + assert_eq!("accepted", text(&result)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_tool_call_without_protocol_version_header_is_rejected_before_backend() { + let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let response = support::create_client(TEST_USER_ID) + .post(gateway.gateway_url()) + .header(http::header::ACCEPT, "application/json, text/event-stream") + .header("MCP-Method", "tools/call") + .header("MCP-Name", "sum") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "sum", + "arguments": { "a": 1, "b": 2 }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "strict-metadata-test", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) + .send() + .await + .expect("request reaches gateway"); + + assert_eq!(http::StatusCode::BAD_REQUEST, response.status()); + let body: serde_json::Value = response.json().await.expect("gateway returns a JSON-RPC error"); + assert_eq!(rmcp::model::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_error_round_trips() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; 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 f0c262e..7612238 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -16,8 +16,9 @@ use rmcp::{ ErrorData, RoleClient, RoleServer, ServerHandler, ServiceExt, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorCode, GetPromptRequestParams, - GetPromptResponse, GetPromptResult, Implementation, InitializeRequestParams, InitializeResult, NumberOrString, - ProgressNotificationParam, ProgressToken, PromptMessage, ResourceContents, Role, ServerCapabilities, + GetPromptResponse, GetPromptResult, Implementation, InitializeRequestParams, InitializeResult, ListToolsResult, + NumberOrString, PaginatedRequestParams, ProgressNotificationParam, ProgressToken, PromptMessage, + ResourceContents, Role, ServerCapabilities, Tool, }, service::{RequestContext, Service}, transport::{ @@ -26,7 +27,7 @@ use rmcp::{ streamable_http_server::session::local::LocalSessionManager, }, }; -use serde_json::{Map, Value}; +use serde_json::{Map, Value, json}; use tokio::sync::Mutex as TokioMutex; use super::{MemoryUserConfigStore, token}; @@ -58,6 +59,48 @@ struct TestBackend { state: BackendState, } +fn sum_tool() -> Tool { + let input_schema = 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 input schema is an object") + .clone(); + Tool::new("sum", "Add two integers", input_schema) +} + +fn reflect_text_tool() -> Tool { + let input_schema = json!({ + "type": "object", + "properties": { + "text": { "type": "string", "x-mcp-header": "Text" } + }, + "required": ["text"] + }) + .as_object() + .expect("reflect_text input schema is an object") + .clone(); + Tool::new("reflect_text", "Reflect text", input_schema) +} + +fn optional_text_tool() -> Tool { + let input_schema = json!({ + "type": "object", + "properties": { + "text": { "type": "string", "x-mcp-header": "Optional-Text" } + } + }) + .as_object() + .expect("optional_text input schema is an object") + .clone(); + Tool::new("optional_text", "Accept optional text", input_schema) +} + #[allow(clippy::unused_async_trait_impl)] impl ServerHandler for TestBackend { async fn initialize( @@ -102,6 +145,23 @@ impl ServerHandler for TestBackend { Ok(GetPromptResult::new(vec![PromptMessage::new_text(Role::User, format!("review of {topic}"))]).into()) } + async fn list_tools( + &self, + _request: Option, + _cx: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(vec![sum_tool(), reflect_text_tool(), optional_text_tool()])) + } + + fn get_tool(&self, name: &str) -> Option { + match name { + "sum" => Some(sum_tool()), + "reflect_text" => Some(reflect_text_tool()), + "optional_text" => Some(optional_text_tool()), + _ => None, + } + } + async fn call_tool( &self, request: CallToolRequestParams, @@ -175,6 +235,7 @@ impl ServerHandler for TestBackend { .ok_or_else(|| ErrorData::invalid_params("reflect_text requires text", None))?; Ok(CallToolResult::success(vec![ContentBlock::text(text.to_owned())])) }, + "optional_text" => Ok(CallToolResult::success(vec![ContentBlock::text("accepted")])), "wait_for_cancellation" => { cx.ct.cancelled().await; self.state diff --git a/tests/conformance/client-expected-failures.yml b/tests/conformance/client-expected-failures.yml index 288387c..0c4180b 100644 --- a/tests/conformance/client-expected-failures.yml +++ b/tests/conformance/client-expected-failures.yml @@ -1,10 +1,3 @@ # 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. - - 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 - - http-custom-headers:sep-2243-client-base64-unsafe +client: [] From d1877dfefe1f26f21a1af823f9483cfabdd1fbbc Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 11:49:42 +0100 Subject: [PATCH 02/16] fix: use published schemas for MCP parameter headers Signed-off-by: lucarlig --- Cargo.lock | 2 + _context/wiki/architecture.md | 2 +- _context/wiki/config.md | 1 + _context/wiki/security.md | 19 +- _context/wiki/testing.md | 8 +- .../src/user_store.rs | 3 + crates/contextforge-data-plane-lib/Cargo.toml | 2 + .../src/gateway/identifier_routing.rs | 2 +- .../src/gateway/mcp_service/initialization.rs | 141 ++++++++++- .../src/gateway/mcp_service/prompts.rs | 2 +- .../src/gateway/mcp_service/resources.rs | 2 +- .../src/gateway/mcp_service/tools.rs | 14 +- .../src/gateway/mod.rs | 1 + .../src/layers/mcp_param_validation.rs | 64 +++++ .../src/layers/mod.rs | 1 + crates/contextforge-data-plane-lib/src/lib.rs | 3 + .../src/mcp_standard_headers.rs | 220 +++++++++++++++++- .../tests/gateway_pagination.rs | 1 + .../tests/gateway_plugins.rs | 111 ++++++--- .../tests/support/list_tools_gateway.rs | 1 + .../tests/support/plugin_gateway.rs | 15 +- .../tests/secrets_detection_e2e.rs | 1 + schemas/user_config.json | 11 +- 23 files changed, 553 insertions(+), 74 deletions(-) create mode 100644 crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs diff --git a/Cargo.lock b/Cargo.lock index a1f9d8c..4f4ef83 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", @@ -639,6 +640,7 @@ dependencies = [ "secret-string", "serde", "serde_json", + "sse-stream", "test-log", "thiserror 2.0.19", "tokio", diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index c18fa9b..437852f 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`, lists the backend's tools on that same connection so RMCP can cache `x-mcp-header` annotations, runs pre/post plugin hooks, executes the call, then explicitly closes the connection before returning. RMCP derives the upstream `Mcp-Param-*` headers from the final routed arguments rather than forwarding downstream computed headers. +- `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. Tool schemas published by the control plane in `UserConfig` let the dataplane validate downstream `Mcp-Param-*` values and derive the upstream values from the final routed arguments without calling backend `tools/list`. A request-aware HTTP client decorator adds only those parameter headers; RMCP continues to generate the method, 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/config.md b/_context/wiki/config.md index a9f7988..98dde30 100644 --- a/_context/wiki/config.md +++ b/_context/wiki/config.md @@ -130,6 +130,7 @@ BackendMCPGateway 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 ← upstream_original → input schema; published per backend allowed_resource_names: Vec ← model exists, NOT currently enforced allowed_prompt_names: Vec ← model exists, NOT currently enforced ``` diff --git a/_context/wiki/security.md b/_context/wiki/security.md index 81abb5e..4739b31 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -95,17 +95,14 @@ For stateless requests, the RMCP service requires `MCP-Protocol-Version` and the matching per-request protocol metadata before handler dispatch. RMCP also validates `Mcp-Method` and `Mcp-Name` against the JSON-RPC body. Computed MCP headers are never accepted through backend pass-through/add/remove policy. The -stateless tool client discovers schemas on its per-request backend connection, -so RMCP regenerates annotated `Mcp-Param-*` values from the final routed tool -arguments. - -Tenant-safe inbound `Mcp-Param-*` value validation is not yet enabled. RMCP -3.1.x resolves server tool schemas by bare tool name before request extensions -are available and caches that result globally inside the Streamable HTTP -service. A gateway `get_tool(name)` implementation would therefore allow one -subject or virtual host to select another tenant's schema. This requires a -request-aware RMCP schema resolver keyed by subject, virtual host, and exposed -tool name; do not add a bare-name cache as a workaround. +control plane publishes each visible tool schema inside the subject-, virtual- +host-, and backend-scoped Redis configuration. The innermost authenticated +middleware resolves that request-scoped schema and returns HTTP `400` with +JSON-RPC `-32020` when an annotated parameter header is missing or mismatched. +After plugin rewrites, a per-request upstream HTTP client decorator derives +`Mcp-Param-*` from the final arguments and the same backend-scoped schema. No +schema is cached globally by bare tool name, and the dataplane does not call +backend `tools/list` as part of `tools/call`. ## Local Bootstrap Helpers (`with_tools`) diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index bf2d89d..38d67d5 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -58,10 +58,10 @@ a control-plane responsibility. Server and client results are written below `server/` and `client/`, with separate `expected-failures.yml` and `client-expected-failures.yml` baselines. -The client lane has no expected failures. Before each stateless upstream tool -call, the dataplane lists tools on the same RMCP connection; this primes RMCP's -schema cache and exercises its native `x-mcp-header` generation, including -omission, primitive conversion, and Base64 wrapping. +The client lane has no expected failures. Each stateless upstream tool call +uses the backend-scoped schema already published in Redis; the dataplane does +not issue `tools/list`. The lane covers omission, primitive conversion, and +Base64 wrapping for `x-mcp-header` annotations. `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 8ded3fa..2509e94 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -36,6 +36,9 @@ pub struct BackendMCPGateway { pub allowed_resource_names: Vec, pub allowed_prompt_names: Vec, pub allowed_tool_names: Vec, + /// Input schemas keyed by the original upstream tool name. + #[serde(default)] + pub tool_schemas: HashMap>, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 6505887..f4ea71f 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -36,6 +36,8 @@ thiserror.workspace = true rmp-serde.workspace = true async-trait.workspace = true reqwest.workspace = true +base64 = "0.22.1" +sse-stream = "0.2.5" uuid.workspace = true lru_time_cache = "0.11.11" hyper-util = "0.1.20" 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 ef98ac1..fa4cd01 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -22,7 +22,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], 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 936c73b..a4f6da7 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 @@ -1,23 +1,144 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use contextforge_data_plane_apis::user_store::BackendMCPGateway; -use http::request::Parts; +use futures::stream::BoxStream; +use http::{HeaderName, HeaderValue, request::Parts}; use rmcp::{ ClientLifecycleMode, ErrorData, RoleClient, RoleServer, model::{ - ClientCapabilities, ErrorCode, Implementation, InitializeRequestParams, InitializeResult, ProtocolVersion, - ServerCapabilities, + ClientCapabilities, ClientJsonRpcMessage, ClientRequest, ErrorCode, Implementation, InitializeRequestParams, + InitializeResult, JsonObject, ProtocolVersion, ServerCapabilities, }, service::serve_client_with_lifecycle_and_ct, service::{RequestContext, RunningService}, - transport::{StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig}, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::{ + SseError, StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, + StreamableHttpPostResponse, + }, + }, }; +use sse_stream::Sse; use tracing::warn; use super::McpService; use crate::gateway::backend_client::GatewayBackendClient; use crate::mcp_standard_headers; +#[derive(Clone)] +struct McpParamHttpClient { + inner: reqwest::Client, + tool_schema: Option>, +} + +impl McpParamHttpClient { + fn new(inner: reqwest::Client, tool_schema: Option>) -> Self { + Self { inner, tool_schema } + } + + fn insert_tool_params( + &self, + message: &ClientJsonRpcMessage, + headers: &mut HashMap, + ) -> Result<(), StreamableHttpError> { + let Some(tool_schema) = self.tool_schema.as_deref() else { + return Ok(()); + }; + let ClientJsonRpcMessage::Request(request) = message else { + return Ok(()); + }; + let ClientRequest::CallToolRequest(request) = &request.request else { + return Ok(()); + }; + mcp_standard_headers::insert_tool_params(headers, request.params.arguments.as_ref(), tool_schema).map_err( + |error| { + StreamableHttpError::UnexpectedServerResponse(format!("invalid published tool schema: {error}").into()) + }, + ) + } +} + +impl StreamableHttpClient for McpParamHttpClient { + type Error = reqwest::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + mut custom_headers: HashMap, + ) -> Result> { + self.insert_tool_params(&message, &mut custom_headers)?; + self.inner.post_message(uri, message, session_id, auth_header, custom_headers).await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + mut custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result> { + self.insert_tool_params(&message, &mut custom_headers)?; + self.inner + .post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_header, + custom_headers, + max_sse_event_size, + ) + .await + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + self.inner.delete_session(uri, session_id, auth_header, custom_headers).await + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + self.inner.get_stream(uri, session_id, last_event_id, auth_header, custom_headers).await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result>, StreamableHttpError> { + self.inner + .get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_header, + custom_headers, + max_sse_event_size, + ) + .await + } +} + #[allow(clippy::unused_async)] pub(super) async fn initialize( _svc: &McpService, @@ -35,10 +156,11 @@ pub(super) async fn initialize( pub(super) async fn connect_backend_for_request( mcp_service: &McpService, - backend_name: &str, - backend: &BackendMCPGateway, + backend: (&str, &BackendMCPGateway), + tool_name: Option<&str>, cx: &RequestContext, ) -> Result, ErrorData> { + let (backend_name, backend) = backend; let mut headers = HashMap::new(); let downstream_headers = cx.extensions.get::().map(|parts| &parts.headers); @@ -56,8 +178,10 @@ pub(super) async fn connect_backend_for_request( apply_header_config(&mut headers, backend, downstream_headers); crate::telemetry::inject_current_context(&mut headers); + let tool_schema = tool_name.and_then(|tool_name| backend.tool_schemas.get(tool_name)).cloned().map(Arc::new); let config = StreamableHttpClientTransportConfig::with_uri(backend.url.to_string()).custom_headers(headers); - let transport = StreamableHttpClientTransport::with_client(mcp_service.http_client.clone(), config); + let client = McpParamHttpClient::new(mcp_service.http_client.clone(), tool_schema); + let transport = StreamableHttpClientTransport::with_client(client, config); let client_info = InitializeRequestParams::new( ClientCapabilities::default(), Implementation::new("contextforge-data-plane", env!("CARGO_PKG_VERSION")), @@ -166,6 +290,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![], diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs index e8b9a18..377b8c9 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs @@ -43,7 +43,7 @@ pub(super) async fn get_prompt( } else { PromptPreFetchResult::unchanged() }; - let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; + let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), None, &cx).await?; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &prompt_name); let response = backend_service.get_prompt(routed_request).await; diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs index 19474ed..32f019b 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs @@ -39,7 +39,7 @@ pub(super) async fn read_resource( })?; let service_name = backend_name.clone(); - let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; + let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), None, &cx).await?; let mut routed_request = request; routed_request.uri = resource_uri; diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index c6d1abe..191d0a3 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -42,21 +42,11 @@ pub(super) async fn call_tool( } else { ToolPreCallResult::unchanged() }; - let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; - // The per-request RMCP client starts with an empty tool-schema cache. Prime - // that same connection so RMCP can derive Mcp-Param-* from x-mcp-header - // annotations after gateway routing and plugin argument rewrites. - if let Err(error) = backend_service.peer().list_all_tools().await { - if let Err(close_error) = backend_service.close().await { - warn!( - "call_tool: backend cleanup after schema discovery failed backend_name = {service_name} error = {close_error:?}" - ); - } - return Err(backend_forward_error("list_tools", &service_name, &error)); - } let post_state = pre_result.state; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &tool_name); + let mut backend_service = + connect_backend_for_request(mcp_service, (&backend_name, backend), Some(&tool_name), &cx).await?; let progress_token = cx.meta.get_progress_token(); let handle = backend_service diff --git a/crates/contextforge-data-plane-lib/src/gateway/mod.rs b/crates/contextforge-data-plane-lib/src/gateway/mod.rs index d9a754b..9933f44 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mod.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mod.rs @@ -5,4 +5,5 @@ mod identifier_routing; mod mcp_call_validator; mod mcp_service; +pub(crate) use identifier_routing::resolve_tool_route; pub use mcp_service::McpService; 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 0000000..79b7abc --- /dev/null +++ b/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs @@ -0,0 +1,64 @@ +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 tool_schema = virtual_host.backends.get(backend_name)?.tool_schemas.get(tool_name)?; + let reason = + mcp_standard_headers::validate_tool_params(&parts.headers, tool_call.params.arguments.as_ref(), tool_schema) + .err()?; + + 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 97164fe..2c5a263 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 user_config_store; pub mod virtual_host_config; pub mod virtual_host_id; diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index e6f9431..0e3efe4 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -43,6 +43,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, user_config_store::user_config_store_layer, virtual_host_config::virtual_host_config_layer, virtual_host_id::virtual_host_id_layer, @@ -117,6 +118,7 @@ impl Gateway { }; let reqwest_backend_client = reqwest::Client::try_from(&config)?; + let max_request_body_bytes = streamable_config.max_request_body_bytes; // Create streamable HTTP service let mcp_service: StreamableHttpService = StreamableHttpService::new( @@ -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(mcp_add_state.clone(), claims_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 b038cd3..50b64df 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,15 @@ -use http::HeaderName; +use std::collections::{HashMap, HashSet}; + +use base64::{Engine, prelude::BASE64_STANDARD}; +use http::{HeaderMap, HeaderName, HeaderValue}; +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,6 +26,13 @@ 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) } @@ -27,3 +42,204 @@ fn is_param(name: &HeaderName) -> bool { .get(..HEADER_MCP_PARAM_PREFIX.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(HEADER_MCP_PARAM_PREFIX)) } + +/// Validate 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(()) +} + +/// Add SEP-2243 parameter headers for a routed upstream tool call. +pub(crate) fn insert_tool_params( + headers: &mut HashMap, + arguments: Option<&JsonObject>, + input_schema: &JsonObject, +) -> Result<(), String> { + for (property, annotation) in param_header_annotations(input_schema)? { + let Some(value) = arguments.and_then(|arguments| arguments.get(&property)).and_then(primitive_to_string) else { + continue; + }; + let header_name = format!("{HEADER_MCP_PARAM_PREFIX}{annotation}"); + let header_name = HeaderName::from_bytes(header_name.as_bytes()) + .map_err(|error| format!("invalid parameter header name: {error}"))?; + let header_value = HeaderValue::from_str(&encode_header_value(&value)) + .map_err(|error| format!("invalid parameter header value: {error}"))?; + headers.insert(header_name, header_value); + } + Ok(()) +} + +fn param_header_annotations(input_schema: &JsonObject) -> Result, String> { + let Some(Value::Object(properties)) = input_schema.get("properties") else { + return Ok(Vec::new()); + }; + let mut annotations = Vec::new(); + let mut seen = HashSet::new(); + for (property, schema) in properties { + reject_nested_annotations(schema, property)?; + let Some(raw) = schema.get("x-mcp-header") else { + continue; + }; + let Value::String(annotation) = raw else { + return Err(format!("property `{property}`: x-mcp-header must be a string")); + }; + if annotation.is_empty() { + return Err(format!("property `{property}`: x-mcp-header must not be empty")); + } + if !annotation.chars().all(is_tchar) { + return Err(format!("property `{property}`: x-mcp-header `{annotation}` is not a valid HTTP token")); + } + if !seen.insert(annotation.to_ascii_lowercase()) { + return Err(format!("property `{property}`: duplicate x-mcp-header `{annotation}` (case-insensitive)")); + } + match schema.get("type").and_then(Value::as_str) { + Some("string" | "integer" | "boolean") => {}, + other => { + return Err(format!( + "property `{property}`: x-mcp-header requires a primitive type \ + (string/integer/boolean), got {other:?}" + )); + }, + } + annotations.push((property.clone(), annotation.clone())); + } + Ok(annotations) +} + +fn reject_nested_annotations(schema: &Value, path: &str) -> Result<(), String> { + if let Some(Value::Object(properties)) = schema.get("properties") { + for (property, nested_schema) in properties { + if nested_schema.get("x-mcp-header").is_some() { + return Err(format!( + "property `{path}.{property}`: x-mcp-header is not supported on nested properties" + )); + } + reject_nested_annotations(nested_schema, &format!("{path}.{property}"))?; + } + } + Ok(()) +} + +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 encode_header_value(value: &str) -> String { + if requires_base64(value) { + format!("{BASE64_HEADER_PREFIX}{}{BASE64_HEADER_SUFFIX}", BASE64_STANDARD.encode(value)) + } else { + value.to_owned() + } +} + +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()), + } +} + +fn requires_base64(value: &str) -> bool { + if value.is_empty() { + return false; + } + let bytes = value.as_bytes(); + if matches!(bytes.first(), Some(b' ' | b'\t')) || matches!(bytes.last(), Some(b' ' | b'\t')) { + return true; + } + value.chars().any(|character| !(0x20..=0x7e).contains(&(character as u32))) + || value.starts_with(BASE64_HEADER_PREFIX) && value.ends_with(BASE64_HEADER_SUFFIX) +} + +fn is_tchar(character: char) -> bool { + character.is_ascii_alphanumeric() + || matches!(character, '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~') +} + +#[cfg(test)] +mod tests { + 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 parameter_headers_round_trip_primitives_and_unsafe_values() { + let arguments = json!({ "region": " leading snowman ☃", "count": 3, "dryRun": false }); + let arguments = arguments.as_object().expect("object arguments"); + let mut headers = HashMap::new(); + + insert_tool_params(&mut headers, Some(arguments), &schema()).expect("headers are generated"); + let headers: HeaderMap = headers.into_iter().collect(); + + assert!( + headers + .get("Mcp-Param-Region") + .expect("region header") + .to_str() + .expect("header string") + .starts_with(BASE64_HEADER_PREFIX) + ); + 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"); + let mut headers = HashMap::new(); + + insert_tool_params(&mut headers, Some(arguments), &schema()).expect("headers are generated"); + assert!(!headers.contains_key("Mcp-Param-Region")); + + 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 c3c48e0..ce41ddd 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 8d49276..e8f8f5f 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -2,6 +2,7 @@ mod support; use std::sync::{Arc, Mutex as StdMutex}; +use base64::{Engine, prelude::BASE64_STANDARD}; use contextforge_data_plane_cpex::CpexRuntimeRegistry; use cpex::cpex_core::cmf::Role; use cpex::cpex_core::config::CpexConfig; @@ -115,6 +116,43 @@ 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(&serde_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": "strict-metadata-test", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) +} + +async fn successful_tool_text(response: reqwest::Response) -> String { + assert_eq!(http::StatusCode::OK, response.status()); + let body = response.text().await.expect("gateway response body"); + let messages = sse_data_values(&body); + messages + .iter() + .find_map(|message| message["result"]["content"][0]["text"].as_str()) + .expect("tool response contains text") + .to_owned() +} + fn raw_tool_call(tool_name: &str, request_id: i64, progress_token: &str) -> Value { serde_json::json!({ "method": "tools/call", @@ -375,51 +413,46 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_primes_rmcp_schema_before_forwarding() { +async fn stateless_tool_call_uses_published_schema_without_backend_listing() { 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 result = service.call_tool(sum_request("sum", 1, 2)).await.expect("stateless tool call succeeds"); - assert_eq!("3", text(&result)); + let response = raw_stateless_tool_call(&gateway, "sum", &json!({ "a": 1, "b": 2 })) + .header("Mcp-Param-A", "1") + .header("Mcp-Param-B", "2") + .send() + .await + .expect("stateless tool call reaches gateway"); + + assert_eq!("3", successful_tool_text(response).await); + assert_eq!( + 0, + gateway.backend_state.list_tool_calls.load(std::sync::atomic::Ordering::Relaxed), + "the dataplane must not call tools/list before forwarding" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_lets_rmcp_encode_unsafe_parameter_headers() { 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 unsafe_value = " leading snowman ☃"; - let request = CallToolRequestParams::new("reflect_text") - .with_arguments(Map::from_iter([("text".to_owned(), Value::from(unsafe_value))])); - - let result = service.call_tool(request).await.expect("RMCP encodes the annotated argument"); + let encoded = format!("=?base64?{}?=", BASE64_STANDARD.encode(unsafe_value)); + let response = raw_stateless_tool_call(&gateway, "reflect_text", &json!({ "text": unsafe_value })) + .header("Mcp-Param-Text", encoded) + .send() + .await + .expect("stateless tool call reaches gateway"); - assert_eq!(unsafe_value, text(&result)); + assert_eq!(unsafe_value, successful_tool_text(response).await); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_lets_rmcp_omit_null_parameter_headers() { 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 request = - CallToolRequestParams::new("optional_text").with_arguments(Map::from_iter([("text".to_owned(), Value::Null)])); - - let result = service.call_tool(request).await.expect("RMCP omits the annotated null argument"); + let response = raw_stateless_tool_call(&gateway, "optional_text", &json!({ "text": null })) + .send() + .await + .expect("stateless tool call reaches gateway"); - assert_eq!("accepted", text(&result)); + assert_eq!("accepted", successful_tool_text(response).await); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -457,6 +490,22 @@ async fn stateless_tool_call_without_protocol_version_header_is_rejected_before_ 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_with_mismatched_parameter_header_is_rejected_before_backend() { + let gateway = start_gateway(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: serde_json::Value = response.json().await.expect("gateway returns a JSON-RPC error"); + assert_eq!(rmcp::model::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_error_round_trips() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; 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 f88d8af..aa942e8 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 @@ -231,6 +231,7 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap>>, + pub(crate) list_tool_calls: Arc, pub(crate) prompts: Arc>>, pub(crate) cancellations: Arc>>, pub(crate) events: Arc>>, @@ -101,6 +105,13 @@ fn optional_text_tool() -> Tool { Tool::new("optional_text", "Accept optional text", input_schema) } +fn published_tool_schemas() -> HashMap> { + [sum_tool(), reflect_text_tool(), optional_text_tool()] + .into_iter() + .map(|tool| (tool.name.to_string(), tool.input_schema.as_ref().clone())) + .collect() +} + #[allow(clippy::unused_async_trait_impl)] impl ServerHandler for TestBackend { async fn initialize( @@ -150,6 +161,7 @@ impl ServerHandler for TestBackend { _request: Option, _cx: RequestContext, ) -> Result { + self.state.list_tool_calls.fetch_add(1, Ordering::Relaxed); Ok(ListToolsResult::with_all_items(vec![sum_tool(), reflect_text_tool(), optional_text_tool()])) } @@ -397,6 +409,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(), 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 b197468..5ed52b6 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -380,6 +380,7 @@ 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::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 69576e9..1723d76 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -106,6 +106,15 @@ "items": { "type": "string" } + }, + "tool_schemas": { + "description": "Input schemas keyed by the original upstream tool name.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + }, + "default": {} } }, "required": [ @@ -118,4 +127,4 @@ ] } } -} \ No newline at end of file +} From 2dd91f752eec807eff9a1c53ec3cba9b34005a8b Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 11:56:23 +0100 Subject: [PATCH 03/16] test: publish conformance tool schemas Signed-off-by: lucarlig --- tests/conformance/write_client_config.py | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index 5d67a7f..2741ab6 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -6,11 +6,77 @@ import argparse import json import os +import urllib.request from urllib.parse import urlparse import msgpack import redis +PROTOCOL_VERSION = "2026-07-28" + + +def fetch_tool_schemas(backend_url: str, tool_names: list[str]) -> dict[str, dict[str, object]]: + body = json.dumps( + { + "jsonrpc": "2.0", + "id": "control-plane-schema-discovery", + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { + "name": "contextforge-conformance-control-plane", + "version": "1.0.0", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, + } + ).encode() + request = urllib.request.Request( + backend_url, + data=body, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": PROTOCOL_VERSION, + "MCP-Method": "tools/list", + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=10) as response: + response_body = response.read().decode() + + messages = [ + json.loads(line.removeprefix("data:").strip()) + for line in response_body.splitlines() + if line.startswith("data:") and line.removeprefix("data:").strip() + ] + if not messages: + messages = [json.loads(response_body)] + tools = next( + ( + message.get("result", {}).get("tools") + for message in messages + if isinstance(message.get("result", {}).get("tools"), list) + ), + None, + ) + if tools is None: + raise SystemExit(f"tools/list did not return tools: {response_body}") + + schemas = { + tool["name"]: tool["inputSchema"] + for tool in tools + if isinstance(tool, dict) + and tool.get("name") in tool_names + and isinstance(tool.get("inputSchema"), dict) + } + missing = sorted(set(tool_names) - schemas.keys()) + if missing: + raise SystemExit(f"tools/list omitted requested tool schemas: {missing}") + return schemas + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() @@ -40,6 +106,7 @@ def main() -> None: raise SystemExit("tool_names_json must be a non-empty JSON string array") backend_name = "conformance-backend" + tool_schemas = fetch_tool_schemas(args.backend_url, tool_names) config = { "virtual_hosts": { args.virtual_host_id: { @@ -51,6 +118,7 @@ def main() -> None: "add_headers": {}, "remove_headers": [], "allowed_tool_names": tool_names, + "tool_schemas": tool_schemas, "tool_name_aliases": {}, "allowed_resource_names": [], "allowed_prompt_names": [], From 4713f951cf37b46f80dcf55d7de83f3f02b02923 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 11:59:51 +0100 Subject: [PATCH 04/16] test: discover client conformance schemas Signed-off-by: lucarlig --- tests/conformance/client-under-test-test.sh | 5 ++ tests/conformance/client-under-test.sh | 11 ++-- tests/conformance/write_client_config.py | 60 +++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/tests/conformance/client-under-test-test.sh b/tests/conformance/client-under-test-test.sh index 3387f24..59e0345 100755 --- a/tests/conformance/client-under-test-test.sh +++ b/tests/conformance/client-under-test-test.sh @@ -16,6 +16,7 @@ mkdir -p "${fake_bin}" cat > "${fake_bin}/docker" <<'EOF' #!/usr/bin/env bash printf '%s\n' "$*" > "${FAKE_DOCKER_ARGS}" +printf '%s\n' "${FAKE_PREPARED_TOOL_CALLS}" EOF cat > "${fake_bin}/curl" <<'EOF' #!/usr/bin/env bash @@ -33,6 +34,10 @@ chmod +x "${fake_bin}/docker" "${fake_bin}/curl" export PATH="${fake_bin}:${PATH}" export FAKE_DOCKER_ARGS="${docker_args}" export FAKE_CURL_BODIES="${curl_bodies}" +export FAKE_PREPARED_TOOL_CALLS='[ + {"name":"first","arguments":{"region":"west"},"headers":{"Mcp-Param-Region":"west"}}, + {"name":"second","arguments":{"verbose":null},"headers":{}} +]' export MCP_CONFORMANCE_PROTOCOL_VERSION=2026-07-28 export MCP_CONFORMANCE_SUBJECT=test-subject export MCP_CONFORMANCE_CLIENT_SERVER_ID=test-client-server diff --git a/tests/conformance/client-under-test.sh b/tests/conformance/client-under-test.sh index 909ee2f..2b01bcb 100755 --- a/tests/conformance/client-under-test.sh +++ b/tests/conformance/client-under-test.sh @@ -43,19 +43,23 @@ case "${MCP_CONFORMANCE_SCENARIO}" in esac tool_names="$(jq --exit-status --compact-output '[.[].name] | unique' <<< "${tool_calls}")" -docker compose -f "${compose_file}" run --rm --no-deps \ +prepared_tool_calls="$(docker compose -f "${compose_file}" run --rm --no-deps \ --entrypoint python3 control-plane \ /opt/contextforge-conformance/write_client_config.py \ "${MCP_CONFORMANCE_SUBJECT}" \ "${virtual_host_id}" \ "${backend_url}" \ "${tool_names}" \ - > /dev/null + "${tool_calls}")" endpoint="http://127.0.0.1:${conformance_port}/servers/${virtual_host_id}/mcp" while IFS= read -r tool_call; do tool_name="$(jq --exit-status --raw-output '.name' <<< "${tool_call}")" arguments="$(jq --exit-status --compact-output '.arguments' <<< "${tool_call}")" + header_args=() + while IFS= read -r header; do + header_args+=(--header "${header}") + done < <(jq --exit-status --raw-output '.headers | to_entries[] | "\(.key): \(.value)"' <<< "${tool_call}") request="$(jq --null-input --compact-output \ --arg name "${tool_name}" \ --argjson arguments "${arguments}" \ @@ -85,6 +89,7 @@ while IFS= read -r tool_call; do --header "MCP-Protocol-Version: ${MCP_CONFORMANCE_PROTOCOL_VERSION}" \ --header 'MCP-Method: tools/call' \ --header "MCP-Name: ${tool_name}" \ + "${header_args[@]}" \ --data "${request}" \ "${endpoint}")" @@ -97,4 +102,4 @@ while IFS= read -r tool_call; do echo "${response}" >&2 exit 1 fi -done < <(jq --compact-output '.[]' <<< "${tool_calls}") +done < <(jq --compact-output '.[]' <<< "${prepared_tool_calls}") diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index 2741ab6..4b7be14 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import base64 import json import os import urllib.request @@ -78,12 +79,58 @@ def fetch_tool_schemas(backend_url: str, tool_names: list[str]) -> dict[str, dic return schemas +def encode_header_value(value: str) -> str: + needs_base64 = ( + bool(value) + and ( + value[0] in {" ", "\t"} + or value[-1] in {" ", "\t"} + or any(ord(character) < 0x20 or ord(character) > 0x7E for character in value) + or (value.startswith("=?base64?") and value.endswith("?=")) + ) + ) + if not needs_base64: + return value + encoded = base64.b64encode(value.encode()).decode() + return f"=?base64?{encoded}?=" + + +def prepare_tool_calls( + tool_calls: list[dict[str, object]], + tool_schemas: dict[str, dict[str, object]], +) -> list[dict[str, object]]: + prepared = [] + for tool_call in tool_calls: + name = tool_call["name"] + arguments = tool_call["arguments"] + properties = tool_schemas[name].get("properties", {}) + headers = {} + if isinstance(arguments, dict) and isinstance(properties, dict): + for property_name, property_schema in properties.items(): + if not isinstance(property_schema, dict): + continue + annotation = property_schema.get("x-mcp-header") + value = arguments.get(property_name) + if not isinstance(annotation, str) or not annotation or value is None: + continue + if isinstance(value, bool): + value = str(value).lower() + elif isinstance(value, (str, int, float)): + value = str(value) + else: + continue + headers[f"Mcp-Param-{annotation}"] = encode_header_value(value) + prepared.append({"name": name, "arguments": arguments, "headers": headers}) + return prepared + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("subject") parser.add_argument("virtual_host_id") parser.add_argument("backend_url") parser.add_argument("tool_names_json") + parser.add_argument("tool_calls_json") return parser.parse_args() @@ -104,6 +151,18 @@ def main() -> None: or not all(isinstance(name, str) and name for name in tool_names) ): raise SystemExit("tool_names_json must be a non-empty JSON string array") + tool_calls = json.loads(args.tool_calls_json) + if ( + not isinstance(tool_calls, list) + or not tool_calls + or not all( + isinstance(tool_call, dict) + and isinstance(tool_call.get("name"), str) + and isinstance(tool_call.get("arguments"), dict) + for tool_call in tool_calls + ) + ): + raise SystemExit("tool_calls_json must be a non-empty tool-call array") backend_name = "conformance-backend" tool_schemas = fetch_tool_schemas(args.backend_url, tool_names) @@ -132,6 +191,7 @@ def main() -> None: value = msgpack.dumps(config, use_bin_type=True) client = redis.Redis.from_url(redis_url, decode_responses=False) client.set(key, value, ex=600) + print(json.dumps(prepare_tool_calls(tool_calls, tool_schemas), separators=(",", ":"))) if __name__ == "__main__": From 003aa36b20bdff06898608df2b4e70a91da99be8 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 12:07:13 +0100 Subject: [PATCH 05/16] test: cover published custom header schemas Signed-off-by: lucarlig --- _context/wiki/testing.md | 4 +++ tests/conformance/client-under-test-test.sh | 7 ++++- tests/conformance/client-under-test.sh | 35 +++++++++++++-------- tests/conformance/run-local.sh | 6 ++-- tests/conformance/write_client_config.py | 15 +++++---- 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 38d67d5..3d945fa 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -65,6 +65,10 @@ Base64 wrapping for `x-mcp-header` annotations. `make conformance` runs both legs locally, while `make conformance-bless` runs both and refreshes both expected-failure baselines from that run. +To exercise unpublished cross-repository changes, build both images locally and +run `tests/conformance/run-local.sh` with `CF_CONTROLPLANE_IMAGE`, +`CF_DATAPLANE_IMAGE`, and `MCP_CONFORMANCE_SKIP_PULL=true` so Compose does not +replace the local tags. Because this conformance CLI cannot set a bearer header, nginx adds an ephemeral control-plane token when one is absent; there is no auth proxy or diff --git a/tests/conformance/client-under-test-test.sh b/tests/conformance/client-under-test-test.sh index 59e0345..19f3c42 100755 --- a/tests/conformance/client-under-test-test.sh +++ b/tests/conformance/client-under-test-test.sh @@ -6,6 +6,7 @@ state_dir="$(mktemp -d "${TMPDIR:-/tmp}/contextforge-client-adapter-test.XXXXXX" fake_bin="${state_dir}/bin" docker_args="${state_dir}/docker-args" curl_bodies="${state_dir}/curl-bodies" +curl_args="${state_dir}/curl-args" cleanup() { rm -rf -- "${state_dir}" @@ -20,6 +21,7 @@ printf '%s\n' "${FAKE_PREPARED_TOOL_CALLS}" EOF cat > "${fake_bin}/curl" <<'EOF' #!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_CURL_ARGS}" while [ "$#" -gt 0 ]; do if [ "$1" = "--data" ]; then shift @@ -34,8 +36,9 @@ chmod +x "${fake_bin}/docker" "${fake_bin}/curl" export PATH="${fake_bin}:${PATH}" export FAKE_DOCKER_ARGS="${docker_args}" export FAKE_CURL_BODIES="${curl_bodies}" +export FAKE_CURL_ARGS="${curl_args}" export FAKE_PREPARED_TOOL_CALLS='[ - {"name":"first","arguments":{"region":"west"},"headers":{"Mcp-Param-Region":"west"}}, + {"name":"first","arguments":{"region":"west","empty_val":""},"headers":{"Mcp-Param-Region":"west","Mcp-Param-EmptyVal":""}}, {"name":"second","arguments":{"verbose":null},"headers":{}} ]' export MCP_CONFORMANCE_PROTOCOL_VERSION=2026-07-28 @@ -55,6 +58,8 @@ export MCP_CONFORMANCE_CONTEXT='{ grep --fixed-strings --quiet -- 'http://host.docker.internal:43123/mcp' "${docker_args}" grep --fixed-strings --quiet -- '["first","second"]' "${docker_args}" +grep --fixed-strings --quiet -- 'Mcp-Param-Region: west' "${curl_args}" +grep --fixed-strings --quiet -- 'Mcp-Param-EmptyVal;' "${curl_args}" test "$(wc -l < "${curl_bodies}" | tr -d '[:space:]')" -eq 2 jq --exit-status --slurp ' length == 2 and diff --git a/tests/conformance/client-under-test.sh b/tests/conformance/client-under-test.sh index 2b01bcb..125f0f6 100755 --- a/tests/conformance/client-under-test.sh +++ b/tests/conformance/client-under-test.sh @@ -57,9 +57,14 @@ while IFS= read -r tool_call; do tool_name="$(jq --exit-status --raw-output '.name' <<< "${tool_call}")" arguments="$(jq --exit-status --compact-output '.arguments' <<< "${tool_call}")" header_args=() - while IFS= read -r header; do - header_args+=(--header "${header}") - done < <(jq --exit-status --raw-output '.headers | to_entries[] | "\(.key): \(.value)"' <<< "${tool_call}") + while IFS=$'\t' read -r header_name header_value; do + if [ -z "${header_value}" ]; then + # curl's `Header:` form removes a header; `Header;` sends an empty value. + header_args+=(--header "${header_name};") + else + header_args+=(--header "${header_name}: ${header_value}") + fi + done < <(jq --exit-status --raw-output '.headers | to_entries[] | [.key, .value] | @tsv' <<< "${tool_call}") request="$(jq --null-input --compact-output \ --arg name "${tool_name}" \ --argjson arguments "${arguments}" \ @@ -82,16 +87,20 @@ while IFS= read -r tool_call; do } }')" - response="$(curl --silent --show-error --fail-with-body \ - --request POST \ - --header 'Content-Type: application/json' \ - --header 'Accept: application/json, text/event-stream' \ - --header "MCP-Protocol-Version: ${MCP_CONFORMANCE_PROTOCOL_VERSION}" \ - --header 'MCP-Method: tools/call' \ - --header "MCP-Name: ${tool_name}" \ - "${header_args[@]}" \ - --data "${request}" \ - "${endpoint}")" + if ! response="$(curl --silent --show-error --fail-with-body \ + --request POST \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --header "MCP-Protocol-Version: ${MCP_CONFORMANCE_PROTOCOL_VERSION}" \ + --header 'MCP-Method: tools/call' \ + --header "MCP-Name: ${tool_name}" \ + "${header_args[@]}" \ + --data "${request}" \ + "${endpoint}")"; then + echo "Dataplane HTTP request failed for client conformance tool call ${tool_name}:" >&2 + echo "${response}" >&2 + exit 1 + fi response_json="$(sed -n 's/^data: //p' <<< "${response}" | head -n 1)" if [ -z "${response_json}" ]; then diff --git a/tests/conformance/run-local.sh b/tests/conformance/run-local.sh index c167402..a9b4af3 100755 --- a/tests/conformance/run-local.sh +++ b/tests/conformance/run-local.sh @@ -78,8 +78,10 @@ cleanup() { } trap cleanup EXIT INT TERM -MCP_CONFORMANCE_TOKEN=pull-only \ - docker compose -f "${compose_file}" pull redis fixture-proxy control-plane nginx +if [ "${MCP_CONFORMANCE_SKIP_PULL:-false}" != "true" ]; then + MCP_CONFORMANCE_TOKEN=pull-only \ + docker compose -f "${compose_file}" pull redis fixture-proxy control-plane nginx +fi echo "Starting the fixture and control plane." MCP_CONFORMANCE_TOKEN=bootstrap-only \ "${script_dir}/start-fixture-and-control-plane.sh" diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index 4b7be14..5193626 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -7,6 +7,7 @@ import base64 import json import os +import urllib.error import urllib.request from urllib.parse import urlparse @@ -45,8 +46,13 @@ def fetch_tool_schemas(backend_url: str, tool_names: list[str]) -> dict[str, dic }, method="POST", ) - with urllib.request.urlopen(request, timeout=10) as response: - response_body = response.read().decode() + try: + with urllib.request.urlopen(request, timeout=10) as response: + response_body = response.read().decode() + except urllib.error.HTTPError as error: + if error.code in {400, 404, 405}: + return {} + raise messages = [ json.loads(line.removeprefix("data:").strip()) @@ -73,9 +79,6 @@ def fetch_tool_schemas(backend_url: str, tool_names: list[str]) -> dict[str, dic and tool.get("name") in tool_names and isinstance(tool.get("inputSchema"), dict) } - missing = sorted(set(tool_names) - schemas.keys()) - if missing: - raise SystemExit(f"tools/list omitted requested tool schemas: {missing}") return schemas @@ -103,7 +106,7 @@ def prepare_tool_calls( for tool_call in tool_calls: name = tool_call["name"] arguments = tool_call["arguments"] - properties = tool_schemas[name].get("properties", {}) + properties = tool_schemas.get(name, {}).get("properties", {}) headers = {} if isinstance(arguments, dict) and isinstance(properties, dict): for property_name, property_schema in properties.items(): From bc84824ad1c4bb71fbf50813dde61e1ee78ea858 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 12:12:36 +0100 Subject: [PATCH 06/16] test: expose client fixtures to control plane Signed-off-by: lucarlig --- tests/conformance/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conformance/docker-compose.yml b/tests/conformance/docker-compose.yml index d16e8fc..743397e 100644 --- a/tests/conformance/docker-compose.yml +++ b/tests/conformance/docker-compose.yml @@ -39,6 +39,8 @@ services: ports: - "127.0.0.1:4444:4444" networks: [contextforge] + extra_hosts: + - host.docker.internal:host-gateway environment: HOST: 0.0.0.0 PORT: "4444" From 622d7595dc387c381f707a93d826edeb3e85e678 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 13:23:45 +0100 Subject: [PATCH 07/16] refactor: require published tool schemas Signed-off-by: lucarlig --- _context/wiki/testing.md | 4 ---- crates/contextforge-data-plane-apis/src/user_store.rs | 1 - .../src/gateway/identifier_routing.rs | 3 +++ .../tests/gateway_plugins.rs | 9 ++++++--- schemas/user_config.json | 8 ++++---- tests/conformance/client-under-test-test.sh | 1 - tests/conformance/client-under-test.sh | 2 -- tests/conformance/run-local.sh | 6 ++---- tests/conformance/write_client_config.py | 10 ++-------- 9 files changed, 17 insertions(+), 27 deletions(-) diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 3d945fa..38d67d5 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -65,10 +65,6 @@ Base64 wrapping for `x-mcp-header` annotations. `make conformance` runs both legs locally, while `make conformance-bless` runs both and refreshes both expected-failure baselines from that run. -To exercise unpublished cross-repository changes, build both images locally and -run `tests/conformance/run-local.sh` with `CF_CONTROLPLANE_IMAGE`, -`CF_DATAPLANE_IMAGE`, and `MCP_CONFORMANCE_SKIP_PULL=true` so Compose does not -replace the local tags. Because this conformance CLI cannot set a bearer header, nginx adds an ephemeral control-plane token when one is absent; there is no auth proxy or diff --git a/crates/contextforge-data-plane-apis/src/user_store.rs b/crates/contextforge-data-plane-apis/src/user_store.rs index 2509e94..588f342 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -37,7 +37,6 @@ pub struct BackendMCPGateway { pub allowed_prompt_names: Vec, pub allowed_tool_names: Vec, /// Input schemas keyed by the original upstream tool name. - #[serde(default)] pub tool_schemas: HashMap>, } 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 fa4cd01..b41f989 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -152,6 +152,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" @@ -183,6 +184,7 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": ["get_stats"], + "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] }, @@ -191,6 +193,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/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index e8f8f5f..dcee690 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -431,7 +431,7 @@ async fn stateless_tool_call_uses_published_schema_without_backend_listing() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_lets_rmcp_encode_unsafe_parameter_headers() { +async fn stateless_tool_call_encodes_unsafe_parameter_headers() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let unsafe_value = " leading snowman ☃"; let encoded = format!("=?base64?{}?=", BASE64_STANDARD.encode(unsafe_value)); @@ -445,7 +445,7 @@ async fn stateless_tool_call_lets_rmcp_encode_unsafe_parameter_headers() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_lets_rmcp_omit_null_parameter_headers() { +async fn stateless_tool_call_omits_null_parameter_headers() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let response = raw_stateless_tool_call(&gateway, "optional_text", &json!({ "text": null })) .send() @@ -716,7 +716,7 @@ 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_arguments_and_derived_parameter_headers_without_rerouting_tool() { 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; @@ -725,6 +725,9 @@ async fn pre_hook_modifies_backend_arguments_without_rerouting_tool() { let service = gateway.connect(TEST_USER_ID).await; let result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap(); + // The backend RMCP service validates Mcp-Param-A/B against its tool schema + // before invoking the handler, so success proves the derived headers use + // the post-plugin arguments. assert_eq!((REWRITTEN_SUM_A + REWRITTEN_SUM_B).to_string(), text(&result)); let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned"); assert_eq!("sum", backend_calls[0].tool_name); diff --git a/schemas/user_config.json b/schemas/user_config.json index 1723d76..fde4c53 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -113,8 +113,7 @@ "additionalProperties": { "type": "object", "additionalProperties": true - }, - "default": {} + } } }, "required": [ @@ -123,8 +122,9 @@ "passthrough_headers", "allowed_resource_names", "allowed_prompt_names", - "allowed_tool_names" + "allowed_tool_names", + "tool_schemas" ] } } -} +} \ No newline at end of file diff --git a/tests/conformance/client-under-test-test.sh b/tests/conformance/client-under-test-test.sh index 19f3c42..9fa7084 100755 --- a/tests/conformance/client-under-test-test.sh +++ b/tests/conformance/client-under-test-test.sh @@ -57,7 +57,6 @@ export MCP_CONFORMANCE_CONTEXT='{ "${script_dir}/client-under-test.sh" "http://localhost:43123/mcp" grep --fixed-strings --quiet -- 'http://host.docker.internal:43123/mcp' "${docker_args}" -grep --fixed-strings --quiet -- '["first","second"]' "${docker_args}" grep --fixed-strings --quiet -- 'Mcp-Param-Region: west' "${curl_args}" grep --fixed-strings --quiet -- 'Mcp-Param-EmptyVal;' "${curl_args}" test "$(wc -l < "${curl_bodies}" | tr -d '[:space:]')" -eq 2 diff --git a/tests/conformance/client-under-test.sh b/tests/conformance/client-under-test.sh index 125f0f6..692687d 100755 --- a/tests/conformance/client-under-test.sh +++ b/tests/conformance/client-under-test.sh @@ -42,14 +42,12 @@ case "${MCP_CONFORMANCE_SCENARIO}" in ;; esac -tool_names="$(jq --exit-status --compact-output '[.[].name] | unique' <<< "${tool_calls}")" prepared_tool_calls="$(docker compose -f "${compose_file}" run --rm --no-deps \ --entrypoint python3 control-plane \ /opt/contextforge-conformance/write_client_config.py \ "${MCP_CONFORMANCE_SUBJECT}" \ "${virtual_host_id}" \ "${backend_url}" \ - "${tool_names}" \ "${tool_calls}")" endpoint="http://127.0.0.1:${conformance_port}/servers/${virtual_host_id}/mcp" diff --git a/tests/conformance/run-local.sh b/tests/conformance/run-local.sh index a9b4af3..c167402 100755 --- a/tests/conformance/run-local.sh +++ b/tests/conformance/run-local.sh @@ -78,10 +78,8 @@ cleanup() { } trap cleanup EXIT INT TERM -if [ "${MCP_CONFORMANCE_SKIP_PULL:-false}" != "true" ]; then - MCP_CONFORMANCE_TOKEN=pull-only \ - docker compose -f "${compose_file}" pull redis fixture-proxy control-plane nginx -fi +MCP_CONFORMANCE_TOKEN=pull-only \ + docker compose -f "${compose_file}" pull redis fixture-proxy control-plane nginx echo "Starting the fixture and control plane." MCP_CONFORMANCE_TOKEN=bootstrap-only \ "${script_dir}/start-fixture-and-control-plane.sh" diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index 5193626..cf41dc8 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -132,7 +132,6 @@ def parse_args() -> argparse.Namespace: parser.add_argument("subject") parser.add_argument("virtual_host_id") parser.add_argument("backend_url") - parser.add_argument("tool_names_json") parser.add_argument("tool_calls_json") return parser.parse_args() @@ -147,13 +146,6 @@ def main() -> None: if parsed_url.scheme not in {"http", "https"} or not parsed_url.hostname: raise SystemExit("backend_url must be an absolute HTTP(S) URL") - tool_names = json.loads(args.tool_names_json) - if ( - not isinstance(tool_names, list) - or not tool_names - or not all(isinstance(name, str) and name for name in tool_names) - ): - raise SystemExit("tool_names_json must be a non-empty JSON string array") tool_calls = json.loads(args.tool_calls_json) if ( not isinstance(tool_calls, list) @@ -161,11 +153,13 @@ def main() -> None: or not all( isinstance(tool_call, dict) and isinstance(tool_call.get("name"), str) + and bool(tool_call["name"]) and isinstance(tool_call.get("arguments"), dict) for tool_call in tool_calls ) ): raise SystemExit("tool_calls_json must be a non-empty tool-call array") + tool_names = sorted({tool_call["name"] for tool_call in tool_calls}) backend_name = "conformance-backend" tool_schemas = fetch_tool_schemas(args.backend_url, tool_names) From 66c434e0cf6f8783b69aec08dc9b34a529ccf182 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 13:52:49 +0100 Subject: [PATCH 08/16] fix: fail closed without published tool schemas Signed-off-by: lucarlig --- _context/wiki/security.md | 3 ++- .../src/gateway/mcp_service/initialization.rs | 4 +-- .../src/gateway/mcp_service/tools.rs | 6 ++++- .../src/layers/mcp_param_validation.rs | 12 ++++++--- .../tests/gateway_plugins.rs | 20 ++++++--------- .../tests/support/list_tools_gateway.rs | 20 ++++++++++++--- .../tests/support/plugin_gateway.rs | 25 +++++++++++-------- 7 files changed, 56 insertions(+), 34 deletions(-) diff --git a/_context/wiki/security.md b/_context/wiki/security.md index 4739b31..6374310 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -98,7 +98,8 @@ headers are never accepted through backend pass-through/add/remove policy. The control plane publishes each visible tool schema inside the subject-, virtual- host-, and backend-scoped Redis configuration. The innermost authenticated middleware resolves that request-scoped schema and returns HTTP `400` with -JSON-RPC `-32020` when an annotated parameter header is missing or mismatched. +JSON-RPC `-32020` when the routed tool schema is absent or an annotated +parameter header is missing or mismatched. After plugin rewrites, a per-request upstream HTTP client decorator derives `Mcp-Param-*` from the final arguments and the same backend-scoped schema. No schema is cached globally by bare tool name, and the dataplane does not call 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 a4f6da7..a5c481a 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 @@ -157,7 +157,7 @@ pub(super) async fn initialize( pub(super) async fn connect_backend_for_request( mcp_service: &McpService, backend: (&str, &BackendMCPGateway), - tool_name: Option<&str>, + tool_schema: Option<&JsonObject>, cx: &RequestContext, ) -> Result, ErrorData> { let (backend_name, backend) = backend; @@ -178,7 +178,7 @@ pub(super) async fn connect_backend_for_request( apply_header_config(&mut headers, backend, downstream_headers); crate::telemetry::inject_current_context(&mut headers); - let tool_schema = tool_name.and_then(|tool_name| backend.tool_schemas.get(tool_name)).cloned().map(Arc::new); + let tool_schema = tool_schema.cloned().map(Arc::new); let config = StreamableHttpClientTransportConfig::with_uri(backend.url.to_string()).custom_headers(headers); let client = McpParamHttpClient::new(mcp_service.http_client.clone(), tool_schema); let transport = StreamableHttpClientTransport::with_client(client, config); diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index 191d0a3..e6613d9 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -36,6 +36,10 @@ pub(super) async fn call_tool( message: "Routing problem... backend not found".into(), data: None, })?; + let tool_schema = backend + .tool_schemas + .get(&tool_name) + .ok_or_else(|| ErrorData::internal_error(format!("Missing published schema for tool '{tool_name}'"), None))?; let service_name = backend_name.clone(); let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime { plugin_runtime.before_tool_call(&request, &tool_name, &service_name).await? @@ -46,7 +50,7 @@ pub(super) async fn call_tool( let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &tool_name); let mut backend_service = - connect_backend_for_request(mcp_service, (&backend_name, backend), Some(&tool_name), &cx).await?; + connect_backend_for_request(mcp_service, (&backend_name, backend), Some(tool_schema), &cx).await?; let progress_token = cx.meta.get_progress_token(); let handle = backend_service 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 index 79b7abc..4c556e0 100644 --- a/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs +++ b/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs @@ -47,10 +47,14 @@ fn validation_error(parts: &http::request::Parts, body: &[u8]) -> Option = 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 tool_schema = virtual_host.backends.get(backend_name)?.tool_schemas.get(tool_name)?; - let reason = - mcp_standard_headers::validate_tool_params(&parts.headers, tool_call.params.arguments.as_ref(), tool_schema) - .err()?; + 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"); diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index dcee690..873e4b5 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -507,19 +507,15 @@ async fn stateless_tool_call_with_mismatched_parameter_header_is_rejected_before } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_error_round_trips() { +async fn stateless_tool_call_without_published_schema_is_rejected_before_backend() { 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_tool")).await.unwrap_err(); - let rmcp::service::ServiceError::McpError(error) = error else { - panic!("expected backend MCP error, got {error:?}"); - }; - assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); + let response = + raw_stateless_tool_call(&gateway, "missing_tool", &json!({})).send().await.expect("request reaches gateway"); + + assert_eq!(http::StatusCode::BAD_REQUEST, response.status()); + let body: serde_json::Value = response.json().await.expect("gateway returns a 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)] 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 aa942e8..a9b076b 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 @@ -8,8 +8,11 @@ use contextforge_data_plane_lib::{ Config, Gateway, Result, UpstreamConnectionMode, UserConfigStore, UserConfigStoreType, }; use futures::{FutureExt, future::BoxFuture}; -use rmcp::transport::{ - StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, +use rmcp::{ + ServerHandler, + transport::{ + StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, + }, }; use tracing::warn; @@ -231,7 +234,7 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap HashMap HashMap { + let counter = mock_counter::Counter::new(); + MOCK_COUNTER_TOOL_NAMES + .iter() + .map(|name| { + let tool = counter.get_tool(name).expect("mock counter tool exists"); + ((*name).to_owned(), tool.input_schema.as_ref().clone()) + }) + .collect() +} + fn backend_id(port: u16) -> String { format!("00000000-0000-0000-0000-{port:012}") } 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 8bb7e3f..6d032d3 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -105,11 +105,19 @@ fn optional_text_tool() -> Tool { Tool::new("optional_text", "Accept optional text", input_schema) } +fn tools() -> Vec { + vec![ + sum_tool(), + reflect_text_tool(), + optional_text_tool(), + Tool::new("progress_sum", "Report progress", Map::new()), + Tool::new("progress_counter_tokens", "Report progress with generated tokens", Map::new()), + Tool::new("wait_for_cancellation", "Wait for cancellation", Map::new()), + ] +} + fn published_tool_schemas() -> HashMap> { - [sum_tool(), reflect_text_tool(), optional_text_tool()] - .into_iter() - .map(|tool| (tool.name.to_string(), tool.input_schema.as_ref().clone())) - .collect() + tools().into_iter().map(|tool| (tool.name.to_string(), tool.input_schema.as_ref().clone())).collect() } #[allow(clippy::unused_async_trait_impl)] @@ -162,16 +170,11 @@ impl ServerHandler for TestBackend { _cx: RequestContext, ) -> Result { self.state.list_tool_calls.fetch_add(1, Ordering::Relaxed); - Ok(ListToolsResult::with_all_items(vec![sum_tool(), reflect_text_tool(), optional_text_tool()])) + Ok(ListToolsResult::with_all_items(tools())) } fn get_tool(&self, name: &str) -> Option { - match name { - "sum" => Some(sum_tool()), - "reflect_text" => Some(reflect_text_tool()), - "optional_text" => Some(optional_text_tool()), - _ => None, - } + tools().into_iter().find(|tool| tool.name == name) } async fn call_tool( From d9b6e759559548c9f212e96e6c76fddea31fa9ca Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 14:29:41 +0100 Subject: [PATCH 09/16] fix: satisfy Rust 1.98 clippy Signed-off-by: lucarlig --- .secrets.baseline | 174 +++++++++--------- .../src/handle.rs | 3 + .../tests/support/mod.rs | 2 + 3 files changed, 92 insertions(+), 87 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index b9b5eff..91df2e0 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,9 +1,9 @@ { "exclude": { - "files": "(?x)(Cargo\\.lock$|\\.lock$)|^\\.secrets\\.baseline$|^.secrets.baseline$", + "files": "(?x)(Cargo\\.lock$|\\.lock$|target/|^\\.secrets\\.baseline$)", "lines": null }, - "generated_at": "2026-08-25T17:14:29Z", + "generated_at": "2026-08-26T08:45:37Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -80,371 +80,371 @@ "assets/contextforgeCA/contextforge-client.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "assets/contextforgeCA/contextforge-server.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "assets/contextforgeCA/contextforge.ca.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "assets/contextforgeCA/contextforge.intermediate.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "assets/jwt.key": [ { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "assets/tls_key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/contextforge-data-plane-lib/src/common.rs": [ { "hashed_secret": "4a4645604f0b9e29503be96a87f6f47a6e4a7890", - "is_secret": false, "is_verified": false, "line_number": 154, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "427f5e1b530d4a544883308d876a11d724060c86", - "is_secret": false, "is_verified": false, "line_number": 157, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad", - "is_secret": false, "is_verified": false, "line_number": 263, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/contextforge-data-plane-lib/src/telemetry.rs": [ { "hashed_secret": "0a24796d4c71ce722a92f450f69dc36c60b21de4", - "is_secret": false, "is_verified": false, "line_number": 87, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/contextforge-data-plane-lib/tests/support/client.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", - "is_secret": false, "is_verified": false, "line_number": 12, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/contextforge-data-plane-lib/tests/support/mod.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", - "is_secret": false, "is_verified": false, - "line_number": 17, + "line_number": 19, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/contextforge-data-plane/Cargo.toml": [ { "hashed_secret": "58e7dc38ba3a7d4a720006d2f3cc4cda774d89dc", - "is_secret": false, "is_verified": false, "line_number": 20, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/src/lib.rs": [ { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, "is_verified": false, "line_number": 610, "type": "AWS Access Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/src/scanner.rs": [ { "hashed_secret": "9249e2590f5d19742260cb5296cb76fe0677f147", - "is_secret": false, "is_verified": false, "line_number": 238, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "199da8f71b7dced64f82cf6e96483134cace9b14", - "is_secret": false, "is_verified": false, "line_number": 239, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "c0026c4c848882618c987859077ffbae92130625", - "is_secret": false, "is_verified": false, "line_number": 242, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "078553dc10635837abb80f404302c70cba91b879", - "is_secret": false, "is_verified": false, "line_number": 278, "type": "Base64 High Entropy String", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "e175c6f5f2a92e8623bd9a4820edb4e8c1b0fd10", - "is_secret": false, "is_verified": false, "line_number": 278, "type": "GitHub Token", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "97d99a51e5ac827bb36fe6273facfda35245917a", - "is_secret": false, "is_verified": false, "line_number": 279, "type": "Base64 High Entropy String", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, "is_verified": false, "line_number": 282, "type": "Private Key", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "b1775a785f09a6ebaf2dc33d6eaeb98974d9cdb8", - "is_secret": false, "is_verified": false, "line_number": 284, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "eae9124e42e2ef05ba727bd1a1c0c6fa61a05b9e", - "is_secret": false, "is_verified": false, "line_number": 302, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, "is_verified": false, "line_number": 401, "type": "AWS Access Key", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "9d7235fe33b6612ed7ebca4b63afd00d4adf5d66", - "is_secret": false, "is_verified": false, "line_number": 410, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "27a39044bff80a4c196689dfa8dcf129cb27fef8", - "is_secret": false, "is_verified": false, "line_number": 431, "type": "Base64 High Entropy String", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/tests/plugin_manager.rs": [ { "hashed_secret": "436da7d4d22c39c0165ab0d5b40073d0f2fc11c5", - "is_secret": false, "is_verified": false, "line_number": 197, "type": "AWS Access Key", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "8b4510a576d82f38bd2730436bf5e20c4e15b30e", - "is_secret": false, "is_verified": false, "line_number": 198, "type": "AWS Access Key", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "e4ea017859bcad962c8ab551fe29da9147877eee", - "is_secret": false, "is_verified": false, "line_number": 199, "type": "AWS Access Key", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, "is_verified": false, "line_number": 268, "type": "AWS Access Key", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "docker/docker-compose-langfuse.yaml": [ { "hashed_secret": "cb1fde0682fbd1ac0faf2a9f297167ac9d06434b", - "is_secret": false, "is_verified": false, "line_number": 16, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "cb58df830a45cc33df1a313e616ecad78cd796c5", - "is_secret": false, "is_verified": false, "line_number": 77, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "2e0c522bfe4e7885492862df2e0b987c0ca02623", - "is_secret": false, "is_verified": false, "line_number": 100, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "d9d007c8de197b3f36a3a0ba4f13c0f7df175d5a", - "is_secret": false, "is_verified": false, "line_number": 255, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "docker/docker-compose.yml": [ { "hashed_secret": "2a8bfc0ce436d55ca907d0162989481bcb7677b4", - "is_secret": false, "is_verified": false, "line_number": 189, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "fdda45b7f6d2ead95d9991fc4678640c3bab0d84", - "is_secret": false, "is_verified": false, "line_number": 363, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "093d378410a5cfa4bd5088f3fef62fbdb8a95665", - "is_secret": false, "is_verified": false, "line_number": 369, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "c3de40d5e3fc71ed62771c2127a8e42585026c97", - "is_secret": false, "is_verified": false, "line_number": 371, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "4d4acd9b084d13f5fdb23807d857e1c48a1cfd0f", - "is_secret": false, "is_verified": false, "line_number": 460, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "bd0160c2cf35d950843c88f3be2b9412ed71f485", - "is_secret": false, "is_verified": false, "line_number": 495, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false }, { "hashed_secret": "293324f6824bb3a6db5c4dc42a60ddd4a9851c99", - "is_secret": false, "is_verified": false, "line_number": 658, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": null, + "is_secret": false } ], "scripts/git/resolve-secrets-baseline-conflict.sh": [ { "hashed_secret": "44ffd1bfb94772d5f91d528e7aca703990edbbd7", - "is_secret": false, "is_verified": false, "line_number": 31, "type": "Secret Keyword", - "verified_result": null + "verified_result": null, + "is_secret": false } ] }, diff --git a/crates/contextforge-data-plane-cpex/src/handle.rs b/crates/contextforge-data-plane-cpex/src/handle.rs index a2f504a..a714b6d 100644 --- a/crates/contextforge-data-plane-cpex/src/handle.rs +++ b/crates/contextforge-data-plane-cpex/src/handle.rs @@ -331,6 +331,9 @@ fn runtime_failed_error(state: &RuntimeState) -> ErrorData { #[cfg(test)] mod tests { + #![allow(unknown_lints, reason = "Rust 1.96 predates unused_async_trait_impl")] + #![allow(clippy::unused_async_trait_impl, reason = "test plugins implement async interfaces synchronously")] + use std::{ collections::HashMap, sync::{ diff --git a/crates/contextforge-data-plane-lib/tests/support/mod.rs b/crates/contextforge-data-plane-lib/tests/support/mod.rs index ffb1ae2..09043ef 100644 --- a/crates/contextforge-data-plane-lib/tests/support/mod.rs +++ b/crates/contextforge-data-plane-lib/tests/support/mod.rs @@ -1,3 +1,5 @@ +#![allow(unknown_lints, reason = "Rust 1.96 predates unused_async_trait_impl")] +#![allow(clippy::unused_async_trait_impl, reason = "test fixtures implement async interfaces synchronously")] #![allow(dead_code, unused_imports, reason = "shared CPEX test fixture is used by separate integration test targets")] mod auth; From 788a59d9d6f9706c8e82adbafcb68c0dfeffe19d Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 15:43:20 +0100 Subject: [PATCH 10/16] refactor: forward validated MCP parameter headers Signed-off-by: lucarlig --- .secrets.baseline | 2 +- Cargo.lock | 1 - _context/wiki/architecture.md | 2 +- _context/wiki/security.md | 10 +- crates/contextforge-data-plane-lib/Cargo.toml | 1 - .../src/gateway/mcp_service/initialization.rs | 160 +++--------------- .../src/gateway/mcp_service/prompts.rs | 2 +- .../src/gateway/mcp_service/resources.rs | 2 +- .../src/gateway/mcp_service/tools.rs | 7 +- .../src/mcp_standard_headers.rs | 75 ++------ .../tests/gateway_plugins.rs | 52 +++--- .../tests/support/mod.rs | 6 +- .../tests/support/plugin_gateway.rs | 47 ++++- tests/conformance/client-under-test.sh | 6 + tests/conformance/write_client_config.py | 16 +- 15 files changed, 143 insertions(+), 246 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 91df2e0..f8f965d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(Cargo\\.lock$|\\.lock$|target/|^\\.secrets\\.baseline$)", "lines": null }, - "generated_at": "2026-08-26T08:45:37Z", + "generated_at": "2026-08-26T08:49:39Z", "plugins_used": [ { "name": "AWSKeyDetector" diff --git a/Cargo.lock b/Cargo.lock index 4f4ef83..bc874d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,7 +640,6 @@ dependencies = [ "secret-string", "serde", "serde_json", - "sse-stream", "test-log", "thiserror 2.0.19", "tokio", diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index 437852f..85885c1 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. Tool schemas published by the control plane in `UserConfig` let the dataplane validate downstream `Mcp-Param-*` values and derive the upstream values from the final routed arguments without calling backend `tools/list`. A request-aware HTTP client decorator adds only those parameter headers; RMCP continues to generate the method, name, and protocol-version headers. +- `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. Tool schemas published by the control plane in `UserConfig` let the dataplane validate downstream `Mcp-Param-*` values without calling backend `tools/list`. The validated parameter headers pass through unchanged; RMCP regenerates the method, routed name, and protocol-version headers. Plugins are responsible for preserving arguments designated by `x-mcp-header`. - `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 6374310..5d50106 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -100,10 +100,12 @@ host-, and backend-scoped Redis configuration. The innermost authenticated middleware resolves that request-scoped schema and returns HTTP `400` with JSON-RPC `-32020` when the routed tool schema is absent or an annotated parameter header is missing or mismatched. -After plugin rewrites, a per-request upstream HTTP client decorator derives -`Mcp-Param-*` from the final arguments and the same backend-scoped schema. No -schema is cached globally by bare tool name, and the dataplane does not call -backend `tools/list` as part of `tools/call`. +Validated `Mcp-Param-*` headers are forwarded unchanged outside backend header +configuration, while RMCP regenerates method, routed-name, and protocol-version +headers. Plugins are trusted and must preserve arguments designated by +`x-mcp-header`; an inconsistent plugin rewrite is rejected by the upstream MCP +server. No schema is cached globally by bare tool name, and the dataplane does +not call backend `tools/list` as part of `tools/call`. ## Local Bootstrap Helpers (`with_tools`) diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index f4ea71f..0d21d27 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -37,7 +37,6 @@ rmp-serde.workspace = true async-trait.workspace = true reqwest.workspace = true base64 = "0.22.1" -sse-stream = "0.2.5" uuid.workspace = true lru_time_cache = "0.11.11" hyper-util = "0.1.20" 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 a5c481a..a75375b 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 @@ -1,144 +1,23 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use contextforge_data_plane_apis::user_store::BackendMCPGateway; -use futures::stream::BoxStream; -use http::{HeaderName, HeaderValue, request::Parts}; +use http::request::Parts; use rmcp::{ ClientLifecycleMode, ErrorData, RoleClient, RoleServer, model::{ - ClientCapabilities, ClientJsonRpcMessage, ClientRequest, ErrorCode, Implementation, InitializeRequestParams, - InitializeResult, JsonObject, ProtocolVersion, ServerCapabilities, + ClientCapabilities, ErrorCode, Implementation, InitializeRequestParams, InitializeResult, ProtocolVersion, + ServerCapabilities, }, service::serve_client_with_lifecycle_and_ct, service::{RequestContext, RunningService}, - transport::{ - StreamableHttpClientTransport, - streamable_http_client::{ - SseError, StreamableHttpClient, StreamableHttpClientTransportConfig, StreamableHttpError, - StreamableHttpPostResponse, - }, - }, + transport::{StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig}, }; -use sse_stream::Sse; use tracing::warn; use super::McpService; use crate::gateway::backend_client::GatewayBackendClient; use crate::mcp_standard_headers; -#[derive(Clone)] -struct McpParamHttpClient { - inner: reqwest::Client, - tool_schema: Option>, -} - -impl McpParamHttpClient { - fn new(inner: reqwest::Client, tool_schema: Option>) -> Self { - Self { inner, tool_schema } - } - - fn insert_tool_params( - &self, - message: &ClientJsonRpcMessage, - headers: &mut HashMap, - ) -> Result<(), StreamableHttpError> { - let Some(tool_schema) = self.tool_schema.as_deref() else { - return Ok(()); - }; - let ClientJsonRpcMessage::Request(request) = message else { - return Ok(()); - }; - let ClientRequest::CallToolRequest(request) = &request.request else { - return Ok(()); - }; - mcp_standard_headers::insert_tool_params(headers, request.params.arguments.as_ref(), tool_schema).map_err( - |error| { - StreamableHttpError::UnexpectedServerResponse(format!("invalid published tool schema: {error}").into()) - }, - ) - } -} - -impl StreamableHttpClient for McpParamHttpClient { - type Error = reqwest::Error; - - async fn post_message( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_header: Option, - mut custom_headers: HashMap, - ) -> Result> { - self.insert_tool_params(&message, &mut custom_headers)?; - self.inner.post_message(uri, message, session_id, auth_header, custom_headers).await - } - - async fn post_message_with_max_sse_event_size( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_header: Option, - mut custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result> { - self.insert_tool_params(&message, &mut custom_headers)?; - self.inner - .post_message_with_max_sse_event_size( - uri, - message, - session_id, - auth_header, - custom_headers, - max_sse_event_size, - ) - .await - } - - async fn delete_session( - &self, - uri: Arc, - session_id: Arc, - auth_header: Option, - custom_headers: HashMap, - ) -> Result<(), StreamableHttpError> { - self.inner.delete_session(uri, session_id, auth_header, custom_headers).await - } - - async fn get_stream( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_header: Option, - custom_headers: HashMap, - ) -> Result>, StreamableHttpError> { - self.inner.get_stream(uri, session_id, last_event_id, auth_header, custom_headers).await - } - - async fn get_stream_with_max_sse_event_size( - &self, - uri: Arc, - session_id: Option>, - last_event_id: Option, - auth_header: Option, - custom_headers: HashMap, - max_sse_event_size: usize, - ) -> Result>, StreamableHttpError> { - self.inner - .get_stream_with_max_sse_event_size( - uri, - session_id, - last_event_id, - auth_header, - custom_headers, - max_sse_event_size, - ) - .await - } -} - #[allow(clippy::unused_async)] pub(super) async fn initialize( _svc: &McpService, @@ -157,7 +36,6 @@ pub(super) async fn initialize( pub(super) async fn connect_backend_for_request( mcp_service: &McpService, backend: (&str, &BackendMCPGateway), - tool_schema: Option<&JsonObject>, cx: &RequestContext, ) -> Result, ErrorData> { let (backend_name, backend) = backend; @@ -176,12 +54,11 @@ pub(super) async fn connect_backend_for_request( } apply_header_config(&mut headers, backend, downstream_headers); + forward_mcp_param_headers(&mut headers, downstream_headers); crate::telemetry::inject_current_context(&mut headers); - let tool_schema = tool_schema.cloned().map(Arc::new); let config = StreamableHttpClientTransportConfig::with_uri(backend.url.to_string()).custom_headers(headers); - let client = McpParamHttpClient::new(mcp_service.http_client.clone(), tool_schema); - let transport = StreamableHttpClientTransport::with_client(client, config); + let transport = StreamableHttpClientTransport::with_client(mcp_service.http_client.clone(), config); let client_info = InitializeRequestParams::new( ClientCapabilities::default(), Implementation::new("contextforge-data-plane", env!("CARGO_PKG_VERSION")), @@ -209,6 +86,19 @@ pub(super) async fn connect_backend_for_request( }) } +fn forward_mcp_param_headers( + headers: &mut HashMap, + downstream: Option<&http::HeaderMap>, +) { + let Some(downstream) = downstream else { return }; + headers.extend( + downstream + .iter() + .filter(|(name, _)| mcp_standard_headers::is_param(name)) + .map(|(name, value)| (name.clone(), value.clone())), + ); +} + /// Apply a backend's header config to the upstream header map. fn apply_header_config( headers: &mut HashMap, @@ -252,6 +142,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-*` +/// +/// Validated downstream `Mcp-Param-*` headers are forwarded separately and cannot be changed by backend config. fn is_protected_header(name: &http::HeaderName) -> bool { const PROTECTED: &[&str] = &[ "host", @@ -402,15 +294,14 @@ mod tests { } #[test] - fn computed_mcp_headers_cannot_be_passed_through_added_or_removed() { + fn validated_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"], @@ -424,9 +315,10 @@ mod tests { ); apply_header_config(&mut headers, &cfg, Some(&ds)); + forward_mcp_param_headers(&mut headers, 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/mcp_service/prompts.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs index 377b8c9..ddd877b 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs @@ -43,7 +43,7 @@ pub(super) async fn get_prompt( } else { PromptPreFetchResult::unchanged() }; - let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), None, &cx).await?; + let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), &cx).await?; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &prompt_name); let response = backend_service.get_prompt(routed_request).await; diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs index 32f019b..d2c188d 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs @@ -39,7 +39,7 @@ pub(super) async fn read_resource( })?; let service_name = backend_name.clone(); - let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), None, &cx).await?; + let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), &cx).await?; let mut routed_request = request; routed_request.uri = resource_uri; diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index e6613d9..b0a44b3 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -36,10 +36,6 @@ pub(super) async fn call_tool( message: "Routing problem... backend not found".into(), data: None, })?; - let tool_schema = backend - .tool_schemas - .get(&tool_name) - .ok_or_else(|| ErrorData::internal_error(format!("Missing published schema for tool '{tool_name}'"), None))?; let service_name = backend_name.clone(); let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime { plugin_runtime.before_tool_call(&request, &tool_name, &service_name).await? @@ -49,8 +45,7 @@ pub(super) async fn call_tool( let post_state = pre_result.state; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &tool_name); - let mut backend_service = - connect_backend_for_request(mcp_service, (&backend_name, backend), Some(tool_schema), &cx).await?; + let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), &cx).await?; let progress_token = cx.meta.get_progress_token(); let handle = backend_service 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 50b64df..724ff07 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,7 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use base64::{Engine, prelude::BASE64_STANDARD}; -use http::{HeaderMap, HeaderName, HeaderValue}; +use http::{HeaderMap, HeaderName}; use rmcp::model::ProtocolVersion; use rmcp::transport::common::http_header::{ BASE64_HEADER_PREFIX, BASE64_HEADER_SUFFIX, HEADER_MCP_METHOD, HEADER_MCP_NAME, HEADER_MCP_PARAM_PREFIX, @@ -37,7 +37,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)) @@ -75,26 +75,6 @@ pub(crate) fn validate_tool_params( Ok(()) } -/// Add SEP-2243 parameter headers for a routed upstream tool call. -pub(crate) fn insert_tool_params( - headers: &mut HashMap, - arguments: Option<&JsonObject>, - input_schema: &JsonObject, -) -> Result<(), String> { - for (property, annotation) in param_header_annotations(input_schema)? { - let Some(value) = arguments.and_then(|arguments| arguments.get(&property)).and_then(primitive_to_string) else { - continue; - }; - let header_name = format!("{HEADER_MCP_PARAM_PREFIX}{annotation}"); - let header_name = HeaderName::from_bytes(header_name.as_bytes()) - .map_err(|error| format!("invalid parameter header name: {error}"))?; - let header_value = HeaderValue::from_str(&encode_header_value(&value)) - .map_err(|error| format!("invalid parameter header value: {error}"))?; - headers.insert(header_name, header_value); - } - Ok(()) -} - fn param_header_annotations(input_schema: &JsonObject) -> Result, String> { let Some(Value::Object(properties)) = input_schema.get("properties") else { return Ok(Vec::new()); @@ -155,14 +135,6 @@ fn primitive_to_string(value: &Value) -> Option { } } -fn encode_header_value(value: &str) -> String { - if requires_base64(value) { - format!("{BASE64_HEADER_PREFIX}{}{BASE64_HEADER_SUFFIX}", BASE64_STANDARD.encode(value)) - } else { - value.to_owned() - } -} - 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(), @@ -170,18 +142,6 @@ fn decode_header_value(value: &str) -> Option { } } -fn requires_base64(value: &str) -> bool { - if value.is_empty() { - return false; - } - let bytes = value.as_bytes(); - if matches!(bytes.first(), Some(b' ' | b'\t')) || matches!(bytes.last(), Some(b' ' | b'\t')) { - return true; - } - value.chars().any(|character| !(0x20..=0x7e).contains(&(character as u32))) - || value.starts_with(BASE64_HEADER_PREFIX) && value.ends_with(BASE64_HEADER_SUFFIX) -} - fn is_tchar(character: char) -> bool { character.is_ascii_alphanumeric() || matches!(character, '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~') @@ -189,6 +149,7 @@ fn is_tchar(character: char) -> bool { #[cfg(test)] mod tests { + use http::HeaderValue; use serde_json::json; use super::*; @@ -208,22 +169,17 @@ mod tests { } #[test] - fn parameter_headers_round_trip_primitives_and_unsafe_values() { + 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 mut headers = HashMap::new(); - - insert_tool_params(&mut headers, Some(arguments), &schema()).expect("headers are generated"); - let headers: HeaderMap = headers.into_iter().collect(); - - assert!( - headers - .get("Mcp-Param-Region") - .expect("region header") - .to_str() - .expect("header string") - .starts_with(BASE64_HEADER_PREFIX) - ); + 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"); } @@ -231,10 +187,7 @@ mod tests { fn null_parameter_is_omitted_and_rejected_when_present() { let arguments = json!({ "region": null }); let arguments = arguments.as_object().expect("object arguments"); - let mut headers = HashMap::new(); - - insert_tool_params(&mut headers, Some(arguments), &schema()).expect("headers are generated"); - assert!(!headers.contains_key("Mcp-Param-Region")); + 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"), diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 873e4b5..8c3d541 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -21,9 +21,9 @@ use serde_json::{Map, Value, json}; use support::{ BACKEND_PROMPT_IMAGE, BACKEND_PROMPT_RESOURCE, POST_DENY_ERROR_CODE, PRE_DENY_ERROR_CODE, PROMPT_ERROR_MESSAGE, 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, + REWRITTEN_PROMPT_TOPIC, 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, start_gateway_with_parameter_headers, sum_request, text, token, }; type Recorded = Arc>>; @@ -413,8 +413,9 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_uses_published_schema_without_backend_listing() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; +async fn stateless_tool_call_forwards_validated_parameter_headers_without_backend_listing() { + 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", "1") .header("Mcp-Param-B", "2") @@ -431,8 +432,9 @@ async fn stateless_tool_call_uses_published_schema_without_backend_listing() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_encodes_unsafe_parameter_headers() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; +async fn stateless_tool_call_forwards_encoded_parameter_headers() { + let gateway = + start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let unsafe_value = " leading snowman ☃"; let encoded = format!("=?base64?{}?=", BASE64_STANDARD.encode(unsafe_value)); let response = raw_stateless_tool_call(&gateway, "reflect_text", &json!({ "text": unsafe_value })) @@ -446,7 +448,8 @@ async fn stateless_tool_call_encodes_unsafe_parameter_headers() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_omits_null_parameter_headers() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let gateway = + start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let response = raw_stateless_tool_call(&gateway, "optional_text", &json!({ "text": null })) .send() .await @@ -457,7 +460,8 @@ async fn stateless_tool_call_omits_null_parameter_headers() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_without_protocol_version_header_is_rejected_before_backend() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let gateway = + start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let response = support::create_client(TEST_USER_ID) .post(gateway.gateway_url()) .header(http::header::ACCEPT, "application/json, text/event-stream") @@ -492,7 +496,8 @@ async fn stateless_tool_call_without_protocol_version_header_is_rejected_before_ #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_with_mismatched_parameter_header_is_rejected_before_backend() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + 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") @@ -712,22 +717,27 @@ async fn secrets_detection_pre_hook_respects_field_allowlist() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn pre_hook_rewrites_arguments_and_derived_parameter_headers_without_rerouting_tool() { +async fn pre_hook_that_rewrites_header_designated_arguments_is_rejected_by_backend() { 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 result = service.call_tool(sum_request("sum", 1, 2)).await.unwrap(); + let gateway = start_gateway_with_parameter_headers(TEST_USER_ID, true, runtime).await; + let response = raw_stateless_tool_call(&gateway, "sum", &json!({ "a": 1, "b": 2 })) + .header("Mcp-Param-A", "1") + .header("Mcp-Param-B", "2") + .send() + .await + .expect("plugin-modified request reaches backend"); - // The backend RMCP service validates Mcp-Param-A/B against its tool schema - // before invoking the handler, so success proves the derived headers use - // the post-plugin arguments. - assert_eq!((REWRITTEN_SUM_A + REWRITTEN_SUM_B).to_string(), text(&result)); - 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"))); + assert_eq!(http::StatusCode::OK, response.status()); + let body = response.text().await.expect("gateway response body"); + let messages = sse_data_values(&body); + assert_eq!( + Some(i64::from(ErrorCode::HEADER_MISMATCH.0)), + messages.iter().find_map(|message| message["error"]["code"].as_i64()) + ); + assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); let observations = observations.lock().expect("observations lock poisoned"); assert_eq!(1, observations.pre_calls); diff --git a/crates/contextforge-data-plane-lib/tests/support/mod.rs b/crates/contextforge-data-plane-lib/tests/support/mod.rs index 09043ef..c4c9942 100644 --- a/crates/contextforge-data-plane-lib/tests/support/mod.rs +++ b/crates/contextforge-data-plane-lib/tests/support/mod.rs @@ -27,12 +27,12 @@ pub(crate) use list_tools_gateway::{ }; pub(crate) use plugin::{ POST_DENY_ERROR_CODE, PRE_DENY_ERROR_CODE, PROMPT_ERROR_MESSAGE, PROMPT_POST_DENY_ERROR_CODE, PromptBehavior, - PromptTestPlugin, REWRITTEN_PROMPT_RESOURCE, REWRITTEN_PROMPT_TEXT, REWRITTEN_PROMPT_TOPIC, REWRITTEN_SUM_A, - REWRITTEN_SUM_B, TestPlugin, TestPluginFactory, + PromptTestPlugin, REWRITTEN_PROMPT_RESOURCE, REWRITTEN_PROMPT_TEXT, REWRITTEN_PROMPT_TOPIC, TestPlugin, + TestPluginFactory, }; pub(crate) use plugin_gateway::{ BACKEND_PROMPT_IMAGE, BACKEND_PROMPT_RESOURCE, RunningGateway, start_gateway, start_gateway_with_events, - start_gateway_with_json_backend_responses, + start_gateway_with_json_backend_responses, start_gateway_with_parameter_headers, }; pub(crate) use runtime::{runtime_with_post, runtime_with_pre, runtime_with_pre_and_post, runtime_with_prompt_plugin}; pub(crate) use tool::{error_code, error_parts, sum_request, text}; 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 6d032d3..04851fb 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -56,6 +56,7 @@ pub(crate) struct BackendState { pub(crate) prompts: Arc>>, pub(crate) cancellations: Arc>>, pub(crate) events: Arc>>, + parameter_headers: bool, } #[derive(Clone)] @@ -105,19 +106,33 @@ fn optional_text_tool() -> Tool { Tool::new("optional_text", "Accept optional text", input_schema) } -fn tools() -> Vec { - vec![ +fn tools(parameter_headers: bool) -> Vec { + let mut tools = vec![ sum_tool(), reflect_text_tool(), optional_text_tool(), Tool::new("progress_sum", "Report progress", Map::new()), Tool::new("progress_counter_tokens", "Report progress with generated tokens", Map::new()), Tool::new("wait_for_cancellation", "Wait for cancellation", Map::new()), - ] + ]; + if !parameter_headers { + for tool in &mut tools { + let schema = Arc::make_mut(&mut tool.input_schema); + if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) { + for property in properties.values_mut().filter_map(Value::as_object_mut) { + property.remove("x-mcp-header"); + } + } + } + } + tools } -fn published_tool_schemas() -> HashMap> { - tools().into_iter().map(|tool| (tool.name.to_string(), tool.input_schema.as_ref().clone())).collect() +fn published_tool_schemas(parameter_headers: bool) -> HashMap> { + tools(parameter_headers) + .into_iter() + .map(|tool| (tool.name.to_string(), tool.input_schema.as_ref().clone())) + .collect() } #[allow(clippy::unused_async_trait_impl)] @@ -170,11 +185,11 @@ impl ServerHandler for TestBackend { _cx: RequestContext, ) -> Result { self.state.list_tool_calls.fetch_add(1, Ordering::Relaxed); - Ok(ListToolsResult::with_all_items(tools())) + Ok(ListToolsResult::with_all_items(tools(self.state.parameter_headers))) } fn get_tool(&self, name: &str) -> Option { - tools().into_iter().find(|tool| tool.name == name) + tools(self.state.parameter_headers).into_iter().find(|tool| tool.name == name) } async fn call_tool( @@ -337,6 +352,21 @@ 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, ..BackendState::default() }, + ) + .await +} + pub(crate) async fn start_gateway_with_events( user: &str, plugin_runtime: Arc, @@ -384,6 +414,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( { @@ -412,7 +443,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(), + 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/tests/conformance/client-under-test.sh b/tests/conformance/client-under-test.sh index 692687d..58a5f71 100755 --- a/tests/conformance/client-under-test.sh +++ b/tests/conformance/client-under-test.sh @@ -50,6 +50,12 @@ prepared_tool_calls="$(docker compose -f "${compose_file}" run --rm --no-deps \ "${backend_url}" \ "${tool_calls}")" +# Schema discovery already exercises every request-metadata check. The scenario +# server intentionally rejects that probe, so it exposes no callable tool schema. +if [ "${MCP_CONFORMANCE_SCENARIO}" = "request-metadata" ]; then + exit 0 +fi + endpoint="http://127.0.0.1:${conformance_port}/servers/${virtual_host_id}/mcp" while IFS= read -r tool_call; do tool_name="$(jq --exit-status --raw-output '.name' <<< "${tool_call}")" diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index cf41dc8..a984581 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -50,9 +50,19 @@ def fetch_tool_schemas(backend_url: str, tool_names: list[str]) -> dict[str, dic with urllib.request.urlopen(request, timeout=10) as response: response_body = response.read().decode() except urllib.error.HTTPError as error: - if error.code in {400, 404, 405}: - return {} - raise + error_body = error.read().decode() + try: + error_data = json.loads(error_body).get("error", {}) + except json.JSONDecodeError: + raise error + if ( + error.code != 400 + or error_data.get("code") != -32022 + or PROTOCOL_VERSION not in error_data.get("data", {}).get("supported", []) + ): + raise error + with urllib.request.urlopen(request, timeout=10) as response: + response_body = response.read().decode() messages = [ json.loads(line.removeprefix("data:").strip()) From a4321797c1cd27dc6678fe2409dc6849f8ff7d33 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 16:18:46 +0100 Subject: [PATCH 11/16] refactor: keep MCP parameter headers transparent Signed-off-by: lucarlig --- Cargo.lock | 1 - _context/wiki/architecture.md | 2 +- _context/wiki/config.md | 1 - _context/wiki/security.md | 18 +- _context/wiki/testing.md | 9 +- .../src/user_store.rs | 2 - crates/contextforge-data-plane-lib/Cargo.toml | 1 - .../src/gateway/identifier_routing.rs | 5 +- .../src/gateway/mcp_service/initialization.rs | 31 +--- .../src/gateway/mcp_service/prompts.rs | 2 +- .../src/gateway/mcp_service/resources.rs | 2 +- .../src/gateway/mcp_service/tools.rs | 3 +- .../src/gateway/mod.rs | 1 - .../src/layers/mcp_param_validation.rs | 68 ------- .../src/layers/mod.rs | 1 - crates/contextforge-data-plane-lib/src/lib.rs | 3 - .../src/mcp_standard_headers.rs | 173 +----------------- .../tests/gateway_pagination.rs | 1 - .../tests/gateway_plugins.rs | 91 ++++----- .../tests/support/list_tools_gateway.rs | 19 +- .../tests/support/mod.rs | 6 +- .../tests/support/plugin_gateway.rs | 125 ++----------- .../tests/secrets_detection_e2e.rs | 1 - schemas/user_config.json | 11 +- tests/conformance/write_client_config.py | 5 +- 25 files changed, 97 insertions(+), 485 deletions(-) delete mode 100644 crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs diff --git a/Cargo.lock b/Cargo.lock index bc874d3..a1f9d8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -615,7 +615,6 @@ 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 85885c1..4994ef5 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. Tool schemas published by the control plane in `UserConfig` let the dataplane validate downstream `Mcp-Param-*` values without calling backend `tools/list`. The validated parameter headers pass through unchanged; RMCP regenerates the method, routed name, and protocol-version headers. Plugins are responsible for preserving arguments designated by `x-mcp-header`. +- `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. RMCP regenerates the method, routed name, and protocol-version headers. The dataplane does not interpret parameter headers or fetch tool schemas; the upstream MCP server owns their validation. Plugins can modify the full payload without the gateway rewriting 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/config.md b/_context/wiki/config.md index 98dde30..a9f7988 100644 --- a/_context/wiki/config.md +++ b/_context/wiki/config.md @@ -130,7 +130,6 @@ BackendMCPGateway 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 ← upstream_original → input schema; published per backend allowed_resource_names: Vec ← model exists, NOT currently enforced allowed_prompt_names: Vec ← model exists, NOT currently enforced ``` diff --git a/_context/wiki/security.md b/_context/wiki/security.md index 5d50106..ba7c465 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -94,18 +94,14 @@ bounded by the HTTP transport. For stateless requests, the RMCP service requires `MCP-Protocol-Version` and the matching per-request protocol metadata before handler dispatch. RMCP also validates `Mcp-Method` and `Mcp-Name` against the JSON-RPC body. Computed MCP -headers are never accepted through backend pass-through/add/remove policy. The -control plane publishes each visible tool schema inside the subject-, virtual- -host-, and backend-scoped Redis configuration. The innermost authenticated -middleware resolves that request-scoped schema and returns HTTP `400` with -JSON-RPC `-32020` when the routed tool schema is absent or an annotated -parameter header is missing or mismatched. -Validated `Mcp-Param-*` headers are forwarded unchanged outside backend header +headers are never accepted through backend pass-through/add/remove policy. +`Mcp-Param-*` headers are forwarded unchanged outside backend header configuration, while RMCP regenerates method, routed-name, and protocol-version -headers. Plugins are trusted and must preserve arguments designated by -`x-mcp-header`; an inconsistent plugin rewrite is rejected by the upstream MCP -server. No schema is cached globally by bare tool name, and the dataplane does -not call backend `tools/list` as part of `tools/call`. +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 parameter-header validation. Plugins receive the full payload; if a +plugin changes an annotated argument without changing the original request +header, the upstream server may reject the mismatch. ## Local Bootstrap Helpers (`with_tools`) diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 38d67d5..cd114b9 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -58,10 +58,11 @@ a control-plane responsibility. Server and client results are written below `server/` and `client/`, with separate `expected-failures.yml` and `client-expected-failures.yml` baselines. -The client lane has no expected failures. Each stateless upstream tool call -uses the backend-scoped schema already published in Redis; the dataplane does -not issue `tools/list`. The lane covers omission, primitive conversion, and -Base64 wrapping for `x-mcp-header` annotations. +The client lane has no expected failures. Its driver discovers the fixture tool +schemas to construct the same `Mcp-Param-*` headers as a normal MCP client, then +asserts that the dataplane forwards them without interpretation. The lane covers +omission, primitive conversion, and Base64 wrapping for `x-mcp-header` +annotations. `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 588f342..8ded3fa 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -36,8 +36,6 @@ pub struct BackendMCPGateway { pub allowed_resource_names: Vec, pub allowed_prompt_names: Vec, pub allowed_tool_names: Vec, - /// Input schemas keyed by the original upstream tool name. - pub tool_schemas: HashMap>, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 0d21d27..6505887 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -36,7 +36,6 @@ thiserror.workspace = true rmp-serde.workspace = true async-trait.workspace = true reqwest.workspace = true -base64 = "0.22.1" uuid.workspace = true lru_time_cache = "0.11.11" hyper-util = "0.1.20" 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 b41f989..ef98ac1 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -22,7 +22,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(crate) fn resolve_tool_route<'a, N: AsRef>( +pub(super) fn resolve_tool_route<'a, N: AsRef>( virtual_host: &'a VirtualHost, name: &'a str, backend_names: &'a [N], @@ -152,7 +152,6 @@ 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" @@ -184,7 +183,6 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": ["get_stats"], - "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] }, @@ -193,7 +191,6 @@ 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/mcp_service/initialization.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs index a75375b..cc0f0d3 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 @@ -35,10 +35,10 @@ pub(super) async fn initialize( pub(super) async fn connect_backend_for_request( mcp_service: &McpService, - backend: (&str, &BackendMCPGateway), + backend_name: &str, + backend: &BackendMCPGateway, cx: &RequestContext, ) -> Result, ErrorData> { - let (backend_name, backend) = backend; let mut headers = HashMap::new(); let downstream_headers = cx.extensions.get::().map(|parts| &parts.headers); @@ -54,7 +54,6 @@ pub(super) async fn connect_backend_for_request( } apply_header_config(&mut headers, backend, downstream_headers); - forward_mcp_param_headers(&mut headers, downstream_headers); crate::telemetry::inject_current_context(&mut headers); let config = StreamableHttpClientTransportConfig::with_uri(backend.url.to_string()).custom_headers(headers); @@ -86,19 +85,6 @@ pub(super) async fn connect_backend_for_request( }) } -fn forward_mcp_param_headers( - headers: &mut HashMap, - downstream: Option<&http::HeaderMap>, -) { - let Some(downstream) = downstream else { return }; - headers.extend( - downstream - .iter() - .filter(|(name, _)| mcp_standard_headers::is_param(name)) - .map(|(name, value)| (name.clone(), value.clone())), - ); -} - /// Apply a backend's header config to the upstream header map. fn apply_header_config( headers: &mut HashMap, @@ -115,6 +101,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)) @@ -143,7 +135,7 @@ fn apply_header_config( /// - RMCP transport-reserved: `Mcp-Session-Id`, `Accept`, `Last-Event-Id` /// - MCP standard computed headers: `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*` /// -/// Validated downstream `Mcp-Param-*` headers are forwarded separately and cannot be changed by backend config. +/// 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", @@ -182,7 +174,6 @@ 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![], @@ -294,7 +285,7 @@ mod tests { } #[test] - fn validated_mcp_param_headers_are_forwarded_but_cannot_be_changed_by_backend_config() { + 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")); let ds = downstream(&[ @@ -315,8 +306,6 @@ mod tests { ); apply_header_config(&mut headers, &cfg, Some(&ds)); - forward_mcp_param_headers(&mut headers, Some(&ds)); - assert_eq!(headers[&http::HeaderName::from_static("mcp-method")], "tools/call"); assert_eq!(headers[&http::HeaderName::from_static("mcp-param-user")], "client-user"); assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-name"))); diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs index ddd877b..e8b9a18 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs @@ -43,7 +43,7 @@ pub(super) async fn get_prompt( } else { PromptPreFetchResult::unchanged() }; - let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), &cx).await?; + let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &prompt_name); let response = backend_service.get_prompt(routed_request).await; diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs index d2c188d..19474ed 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs @@ -39,7 +39,7 @@ pub(super) async fn read_resource( })?; let service_name = backend_name.clone(); - let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), &cx).await?; + let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; let mut routed_request = request; routed_request.uri = resource_uri; diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index b0a44b3..9d0b384 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -42,11 +42,10 @@ pub(super) async fn call_tool( } else { ToolPreCallResult::unchanged() }; + let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; let post_state = pre_result.state; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &tool_name); - let mut backend_service = connect_backend_for_request(mcp_service, (&backend_name, backend), &cx).await?; - let progress_token = cx.meta.get_progress_token(); let handle = backend_service .service() diff --git a/crates/contextforge-data-plane-lib/src/gateway/mod.rs b/crates/contextforge-data-plane-lib/src/gateway/mod.rs index 9933f44..d9a754b 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mod.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mod.rs @@ -5,5 +5,4 @@ mod identifier_routing; mod mcp_call_validator; mod mcp_service; -pub(crate) use identifier_routing::resolve_tool_route; pub use mcp_service::McpService; 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 deleted file mode 100644 index 4c556e0..0000000 --- a/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs +++ /dev/null @@ -1,68 +0,0 @@ -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 2c5a263..97164fe 100644 --- a/crates/contextforge-data-plane-lib/src/layers/mod.rs +++ b/crates/contextforge-data-plane-lib/src/layers/mod.rs @@ -1,7 +1,6 @@ pub mod claims_id; pub mod mcp_header_limits; pub mod mcp_origin; -pub mod mcp_param_validation; pub mod user_config_store; pub mod virtual_host_config; pub mod virtual_host_id; diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index 0e3efe4..e6f9431 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -43,7 +43,6 @@ 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, user_config_store::user_config_store_layer, virtual_host_config::virtual_host_config_layer, virtual_host_id::virtual_host_id_layer, @@ -118,7 +117,6 @@ impl Gateway { }; let reqwest_backend_client = reqwest::Client::try_from(&config)?; - let max_request_body_bytes = streamable_config.max_request_body_bytes; // Create streamable HTTP service let mcp_service: StreamableHttpService = StreamableHttpService::new( @@ -163,7 +161,6 @@ 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(mcp_add_state.clone(), claims_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 724ff07..bc4101d 100644 --- a/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs +++ b/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs @@ -1,15 +1,7 @@ -use std::collections::HashSet; - -use base64::{Engine, prelude::BASE64_STANDARD}; -use http::{HeaderMap, HeaderName}; -use rmcp::model::ProtocolVersion; +use http::HeaderName; use rmcp::transport::common::http_header::{ - BASE64_HEADER_PREFIX, BASE64_HEADER_SUFFIX, HEADER_MCP_METHOD, HEADER_MCP_NAME, HEADER_MCP_PARAM_PREFIX, - HEADER_MCP_PROTOCOL_VERSION, HEADER_SESSION_ID, + 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) @@ -26,13 +18,6 @@ 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) } @@ -42,157 +27,3 @@ pub(crate) fn is_param(name: &HeaderName) -> bool { .get(..HEADER_MCP_PARAM_PREFIX.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(HEADER_MCP_PARAM_PREFIX)) } - -/// Validate 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) -> Result, String> { - let Some(Value::Object(properties)) = input_schema.get("properties") else { - return Ok(Vec::new()); - }; - let mut annotations = Vec::new(); - let mut seen = HashSet::new(); - for (property, schema) in properties { - reject_nested_annotations(schema, property)?; - let Some(raw) = schema.get("x-mcp-header") else { - continue; - }; - let Value::String(annotation) = raw else { - return Err(format!("property `{property}`: x-mcp-header must be a string")); - }; - if annotation.is_empty() { - return Err(format!("property `{property}`: x-mcp-header must not be empty")); - } - if !annotation.chars().all(is_tchar) { - return Err(format!("property `{property}`: x-mcp-header `{annotation}` is not a valid HTTP token")); - } - if !seen.insert(annotation.to_ascii_lowercase()) { - return Err(format!("property `{property}`: duplicate x-mcp-header `{annotation}` (case-insensitive)")); - } - match schema.get("type").and_then(Value::as_str) { - Some("string" | "integer" | "boolean") => {}, - other => { - return Err(format!( - "property `{property}`: x-mcp-header requires a primitive type \ - (string/integer/boolean), got {other:?}" - )); - }, - } - annotations.push((property.clone(), annotation.clone())); - } - Ok(annotations) -} - -fn reject_nested_annotations(schema: &Value, path: &str) -> Result<(), String> { - if let Some(Value::Object(properties)) = schema.get("properties") { - for (property, nested_schema) in properties { - if nested_schema.get("x-mcp-header").is_some() { - return Err(format!( - "property `{path}.{property}`: x-mcp-header is not supported on nested properties" - )); - } - reject_nested_annotations(nested_schema, &format!("{path}.{property}"))?; - } - } - Ok(()) -} - -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()), - } -} - -fn is_tchar(character: char) -> bool { - character.is_ascii_alphanumeric() - || matches!(character, '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~') -} - -#[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 ce41ddd..c3c48e0 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs @@ -28,7 +28,6 @@ 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 8c3d541..d3eb21b 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -2,7 +2,6 @@ mod support; use std::sync::{Arc, Mutex as StdMutex}; -use base64::{Engine, prelude::BASE64_STANDARD}; use contextforge_data_plane_cpex::CpexRuntimeRegistry; use cpex::cpex_core::cmf::Role; use cpex::cpex_core::config::CpexConfig; @@ -21,9 +20,9 @@ use serde_json::{Map, Value, json}; use support::{ BACKEND_PROMPT_IMAGE, BACKEND_PROMPT_RESOURCE, POST_DENY_ERROR_CODE, PRE_DENY_ERROR_CODE, PROMPT_ERROR_MESSAGE, PROMPT_POST_DENY_ERROR_CODE, PromptBehavior, PromptTestPlugin, REWRITTEN_PROMPT_RESOURCE, REWRITTEN_PROMPT_TEXT, - REWRITTEN_PROMPT_TOPIC, 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, start_gateway_with_parameter_headers, sum_request, text, token, + 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, }; type Recorded = Arc>>; @@ -153,6 +152,17 @@ async fn successful_tool_text(response: reqwest::Response) -> String { .to_owned() } +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", @@ -413,9 +423,8 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_forwards_validated_parameter_headers_without_backend_listing() { - let gateway = - start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; +async fn stateless_tool_call_forwards_parameter_headers_without_backend_listing() { + let gateway = start_gateway(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", "1") .header("Mcp-Param-B", "2") @@ -424,19 +433,16 @@ async fn stateless_tool_call_forwards_validated_parameter_headers_without_backen .expect("stateless tool call reaches gateway"); assert_eq!("3", successful_tool_text(response).await); - assert_eq!( - 0, - gateway.backend_state.list_tool_calls.load(std::sync::atomic::Ordering::Relaxed), - "the dataplane must not call tools/list before forwarding" - ); + 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_forwards_encoded_parameter_headers() { - let gateway = - start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let unsafe_value = " leading snowman ☃"; - let encoded = format!("=?base64?{}?=", BASE64_STANDARD.encode(unsafe_value)); + let encoded = "=?base64?IGxlYWRpbmcgc25vd21hbiDimIM=?="; let response = raw_stateless_tool_call(&gateway, "reflect_text", &json!({ "text": unsafe_value })) .header("Mcp-Param-Text", encoded) .send() @@ -444,24 +450,24 @@ async fn stateless_tool_call_forwards_encoded_parameter_headers() { .expect("stateless tool call reaches gateway"); assert_eq!(unsafe_value, successful_tool_text(response).await); + assert_eq!(encoded, last_backend_request_headers(&gateway)["Mcp-Param-Text"]); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_omits_null_parameter_headers() { - let gateway = - start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let response = raw_stateless_tool_call(&gateway, "optional_text", &json!({ "text": null })) .send() .await .expect("stateless tool call reaches gateway"); assert_eq!("accepted", successful_tool_text(response).await); + assert!(!last_backend_request_headers(&gateway).contains_key("Mcp-Param-Optional-Text")); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_without_protocol_version_header_is_rejected_before_backend() { - let gateway = - start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let response = support::create_client(TEST_USER_ID) .post(gateway.gateway_url()) .header(http::header::ACCEPT, "application/json, text/event-stream") @@ -495,9 +501,8 @@ async fn stateless_tool_call_without_protocol_version_header_is_rejected_before_ } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_with_mismatched_parameter_header_is_rejected_before_backend() { - let gateway = - start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; +async fn stateless_tool_call_forwards_mismatched_parameter_header_to_backend() { + let gateway = start_gateway(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") @@ -505,22 +510,24 @@ async fn stateless_tool_call_with_mismatched_parameter_header_is_rejected_before .await .expect("request reaches gateway"); - assert_eq!(http::StatusCode::BAD_REQUEST, response.status()); - let body: serde_json::Value = response.json().await.expect("gateway returns a JSON-RPC error"); - assert_eq!(rmcp::model::ErrorCode::HEADER_MISMATCH.0, body["error"]["code"]); - assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); + assert_eq!("3", successful_tool_text(response).await); + assert_eq!("9", last_backend_request_headers(&gateway)["Mcp-Param-A"]); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_without_published_schema_is_rejected_before_backend() { +async fn stateless_tool_error_round_trips() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; - let response = - raw_stateless_tool_call(&gateway, "missing_tool", &json!({})).send().await.expect("request reaches gateway"); - - assert_eq!(http::StatusCode::BAD_REQUEST, response.status()); - let body: serde_json::Value = response.json().await.expect("gateway returns a 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()); + 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_tool")).await.unwrap_err(); + let rmcp::service::ServiceError::McpError(error) = error else { + panic!("expected backend MCP error, got {error:?}"); + }; + assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -717,12 +724,12 @@ async fn secrets_detection_pre_hook_respects_field_allowlist() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn pre_hook_that_rewrites_header_designated_arguments_is_rejected_by_backend() { +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_with_parameter_headers(TEST_USER_ID, true, runtime).await; + let gateway = start_gateway(TEST_USER_ID, true, runtime).await; let response = raw_stateless_tool_call(&gateway, "sum", &json!({ "a": 1, "b": 2 })) .header("Mcp-Param-A", "1") .header("Mcp-Param-B", "2") @@ -730,14 +737,10 @@ async fn pre_hook_that_rewrites_header_designated_arguments_is_rejected_by_backe .await .expect("plugin-modified request reaches backend"); - assert_eq!(http::StatusCode::OK, response.status()); - let body = response.text().await.expect("gateway response body"); - let messages = sse_data_values(&body); - assert_eq!( - Some(i64::from(ErrorCode::HEADER_MISMATCH.0)), - messages.iter().find_map(|message| message["error"]["code"].as_i64()) - ); - assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); + assert_eq!((REWRITTEN_SUM_A + REWRITTEN_SUM_B).to_string(), successful_tool_text(response).await); + 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!(Some(&Value::from(REWRITTEN_SUM_A)), backend_calls[0].args.as_ref().and_then(|args| args.get("a"))); let observations = observations.lock().expect("observations lock poisoned"); assert_eq!(1, observations.pre_calls); 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 a9b076b..f88d8af 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 @@ -8,11 +8,8 @@ use contextforge_data_plane_lib::{ Config, Gateway, Result, UpstreamConnectionMode, UserConfigStore, UserConfigStoreType, }; use futures::{FutureExt, future::BoxFuture}; -use rmcp::{ - ServerHandler, - transport::{ - StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, - }, +use rmcp::transport::{ + StreamableHttpServerConfig, StreamableHttpService, streamable_http_server::session::local::LocalSessionManager, }; use tracing::warn; @@ -234,7 +231,6 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap HashMap HashMap { - let counter = mock_counter::Counter::new(); - MOCK_COUNTER_TOOL_NAMES - .iter() - .map(|name| { - let tool = counter.get_tool(name).expect("mock counter tool exists"); - ((*name).to_owned(), tool.input_schema.as_ref().clone()) - }) - .collect() -} - fn backend_id(port: u16) -> String { format!("00000000-0000-0000-0000-{port:012}") } diff --git a/crates/contextforge-data-plane-lib/tests/support/mod.rs b/crates/contextforge-data-plane-lib/tests/support/mod.rs index c4c9942..09043ef 100644 --- a/crates/contextforge-data-plane-lib/tests/support/mod.rs +++ b/crates/contextforge-data-plane-lib/tests/support/mod.rs @@ -27,12 +27,12 @@ pub(crate) use list_tools_gateway::{ }; pub(crate) use plugin::{ POST_DENY_ERROR_CODE, PRE_DENY_ERROR_CODE, PROMPT_ERROR_MESSAGE, PROMPT_POST_DENY_ERROR_CODE, PromptBehavior, - PromptTestPlugin, REWRITTEN_PROMPT_RESOURCE, REWRITTEN_PROMPT_TEXT, REWRITTEN_PROMPT_TOPIC, TestPlugin, - TestPluginFactory, + PromptTestPlugin, REWRITTEN_PROMPT_RESOURCE, REWRITTEN_PROMPT_TEXT, REWRITTEN_PROMPT_TOPIC, REWRITTEN_SUM_A, + REWRITTEN_SUM_B, TestPlugin, TestPluginFactory, }; pub(crate) use plugin_gateway::{ BACKEND_PROMPT_IMAGE, BACKEND_PROMPT_RESOURCE, RunningGateway, start_gateway, start_gateway_with_events, - start_gateway_with_json_backend_responses, start_gateway_with_parameter_headers, + start_gateway_with_json_backend_responses, }; pub(crate) use runtime::{runtime_with_post, runtime_with_pre, runtime_with_pre_and_post, runtime_with_prompt_plugin}; pub(crate) use tool::{error_code, error_parts, sum_request, text}; 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 04851fb..7b1c6ac 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -1,9 +1,6 @@ use std::{ collections::HashMap, - sync::{ - Arc, Mutex as StdMutex, OnceLock, - atomic::{AtomicUsize, Ordering}, - }, + sync::{Arc, Mutex as StdMutex, OnceLock}, time::{Duration, Instant}, }; @@ -14,14 +11,13 @@ 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::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorCode, GetPromptRequestParams, - GetPromptResponse, GetPromptResult, Implementation, InitializeRequestParams, InitializeResult, ListToolsResult, - NumberOrString, PaginatedRequestParams, ProgressNotificationParam, ProgressToken, PromptMessage, - ResourceContents, Role, ServerCapabilities, Tool, + GetPromptResponse, GetPromptResult, Implementation, InitializeRequestParams, InitializeResult, NumberOrString, + ProgressNotificationParam, ProgressToken, PromptMessage, ResourceContents, Role, ServerCapabilities, }, service::{RequestContext, Service}, transport::{ @@ -30,7 +26,7 @@ use rmcp::{ streamable_http_server::session::local::LocalSessionManager, }, }; -use serde_json::{Map, Value, json}; +use serde_json::{Map, Value}; use tokio::sync::Mutex as TokioMutex; use super::{MemoryUserConfigStore, token}; @@ -52,11 +48,10 @@ pub(crate) struct BackendObservation { #[derive(Clone, Default)] pub(crate) struct BackendState { pub(crate) calls: Arc>>, - pub(crate) list_tool_calls: Arc, + pub(crate) request_headers: Arc>>, pub(crate) prompts: Arc>>, pub(crate) cancellations: Arc>>, pub(crate) events: Arc>>, - parameter_headers: bool, } #[derive(Clone)] @@ -64,77 +59,6 @@ struct TestBackend { state: BackendState, } -fn sum_tool() -> Tool { - let input_schema = 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 input schema is an object") - .clone(); - Tool::new("sum", "Add two integers", input_schema) -} - -fn reflect_text_tool() -> Tool { - let input_schema = json!({ - "type": "object", - "properties": { - "text": { "type": "string", "x-mcp-header": "Text" } - }, - "required": ["text"] - }) - .as_object() - .expect("reflect_text input schema is an object") - .clone(); - Tool::new("reflect_text", "Reflect text", input_schema) -} - -fn optional_text_tool() -> Tool { - let input_schema = json!({ - "type": "object", - "properties": { - "text": { "type": "string", "x-mcp-header": "Optional-Text" } - } - }) - .as_object() - .expect("optional_text input schema is an object") - .clone(); - Tool::new("optional_text", "Accept optional text", input_schema) -} - -fn tools(parameter_headers: bool) -> Vec { - let mut tools = vec![ - sum_tool(), - reflect_text_tool(), - optional_text_tool(), - Tool::new("progress_sum", "Report progress", Map::new()), - Tool::new("progress_counter_tokens", "Report progress with generated tokens", Map::new()), - Tool::new("wait_for_cancellation", "Wait for cancellation", Map::new()), - ]; - if !parameter_headers { - for tool in &mut tools { - let schema = Arc::make_mut(&mut tool.input_schema); - if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) { - for property in properties.values_mut().filter_map(Value::as_object_mut) { - property.remove("x-mcp-header"); - } - } - } - } - tools -} - -fn published_tool_schemas(parameter_headers: bool) -> HashMap> { - tools(parameter_headers) - .into_iter() - .map(|tool| (tool.name.to_string(), tool.input_schema.as_ref().clone())) - .collect() -} - #[allow(clippy::unused_async_trait_impl)] impl ServerHandler for TestBackend { async fn initialize( @@ -179,24 +103,18 @@ impl ServerHandler for TestBackend { Ok(GetPromptResult::new(vec![PromptMessage::new_text(Role::User, format!("review of {topic}"))]).into()) } - async fn list_tools( - &self, - _request: Option, - _cx: RequestContext, - ) -> Result { - self.state.list_tool_calls.fetch_add(1, Ordering::Relaxed); - Ok(ListToolsResult::with_all_items(tools(self.state.parameter_headers))) - } - - fn get_tool(&self, name: &str) -> Option { - tools(self.state.parameter_headers).into_iter().find(|tool| tool.name == name) - } - async fn call_tool( &self, 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() @@ -352,21 +270,6 @@ 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, ..BackendState::default() }, - ) - .await -} - pub(crate) async fn start_gateway_with_events( user: &str, plugin_runtime: Arc, @@ -414,7 +317,6 @@ 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( { @@ -443,7 +345,6 @@ 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 5ed52b6..b197468 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -380,7 +380,6 @@ 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::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 fde4c53..69576e9 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -106,14 +106,6 @@ "items": { "type": "string" } - }, - "tool_schemas": { - "description": "Input schemas keyed by the original upstream tool name.", - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": true - } } }, "required": [ @@ -122,8 +114,7 @@ "passthrough_headers", "allowed_resource_names", "allowed_prompt_names", - "allowed_tool_names", - "tool_schemas" + "allowed_tool_names" ] } } diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index a984581..a7caa6d 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -21,13 +21,13 @@ def fetch_tool_schemas(backend_url: str, tool_names: list[str]) -> dict[str, dic body = json.dumps( { "jsonrpc": "2.0", - "id": "control-plane-schema-discovery", + "id": "conformance-client-schema-discovery", "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, "io.modelcontextprotocol/clientInfo": { - "name": "contextforge-conformance-control-plane", + "name": "contextforge-conformance-client-driver", "version": "1.0.0", }, "io.modelcontextprotocol/clientCapabilities": {}, @@ -184,7 +184,6 @@ def main() -> None: "add_headers": {}, "remove_headers": [], "allowed_tool_names": tool_names, - "tool_schemas": tool_schemas, "tool_name_aliases": {}, "allowed_resource_names": [], "allowed_prompt_names": [], From 3fba0015d1dfa66d19ebc8ba966ac9ed3987f828 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 17:41:36 +0100 Subject: [PATCH 12/16] chore: remove obsolete clippy allowances Signed-off-by: lucarlig --- .secrets.baseline | 4 ++-- crates/contextforge-data-plane-cpex/src/handle.rs | 3 --- crates/contextforge-data-plane-lib/tests/support/mod.rs | 2 -- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index f8f965d..1e44b95 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(Cargo\\.lock$|\\.lock$|target/|^\\.secrets\\.baseline$)", "lines": null }, - "generated_at": "2026-08-26T08:49:39Z", + "generated_at": "2026-08-26T08:54:54Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -187,7 +187,7 @@ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", "is_verified": false, - "line_number": 19, + "line_number": 17, "type": "Secret Keyword", "verified_result": null, "is_secret": false diff --git a/crates/contextforge-data-plane-cpex/src/handle.rs b/crates/contextforge-data-plane-cpex/src/handle.rs index a714b6d..a2f504a 100644 --- a/crates/contextforge-data-plane-cpex/src/handle.rs +++ b/crates/contextforge-data-plane-cpex/src/handle.rs @@ -331,9 +331,6 @@ fn runtime_failed_error(state: &RuntimeState) -> ErrorData { #[cfg(test)] mod tests { - #![allow(unknown_lints, reason = "Rust 1.96 predates unused_async_trait_impl")] - #![allow(clippy::unused_async_trait_impl, reason = "test plugins implement async interfaces synchronously")] - use std::{ collections::HashMap, sync::{ diff --git a/crates/contextforge-data-plane-lib/tests/support/mod.rs b/crates/contextforge-data-plane-lib/tests/support/mod.rs index 09043ef..ffb1ae2 100644 --- a/crates/contextforge-data-plane-lib/tests/support/mod.rs +++ b/crates/contextforge-data-plane-lib/tests/support/mod.rs @@ -1,5 +1,3 @@ -#![allow(unknown_lints, reason = "Rust 1.96 predates unused_async_trait_impl")] -#![allow(clippy::unused_async_trait_impl, reason = "test fixtures implement async interfaces synchronously")] #![allow(dead_code, unused_imports, reason = "shared CPEX test fixture is used by separate integration test targets")] mod auth; From 37012c84ec472221d50b24edd73ad1be838a226f Mon Sep 17 00:00:00 2001 From: lucarlig Date: Sat, 22 Aug 2026 12:02:49 +0100 Subject: [PATCH 13/16] refactor: keep parameter forwarding transparent Signed-off-by: lucarlig --- _context/wiki/architecture.md | 2 +- _context/wiki/security.md | 18 +- _context/wiki/testing.md | 6 - crates/contextforge-data-plane-lib/src/lib.rs | 9 +- .../tests/gateway_plugins.rs | 155 ++++-------------- .../tests/support/plugin_gateway.rs | 1 - .../conformance/client-expected-failures.yml | 9 +- tests/conformance/client-under-test-test.sh | 11 +- tests/conformance/client-under-test.sh | 46 ++---- tests/conformance/docker-compose.yml | 2 - tests/conformance/write_client_config.py | 146 +---------------- 11 files changed, 69 insertions(+), 336 deletions(-) diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index 4994ef5..05d2867 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`, forwards downstream `Mcp-Param-*` headers unchanged, runs pre/post plugin hooks, executes the call, then explicitly closes the connection before returning. RMCP regenerates the method, routed name, and protocol-version headers. The dataplane does not interpret parameter headers or fetch tool schemas; the upstream MCP server owns their validation. Plugins can modify the full payload without the gateway rewriting headers. +- `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 ba7c465..6fb7203 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -91,17 +91,13 @@ 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. -For stateless requests, the RMCP service requires `MCP-Protocol-Version` and -the matching per-request protocol metadata before handler dispatch. RMCP also -validates `Mcp-Method` and `Mcp-Name` against the JSON-RPC body. Computed MCP -headers are never accepted through backend pass-through/add/remove policy. -`Mcp-Param-*` headers are forwarded unchanged outside backend header -configuration, 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 parameter-header validation. Plugins receive the full payload; if a -plugin changes an annotated argument without changing the original request -header, the upstream server may reject the mismatch. +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`) diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index cd114b9..b780fac 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -58,12 +58,6 @@ a control-plane responsibility. Server and client results are written below `server/` and `client/`, with separate `expected-failures.yml` and `client-expected-failures.yml` baselines. -The client lane has no expected failures. Its driver discovers the fixture tool -schemas to construct the same `Mcp-Param-*` headers as a normal MCP client, then -asserts that the dataplane forwards them without interpretation. The lane covers -omission, primitive conversion, and Base64 wrapping for `x-mcp-header` -annotations. - `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-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index e6f9431..a5d72ea 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -107,13 +107,12 @@ impl Gateway { // RMCP owns Host validation. Keep its Origin validator disabled because // mcp_origin_layer enforces exact origin tuples and returns 403 for every // invalid present Origin, including when no allowlist is configured. - let streamable_config = StreamableHttpServerConfig::default() - .with_stateless_protocol_metadata_required(true) - .disable_allowed_origins(); let streamable_config = if let Some(ref hosts) = config.mcp_allowed_hosts { - streamable_config.with_allowed_hosts(hosts.iter().map(Authority::as_str)) + StreamableHttpServerConfig::default() + .with_allowed_hosts(hosts.iter().map(Authority::as_str)) + .disable_allowed_origins() } else { - streamable_config.disable_allowed_hosts() + StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins() }; let reqwest_backend_client = reqwest::Client::try_from(&config)?; diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index d3eb21b..7cdf4d2 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -115,41 +115,15 @@ 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(&serde_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": "strict-metadata-test", - "version": "1.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } - })) -} - -async fn successful_tool_text(response: reqwest::Response) -> String { - assert_eq!(http::StatusCode::OK, response.status()); - let body = response.text().await.expect("gateway response body"); - let messages = sse_data_values(&body); - messages - .iter() - .find_map(|message| message["result"]["content"][0]["text"].as_str()) - .expect("tool response contains text") - .to_owned() +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 { @@ -423,97 +397,22 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_forwards_parameter_headers_without_backend_listing() { +async fn stateless_tool_call_forwards_parameter_headers_without_interpretation() { let gateway = start_gateway(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", "1") - .header("Mcp-Param-B", "2") - .send() - .await - .expect("stateless tool call reaches gateway"); + let service = support::connect_modern_client( + gateway.gateway_url(), + 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", successful_tool_text(response).await); + assert_eq!("3", text(&result)); let headers = last_backend_request_headers(&gateway); - assert_eq!("1", headers["Mcp-Param-A"]); + assert_eq!("9", headers["Mcp-Param-A"]); assert_eq!("2", headers["Mcp-Param-B"]); } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_forwards_encoded_parameter_headers() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; - let unsafe_value = " leading snowman ☃"; - let encoded = "=?base64?IGxlYWRpbmcgc25vd21hbiDimIM=?="; - let response = raw_stateless_tool_call(&gateway, "reflect_text", &json!({ "text": unsafe_value })) - .header("Mcp-Param-Text", encoded) - .send() - .await - .expect("stateless tool call reaches gateway"); - - assert_eq!(unsafe_value, successful_tool_text(response).await); - assert_eq!(encoded, last_backend_request_headers(&gateway)["Mcp-Param-Text"]); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_omits_null_parameter_headers() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; - let response = raw_stateless_tool_call(&gateway, "optional_text", &json!({ "text": null })) - .send() - .await - .expect("stateless tool call reaches gateway"); - - assert_eq!("accepted", successful_tool_text(response).await); - assert!(!last_backend_request_headers(&gateway).contains_key("Mcp-Param-Optional-Text")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_without_protocol_version_header_is_rejected_before_backend() { - let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; - let response = support::create_client(TEST_USER_ID) - .post(gateway.gateway_url()) - .header(http::header::ACCEPT, "application/json, text/event-stream") - .header("MCP-Method", "tools/call") - .header("MCP-Name", "sum") - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "sum", - "arguments": { "a": 1, "b": 2 }, - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "strict-metadata-test", - "version": "1.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } - })) - .send() - .await - .expect("request reaches gateway"); - - assert_eq!(http::StatusCode::BAD_REQUEST, response.status()); - let body: serde_json::Value = response.json().await.expect("gateway returns a JSON-RPC error"); - assert_eq!(rmcp::model::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_forwards_mismatched_parameter_header_to_backend() { - let gateway = start_gateway(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!("3", successful_tool_text(response).await); - assert_eq!("9", last_backend_request_headers(&gateway)["Mcp-Param-A"]); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_error_round_trips() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; @@ -730,16 +629,18 @@ async fn pre_hook_rewrites_payload_without_changing_forwarded_parameter_headers( let runtime = runtime_with_pre(plugin).await; let gateway = start_gateway(TEST_USER_ID, true, runtime).await; - let response = raw_stateless_tool_call(&gateway, "sum", &json!({ "a": 1, "b": 2 })) - .header("Mcp-Param-A", "1") - .header("Mcp-Param-B", "2") - .send() - .await - .expect("plugin-modified request reaches backend"); + 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(), successful_tool_text(response).await); + 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"))); let observations = observations.lock().expect("observations lock poisoned"); 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 7b1c6ac..4639435 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -183,7 +183,6 @@ impl ServerHandler for TestBackend { .ok_or_else(|| ErrorData::invalid_params("reflect_text requires text", None))?; Ok(CallToolResult::success(vec![ContentBlock::text(text.to_owned())])) }, - "optional_text" => Ok(CallToolResult::success(vec![ContentBlock::text("accepted")])), "wait_for_cancellation" => { cx.ct.cancelled().await; self.state diff --git a/tests/conformance/client-expected-failures.yml b/tests/conformance/client-expected-failures.yml index 0c4180b..5f19d93 100644 --- a/tests/conformance/client-expected-failures.yml +++ b/tests/conformance/client-expected-failures.yml @@ -1,3 +1,10 @@ # Dataplane-owned upstream MCP client findings for the scoped client lane. # OAuth scenarios are control-plane responsibilities and are not run here. -client: [] +client: + # 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 + - http-custom-headers:sep-2243-client-base64-unsafe diff --git a/tests/conformance/client-under-test-test.sh b/tests/conformance/client-under-test-test.sh index 9fa7084..3387f24 100755 --- a/tests/conformance/client-under-test-test.sh +++ b/tests/conformance/client-under-test-test.sh @@ -6,7 +6,6 @@ state_dir="$(mktemp -d "${TMPDIR:-/tmp}/contextforge-client-adapter-test.XXXXXX" fake_bin="${state_dir}/bin" docker_args="${state_dir}/docker-args" curl_bodies="${state_dir}/curl-bodies" -curl_args="${state_dir}/curl-args" cleanup() { rm -rf -- "${state_dir}" @@ -17,11 +16,9 @@ mkdir -p "${fake_bin}" cat > "${fake_bin}/docker" <<'EOF' #!/usr/bin/env bash printf '%s\n' "$*" > "${FAKE_DOCKER_ARGS}" -printf '%s\n' "${FAKE_PREPARED_TOOL_CALLS}" EOF cat > "${fake_bin}/curl" <<'EOF' #!/usr/bin/env bash -printf '%s\n' "$*" >> "${FAKE_CURL_ARGS}" while [ "$#" -gt 0 ]; do if [ "$1" = "--data" ]; then shift @@ -36,11 +33,6 @@ chmod +x "${fake_bin}/docker" "${fake_bin}/curl" export PATH="${fake_bin}:${PATH}" export FAKE_DOCKER_ARGS="${docker_args}" export FAKE_CURL_BODIES="${curl_bodies}" -export FAKE_CURL_ARGS="${curl_args}" -export FAKE_PREPARED_TOOL_CALLS='[ - {"name":"first","arguments":{"region":"west","empty_val":""},"headers":{"Mcp-Param-Region":"west","Mcp-Param-EmptyVal":""}}, - {"name":"second","arguments":{"verbose":null},"headers":{}} -]' export MCP_CONFORMANCE_PROTOCOL_VERSION=2026-07-28 export MCP_CONFORMANCE_SUBJECT=test-subject export MCP_CONFORMANCE_CLIENT_SERVER_ID=test-client-server @@ -57,8 +49,7 @@ export MCP_CONFORMANCE_CONTEXT='{ "${script_dir}/client-under-test.sh" "http://localhost:43123/mcp" grep --fixed-strings --quiet -- 'http://host.docker.internal:43123/mcp' "${docker_args}" -grep --fixed-strings --quiet -- 'Mcp-Param-Region: west' "${curl_args}" -grep --fixed-strings --quiet -- 'Mcp-Param-EmptyVal;' "${curl_args}" +grep --fixed-strings --quiet -- '["first","second"]' "${docker_args}" test "$(wc -l < "${curl_bodies}" | tr -d '[:space:]')" -eq 2 jq --exit-status --slurp ' length == 2 and diff --git a/tests/conformance/client-under-test.sh b/tests/conformance/client-under-test.sh index 58a5f71..909ee2f 100755 --- a/tests/conformance/client-under-test.sh +++ b/tests/conformance/client-under-test.sh @@ -42,33 +42,20 @@ case "${MCP_CONFORMANCE_SCENARIO}" in ;; esac -prepared_tool_calls="$(docker compose -f "${compose_file}" run --rm --no-deps \ +tool_names="$(jq --exit-status --compact-output '[.[].name] | unique' <<< "${tool_calls}")" +docker compose -f "${compose_file}" run --rm --no-deps \ --entrypoint python3 control-plane \ /opt/contextforge-conformance/write_client_config.py \ "${MCP_CONFORMANCE_SUBJECT}" \ "${virtual_host_id}" \ "${backend_url}" \ - "${tool_calls}")" - -# Schema discovery already exercises every request-metadata check. The scenario -# server intentionally rejects that probe, so it exposes no callable tool schema. -if [ "${MCP_CONFORMANCE_SCENARIO}" = "request-metadata" ]; then - exit 0 -fi + "${tool_names}" \ + > /dev/null endpoint="http://127.0.0.1:${conformance_port}/servers/${virtual_host_id}/mcp" while IFS= read -r tool_call; do tool_name="$(jq --exit-status --raw-output '.name' <<< "${tool_call}")" arguments="$(jq --exit-status --compact-output '.arguments' <<< "${tool_call}")" - header_args=() - while IFS=$'\t' read -r header_name header_value; do - if [ -z "${header_value}" ]; then - # curl's `Header:` form removes a header; `Header;` sends an empty value. - header_args+=(--header "${header_name};") - else - header_args+=(--header "${header_name}: ${header_value}") - fi - done < <(jq --exit-status --raw-output '.headers | to_entries[] | [.key, .value] | @tsv' <<< "${tool_call}") request="$(jq --null-input --compact-output \ --arg name "${tool_name}" \ --argjson arguments "${arguments}" \ @@ -91,20 +78,15 @@ while IFS= read -r tool_call; do } }')" - if ! response="$(curl --silent --show-error --fail-with-body \ - --request POST \ - --header 'Content-Type: application/json' \ - --header 'Accept: application/json, text/event-stream' \ - --header "MCP-Protocol-Version: ${MCP_CONFORMANCE_PROTOCOL_VERSION}" \ - --header 'MCP-Method: tools/call' \ - --header "MCP-Name: ${tool_name}" \ - "${header_args[@]}" \ - --data "${request}" \ - "${endpoint}")"; then - echo "Dataplane HTTP request failed for client conformance tool call ${tool_name}:" >&2 - echo "${response}" >&2 - exit 1 - fi + response="$(curl --silent --show-error --fail-with-body \ + --request POST \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --header "MCP-Protocol-Version: ${MCP_CONFORMANCE_PROTOCOL_VERSION}" \ + --header 'MCP-Method: tools/call' \ + --header "MCP-Name: ${tool_name}" \ + --data "${request}" \ + "${endpoint}")" response_json="$(sed -n 's/^data: //p' <<< "${response}" | head -n 1)" if [ -z "${response_json}" ]; then @@ -115,4 +97,4 @@ while IFS= read -r tool_call; do echo "${response}" >&2 exit 1 fi -done < <(jq --compact-output '.[]' <<< "${prepared_tool_calls}") +done < <(jq --compact-output '.[]' <<< "${tool_calls}") diff --git a/tests/conformance/docker-compose.yml b/tests/conformance/docker-compose.yml index 743397e..d16e8fc 100644 --- a/tests/conformance/docker-compose.yml +++ b/tests/conformance/docker-compose.yml @@ -39,8 +39,6 @@ services: ports: - "127.0.0.1:4444:4444" networks: [contextforge] - extra_hosts: - - host.docker.internal:host-gateway environment: HOST: 0.0.0.0 PORT: "4444" diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index a7caa6d..5d67a7f 100755 --- a/tests/conformance/write_client_config.py +++ b/tests/conformance/write_client_config.py @@ -4,145 +4,20 @@ from __future__ import annotations import argparse -import base64 import json import os -import urllib.error -import urllib.request from urllib.parse import urlparse import msgpack import redis -PROTOCOL_VERSION = "2026-07-28" - - -def fetch_tool_schemas(backend_url: str, tool_names: list[str]) -> dict[str, dict[str, object]]: - body = json.dumps( - { - "jsonrpc": "2.0", - "id": "conformance-client-schema-discovery", - "method": "tools/list", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, - "io.modelcontextprotocol/clientInfo": { - "name": "contextforge-conformance-client-driver", - "version": "1.0.0", - }, - "io.modelcontextprotocol/clientCapabilities": {}, - } - }, - } - ).encode() - request = urllib.request.Request( - backend_url, - data=body, - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "MCP-Protocol-Version": PROTOCOL_VERSION, - "MCP-Method": "tools/list", - }, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=10) as response: - response_body = response.read().decode() - except urllib.error.HTTPError as error: - error_body = error.read().decode() - try: - error_data = json.loads(error_body).get("error", {}) - except json.JSONDecodeError: - raise error - if ( - error.code != 400 - or error_data.get("code") != -32022 - or PROTOCOL_VERSION not in error_data.get("data", {}).get("supported", []) - ): - raise error - with urllib.request.urlopen(request, timeout=10) as response: - response_body = response.read().decode() - - messages = [ - json.loads(line.removeprefix("data:").strip()) - for line in response_body.splitlines() - if line.startswith("data:") and line.removeprefix("data:").strip() - ] - if not messages: - messages = [json.loads(response_body)] - tools = next( - ( - message.get("result", {}).get("tools") - for message in messages - if isinstance(message.get("result", {}).get("tools"), list) - ), - None, - ) - if tools is None: - raise SystemExit(f"tools/list did not return tools: {response_body}") - - schemas = { - tool["name"]: tool["inputSchema"] - for tool in tools - if isinstance(tool, dict) - and tool.get("name") in tool_names - and isinstance(tool.get("inputSchema"), dict) - } - return schemas - - -def encode_header_value(value: str) -> str: - needs_base64 = ( - bool(value) - and ( - value[0] in {" ", "\t"} - or value[-1] in {" ", "\t"} - or any(ord(character) < 0x20 or ord(character) > 0x7E for character in value) - or (value.startswith("=?base64?") and value.endswith("?=")) - ) - ) - if not needs_base64: - return value - encoded = base64.b64encode(value.encode()).decode() - return f"=?base64?{encoded}?=" - - -def prepare_tool_calls( - tool_calls: list[dict[str, object]], - tool_schemas: dict[str, dict[str, object]], -) -> list[dict[str, object]]: - prepared = [] - for tool_call in tool_calls: - name = tool_call["name"] - arguments = tool_call["arguments"] - properties = tool_schemas.get(name, {}).get("properties", {}) - headers = {} - if isinstance(arguments, dict) and isinstance(properties, dict): - for property_name, property_schema in properties.items(): - if not isinstance(property_schema, dict): - continue - annotation = property_schema.get("x-mcp-header") - value = arguments.get(property_name) - if not isinstance(annotation, str) or not annotation or value is None: - continue - if isinstance(value, bool): - value = str(value).lower() - elif isinstance(value, (str, int, float)): - value = str(value) - else: - continue - headers[f"Mcp-Param-{annotation}"] = encode_header_value(value) - prepared.append({"name": name, "arguments": arguments, "headers": headers}) - return prepared - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("subject") parser.add_argument("virtual_host_id") parser.add_argument("backend_url") - parser.add_argument("tool_calls_json") + parser.add_argument("tool_names_json") return parser.parse_args() @@ -156,23 +31,15 @@ def main() -> None: if parsed_url.scheme not in {"http", "https"} or not parsed_url.hostname: raise SystemExit("backend_url must be an absolute HTTP(S) URL") - tool_calls = json.loads(args.tool_calls_json) + tool_names = json.loads(args.tool_names_json) if ( - not isinstance(tool_calls, list) - or not tool_calls - or not all( - isinstance(tool_call, dict) - and isinstance(tool_call.get("name"), str) - and bool(tool_call["name"]) - and isinstance(tool_call.get("arguments"), dict) - for tool_call in tool_calls - ) + not isinstance(tool_names, list) + or not tool_names + or not all(isinstance(name, str) and name for name in tool_names) ): - raise SystemExit("tool_calls_json must be a non-empty tool-call array") - tool_names = sorted({tool_call["name"] for tool_call in tool_calls}) + raise SystemExit("tool_names_json must be a non-empty JSON string array") backend_name = "conformance-backend" - tool_schemas = fetch_tool_schemas(args.backend_url, tool_names) config = { "virtual_hosts": { args.virtual_host_id: { @@ -197,7 +64,6 @@ def main() -> None: value = msgpack.dumps(config, use_bin_type=True) client = redis.Redis.from_url(redis_url, decode_responses=False) client.set(key, value, ex=600) - print(json.dumps(prepare_tool_calls(tool_calls, tool_schemas), separators=(",", ":"))) if __name__ == "__main__": From b79783b7f89810829e74bf25767f917ffa42a4c6 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Mon, 24 Aug 2026 09:46:26 +0100 Subject: [PATCH 14/16] fix: validate MCP parameter headers Signed-off-by: lucarlig --- Cargo.lock | 1 + _context/wiki/architecture.md | 14 +- _context/wiki/config.md | 5 +- _context/wiki/security.md | 14 +- _context/wiki/testing.md | 2 +- .../src/user_store.rs | 2 + crates/contextforge-data-plane-lib/Cargo.toml | 1 + .../src/gateway/identifier_routing.rs | 5 +- .../src/gateway/mcp_service/initialization.rs | 1 + .../src/gateway/mod.rs | 1 + .../src/layers/mcp_param_validation.rs | 68 +++++++++ .../src/layers/mod.rs | 1 + crates/contextforge-data-plane-lib/src/lib.rs | 3 + .../src/mcp_standard_headers.rs | 131 +++++++++++++++++- .../tests/gateway_pagination.rs | 1 + .../tests/gateway_plugins.rs | 74 +++++++++- .../tests/support/list_tools_gateway.rs | 4 + .../tests/support/mod.rs | 2 +- .../tests/support/plugin_gateway.rs | 48 ++++++- .../tests/secrets_detection_e2e.rs | 4 + schemas/user_config.json | 11 +- tests/conformance/write_client_config.py | 1 + 22 files changed, 368 insertions(+), 26 deletions(-) create mode 100644 crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs diff --git a/Cargo.lock b/Cargo.lock index a1f9d8c..bc874d3 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 05d2867..3501932 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`) @@ -143,7 +151,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`, 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` 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` 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/config.md b/_context/wiki/config.md index a9f7988..315ef53 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 6fb7203..0348a6e 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -92,12 +92,14 @@ 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. +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`) diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index b780fac..283a1d8 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -30,7 +30,7 @@ Protocol-sensitive tests and fixtures must cover MCP `2026-07-28` and `2025-11-2 | `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. diff --git a/crates/contextforge-data-plane-apis/src/user_store.rs b/crates/contextforge-data-plane-apis/src/user_store.rs index 8ded3fa..588f342 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -36,6 +36,8 @@ pub struct BackendMCPGateway { pub allowed_resource_names: Vec, pub allowed_prompt_names: Vec, pub allowed_tool_names: Vec, + /// Input schemas keyed by the original upstream tool name. + pub tool_schemas: HashMap>, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 6505887..681daba 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 ef98ac1..b41f989 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -22,7 +22,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], @@ -152,6 +152,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" @@ -183,6 +184,7 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": ["get_stats"], + "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] }, @@ -191,6 +193,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/mcp_service/initialization.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs index cc0f0d3..0862bda 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 @@ -174,6 +174,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![], diff --git a/crates/contextforge-data-plane-lib/src/gateway/mod.rs b/crates/contextforge-data-plane-lib/src/gateway/mod.rs index d9a754b..9933f44 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mod.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mod.rs @@ -5,4 +5,5 @@ mod identifier_routing; mod mcp_call_validator; mod mcp_service; +pub(crate) use identifier_routing::resolve_tool_route; pub use mcp_service::McpService; 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 0000000..4c556e0 --- /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 97164fe..2c5a263 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 user_config_store; pub mod virtual_host_config; pub mod virtual_host_id; diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index a5d72ea..6fee0f5 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -43,6 +43,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, user_config_store::user_config_store_layer, virtual_host_config::virtual_host_config_layer, virtual_host_id::virtual_host_id_layer, @@ -114,6 +115,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)?; @@ -160,6 +162,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(mcp_add_state.clone(), claims_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 bc4101d..06cd8d0 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,6 +24,13 @@ 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) } @@ -27,3 +40,117 @@ pub(crate) fn is_param(name: &HeaderName) -> bool { .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 c3c48e0..ce41ddd 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 7cdf4d2..4f40521 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,32 @@ 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( @@ -397,11 +424,12 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_forwards_parameter_headers_without_interpretation() { - 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(), - client_with_parameter_headers("9", "2"), + client_with_parameter_headers("1", "2"), support::modern_client_info(), ) .await; @@ -409,10 +437,44 @@ async fn stateless_tool_call_forwards_parameter_headers_without_interpretation() assert_eq!("3", text(&result)); let headers = last_backend_request_headers(&gateway); - assert_eq!("9", headers["Mcp-Param-A"]); + 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)] async fn stateless_tool_error_round_trips() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; @@ -628,7 +690,7 @@ async fn pre_hook_rewrites_payload_without_changing_forwarded_parameter_headers( let observations = plugin.observations(); let runtime = runtime_with_pre(plugin).await; - let gateway = start_gateway(TEST_USER_ID, true, runtime).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"), 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 f88d8af..e870675 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 @@ -231,6 +231,10 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap>>, pub(crate) cancellations: Arc>>, pub(crate) events: Arc>>, + parameter_headers: bool, } #[derive(Clone)] @@ -59,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 +} + #[allow(clippy::unused_async_trait_impl)] impl ServerHandler for TestBackend { async fn initialize( @@ -269,13 +295,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( @@ -316,6 +356,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( { @@ -344,6 +385,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 b197468..bc3aec5 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -380,6 +380,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 69576e9..fde4c53 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -106,6 +106,14 @@ "items": { "type": "string" } + }, + "tool_schemas": { + "description": "Input schemas keyed by the original upstream tool name.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + } } }, "required": [ @@ -114,7 +122,8 @@ "passthrough_headers", "allowed_resource_names", "allowed_prompt_names", - "allowed_tool_names" + "allowed_tool_names", + "tool_schemas" ] } } diff --git a/tests/conformance/write_client_config.py b/tests/conformance/write_client_config.py index 5d67a7f..b31e8b1 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": [], From 618c702df76851568997c377a7102b5c276e585b Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 25 Aug 2026 15:47:43 +0100 Subject: [PATCH 15/16] ci: use latest control-plane main image Signed-off-by: lucarlig --- .github/workflows/mcp_conformance.yml | 6 +- .secrets.baseline | 172 +++++++++--------- _context/wiki/testing.md | 11 +- .../src/gateway/mcp_service/tools.rs | 1 + tests/conformance/expected-failures.yml | 4 + .../resolve-control-plane-image.sh | 33 ++++ tests/conformance/run-local.sh | 6 +- 7 files changed, 142 insertions(+), 91 deletions(-) create mode 100755 tests/conformance/resolve-control-plane-image.sh diff --git a/.github/workflows/mcp_conformance.yml b/.github/workflows/mcp_conformance.yml index 712bcca..3feeb57 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/.secrets.baseline b/.secrets.baseline index 1e44b95..b9b5eff 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,9 +1,9 @@ { "exclude": { - "files": "(?x)(Cargo\\.lock$|\\.lock$|target/|^\\.secrets\\.baseline$)", + "files": "(?x)(Cargo\\.lock$|\\.lock$)|^\\.secrets\\.baseline$|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-08-26T08:54:54Z", + "generated_at": "2026-08-25T17:14:29Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -80,371 +80,371 @@ "assets/contextforgeCA/contextforge-client.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "assets/contextforgeCA/contextforge-server.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "assets/contextforgeCA/contextforge.ca.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "assets/contextforgeCA/contextforge.intermediate.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "assets/jwt.key": [ { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", + "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "assets/tls_key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": false, "line_number": 1, "type": "Private Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/contextforge-data-plane-lib/src/common.rs": [ { "hashed_secret": "4a4645604f0b9e29503be96a87f6f47a6e4a7890", + "is_secret": false, "is_verified": false, "line_number": 154, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "427f5e1b530d4a544883308d876a11d724060c86", + "is_secret": false, "is_verified": false, "line_number": 157, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad", + "is_secret": false, "is_verified": false, "line_number": 263, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/contextforge-data-plane-lib/src/telemetry.rs": [ { "hashed_secret": "0a24796d4c71ce722a92f450f69dc36c60b21de4", + "is_secret": false, "is_verified": false, "line_number": 87, "type": "Hex High Entropy String", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/contextforge-data-plane-lib/tests/support/client.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", + "is_secret": false, "is_verified": false, "line_number": 12, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/contextforge-data-plane-lib/tests/support/mod.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", + "is_secret": false, "is_verified": false, "line_number": 17, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/contextforge-data-plane/Cargo.toml": [ { "hashed_secret": "58e7dc38ba3a7d4a720006d2f3cc4cda774d89dc", + "is_secret": false, "is_verified": false, "line_number": 20, "type": "Hex High Entropy String", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/plugins/cpex-secrets-detection/src/lib.rs": [ { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", + "is_secret": false, "is_verified": false, "line_number": 610, "type": "AWS Access Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/plugins/cpex-secrets-detection/src/scanner.rs": [ { "hashed_secret": "9249e2590f5d19742260cb5296cb76fe0677f147", + "is_secret": false, "is_verified": false, "line_number": 238, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "199da8f71b7dced64f82cf6e96483134cace9b14", + "is_secret": false, "is_verified": false, "line_number": 239, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "c0026c4c848882618c987859077ffbae92130625", + "is_secret": false, "is_verified": false, "line_number": 242, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "078553dc10635837abb80f404302c70cba91b879", + "is_secret": false, "is_verified": false, "line_number": 278, "type": "Base64 High Entropy String", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "e175c6f5f2a92e8623bd9a4820edb4e8c1b0fd10", + "is_secret": false, "is_verified": false, "line_number": 278, "type": "GitHub Token", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "97d99a51e5ac827bb36fe6273facfda35245917a", + "is_secret": false, "is_verified": false, "line_number": 279, "type": "Base64 High Entropy String", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", + "is_secret": false, "is_verified": false, "line_number": 282, "type": "Private Key", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "b1775a785f09a6ebaf2dc33d6eaeb98974d9cdb8", + "is_secret": false, "is_verified": false, "line_number": 284, "type": "Hex High Entropy String", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "eae9124e42e2ef05ba727bd1a1c0c6fa61a05b9e", + "is_secret": false, "is_verified": false, "line_number": 302, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", + "is_secret": false, "is_verified": false, "line_number": 401, "type": "AWS Access Key", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "9d7235fe33b6612ed7ebca4b63afd00d4adf5d66", + "is_secret": false, "is_verified": false, "line_number": 410, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "27a39044bff80a4c196689dfa8dcf129cb27fef8", + "is_secret": false, "is_verified": false, "line_number": 431, "type": "Base64 High Entropy String", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "crates/plugins/cpex-secrets-detection/tests/plugin_manager.rs": [ { "hashed_secret": "436da7d4d22c39c0165ab0d5b40073d0f2fc11c5", + "is_secret": false, "is_verified": false, "line_number": 197, "type": "AWS Access Key", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "8b4510a576d82f38bd2730436bf5e20c4e15b30e", + "is_secret": false, "is_verified": false, "line_number": 198, "type": "AWS Access Key", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "e4ea017859bcad962c8ab551fe29da9147877eee", + "is_secret": false, "is_verified": false, "line_number": 199, "type": "AWS Access Key", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", + "is_secret": false, "is_verified": false, "line_number": 268, "type": "AWS Access Key", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "docker/docker-compose-langfuse.yaml": [ { "hashed_secret": "cb1fde0682fbd1ac0faf2a9f297167ac9d06434b", + "is_secret": false, "is_verified": false, "line_number": 16, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "cb58df830a45cc33df1a313e616ecad78cd796c5", + "is_secret": false, "is_verified": false, "line_number": 77, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "2e0c522bfe4e7885492862df2e0b987c0ca02623", + "is_secret": false, "is_verified": false, "line_number": 100, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "d9d007c8de197b3f36a3a0ba4f13c0f7df175d5a", + "is_secret": false, "is_verified": false, "line_number": 255, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "docker/docker-compose.yml": [ { "hashed_secret": "2a8bfc0ce436d55ca907d0162989481bcb7677b4", + "is_secret": false, "is_verified": false, "line_number": 189, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "fdda45b7f6d2ead95d9991fc4678640c3bab0d84", + "is_secret": false, "is_verified": false, "line_number": 363, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "093d378410a5cfa4bd5088f3fef62fbdb8a95665", + "is_secret": false, "is_verified": false, "line_number": 369, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "c3de40d5e3fc71ed62771c2127a8e42585026c97", + "is_secret": false, "is_verified": false, "line_number": 371, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "4d4acd9b084d13f5fdb23807d857e1c48a1cfd0f", + "is_secret": false, "is_verified": false, "line_number": 460, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "bd0160c2cf35d950843c88f3be2b9412ed71f485", + "is_secret": false, "is_verified": false, "line_number": 495, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null }, { "hashed_secret": "293324f6824bb3a6db5c4dc42a60ddd4a9851c99", + "is_secret": false, "is_verified": false, "line_number": 658, "type": "Hex High Entropy String", - "verified_result": null, - "is_secret": false + "verified_result": null } ], "scripts/git/resolve-secrets-baseline-conflict.sh": [ { "hashed_secret": "44ffd1bfb94772d5f91d528e7aca703990edbbd7", + "is_secret": false, "is_verified": false, "line_number": 31, "type": "Secret Keyword", - "verified_result": null, - "is_secret": false + "verified_result": null } ] }, diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 283a1d8..3d811d4 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -39,9 +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 external dataplane → fixture proxy -→ official server, with the published `latest` Python image's control plane -registering and publishing the fixture through Redis. The backend-only proxy -rewrites `Host` to +→ official 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 external-dataplane header protections unchanged. The control plane uses ephemeral SQLite, so PostgreSQL is unnecessary. The harness lives @@ -58,6 +58,11 @@ a control-plane 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 external 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-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index 9d0b384..950e727 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -46,6 +46,7 @@ pub(super) async fn call_tool( let post_state = pre_result.state; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &tool_name); + let progress_token = cx.meta.get_progress_token(); let handle = backend_service .service() diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml index f39c373..c38bf4c 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 0000000..c1b87cd --- /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 c167402..ea26ab0 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 From 0c47a089dcfdbd62d050f41d7cb6655aeaf8d8de Mon Sep 17 00:00:00 2001 From: lucarlig Date: Wed, 26 Aug 2026 11:10:06 +0100 Subject: [PATCH 16/16] fix: scope MCP parameter validation Signed-off-by: lucarlig --- _context/wiki/architecture.md | 12 ++-- .../src/layers/mcp_param_validation.rs | 59 ++++++++++++++----- crates/contextforge-data-plane-lib/src/lib.rs | 5 +- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index 3501932..8566d38 100644 --- a/_context/wiki/architecture.md +++ b/_context/wiki/architecture.md @@ -22,7 +22,8 @@ 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) + -> DefaultBodyLimit → configures the MCP body cap (413 when exceeded) + -> mcp_param_validation_layer → validates modern tools/call (400/-32020 on mismatch) -> /servers/{virtual_host_name}/mcp RMCP service → validates Host, then dispatches MCP ``` @@ -78,9 +79,12 @@ 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`. +For modern `tools/call`, `DefaultBodyLimit` supplies the configured body cap +before the parameter-header layer reads the body. Non-tool requests bypass this +layer's body processing; RMCP still validates their standard headers. The layer +then resolves the request's backend and original tool name and validates +`Mcp-Param-*` against the 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. 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 index 4c556e0..06e152c 100644 --- a/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs +++ b/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs @@ -1,40 +1,49 @@ use axum::{ + RequestExt, 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 rmcp::{ + model::{ClientJsonRpcMessage, ClientRequest, ErrorData, JsonRpcError}, + transport::common::http_header::HEADER_MCP_METHOD, +}; 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()) { +pub async fn mcp_param_validation_layer(request: http::Request, next: Next) -> Response { + if !should_validate_tool_params(&request) { return next.run(request).await; } - let (parts, body) = request.into_parts(); - let Ok(body) = to_bytes(body, max_request_body_bytes).await else { + // The router's DefaultBodyLimit layer owns the byte budget. Applying it + // here wraps the body without duplicating that limit in this validator. + let (parts, body) = request.with_limited_body().into_parts(); + let Ok(body) = to_bytes(body, usize::MAX).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) { + if let Some(response) = tool_param_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 { +fn should_validate_tool_params(request: &http::Request) -> bool { + // RMCP rejects Mcp-Method/body mismatches before dispatch, so the standard + // header is a safe cheap gate for this tool-only validation. + request.method() == Method::POST + && mcp_standard_headers::required_for(request.headers()) + && request.headers().get(HEADER_MCP_METHOD).and_then(|value| value.to_str().ok()) == Some("tools/call") +} + +fn tool_param_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; @@ -48,7 +57,7 @@ fn validation_error(parts: &http::request::Parts, body: &[u8]) -> Option = 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) { + let mismatch = 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()? @@ -56,7 +65,7 @@ fn validation_error(parts: &http::request::Parts, body: &[u8]) -> Option format!("Missing published schema for tool '{tool_name}'"), }; - let error = JsonRpcError::new(Some(request.id), ErrorData::header_mismatch(reason, None)); + let error = JsonRpcError::new(Some(request.id), ErrorData::header_mismatch(mismatch, None)); let body = serde_json::to_vec(&error).expect("JSON-RPC header mismatch serializes"); Some( Response::builder() @@ -66,3 +75,25 @@ fn validation_error(parts: &http::request::Parts, body: &[u8]) -> Option http::Request { + http::Request::builder() + .method(Method::POST) + .uri("/") + .header("MCP-Protocol-Version", protocol_version) + .header(HEADER_MCP_METHOD, method) + .body(Body::empty()) + .expect("request builds") + } + + #[test] + fn only_modern_tool_calls_require_param_validation() { + assert!(should_validate_tool_params(&request("2026-07-28", "tools/call"))); + assert!(!should_validate_tool_params(&request("2026-07-28", "resources/read"))); + assert!(!should_validate_tool_params(&request("2025-11-25", "tools/call"))); + } +} diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index 6fee0f5..7fd10ac 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -1,6 +1,6 @@ use std::{fs, sync::Arc}; -use axum::middleware; +use axum::{extract::DefaultBodyLimit, middleware}; use axum_otel_metrics::HttpMetricsLayerBuilder; use contextforge_data_plane_cpex::GatewayPluginRuntimeHandle; use futures::FutureExt; @@ -162,7 +162,8 @@ 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(mcp_param_validation_layer)) + .layer(DefaultBodyLimit::max(max_request_body_bytes)) .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(mcp_add_state.clone(), claims_layer))