From bc361a2b88d93c349159008978fa83dfc8f65cc1 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 10:19:43 +0100 Subject: [PATCH 01/13] 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 2f1570e..8f07a8c 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 bf0ac53..43d3475 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -90,6 +90,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 270668d..9729dca 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -57,6 +57,11 @@ 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 401ae52..83c77c9 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 @@ -101,6 +101,17 @@ where }; let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &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 8804520..ec33b59 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -105,12 +105,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 32d88fa..072ba0e 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) +} + impl ServerHandler for TestBackend { fn initialize( &self, @@ -107,6 +150,23 @@ impl ServerHandler for TestBackend { .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, @@ -180,6 +240,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 5b201ba73e45303572cfac3462f514bad7bdc072 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 11:49:42 +0100 Subject: [PATCH 02/13] 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 | 139 ++++++++++- .../src/gateway/mcp_service/prompts.rs | 3 +- .../src/gateway/mcp_service/resources.rs | 3 +- .../src/gateway/mcp_service/tools.rs | 21 +- .../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 | 9 + 23 files changed, 559 insertions(+), 73 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 8f07a8c..0076cb0 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 33a55bc..e41c494 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 43d3475..e8d050a 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -94,17 +94,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 9729dca..a0619fa 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -57,10 +57,10 @@ 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 eddf845..29b0feb 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -25,6 +25,9 @@ pub struct BackendMCPGateway { #[serde(default)] pub remove_headers: Vec, pub allowed_tool_names: Vec, + /// Input schemas keyed by the original upstream tool name. + #[serde(default)] + pub tool_schemas: HashMap>, #[serde(default)] pub tool_name_aliases: HashMap, pub allowed_resource_names: Vec, diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 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 c02b994..b1efa69 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -27,7 +27,7 @@ pub(crate) fn prefixed_name(backend_name: &str, rest: &str) -> String { /// Resolves an exact control-plane alias to its backend and upstream name. Without an alias, /// single-backend hosts preserve the upstream name and multi-backend hosts use the legacy prefix. -pub(super) fn resolve_tool_route<'a, N: AsRef>( +pub(crate) fn resolve_tool_route<'a, N: AsRef>( virtual_host: &'a VirtualHost, name: &'a str, backend_names: &'a [N], diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs index 4b86148..d0ea6a7 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,17 +1,25 @@ 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, ServiceExt, 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::{info, warn}; use super::McpService; @@ -23,6 +31,119 @@ use crate::gateway::{ }; 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 + } +} + pub(super) async fn initialize( mcp_service: &McpService, request: InitializeRequestParams, @@ -199,14 +320,15 @@ fn merge_and_build_capabilities(server_capabilities: Vec<(String, Option( mcp_service: &McpService, - backend_name: &str, - backend: &BackendMCPGateway, + backend: (&str, &BackendMCPGateway), + tool_name: Option<&str>, namespace_identifiers: bool, cx: &RequestContext, ) -> Result, ErrorData> where T: UserSessionStore + Send + Sync + 'static, { + let (backend_name, backend) = backend; let mut headers = HashMap::new(); let downstream_headers = cx.extensions.get::().map(|parts| &parts.headers); @@ -224,8 +346,10 @@ where 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")), @@ -353,6 +477,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 1c3d0a6..9c4a41c 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 @@ -100,7 +100,8 @@ where PromptPreFetchResult::unchanged() }; let mut backend_service = - connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &cx).await?; + connect_backend_for_request(mcp_service, (&backend_name, backend), None, virtual_host.backends.len() > 1, &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 504cad6..75210b5 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 @@ -97,7 +97,8 @@ where let service_name = backend_name.clone(); let mut backend_service = - connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &cx).await?; + connect_backend_for_request(mcp_service, (&backend_name, backend), None, virtual_host.backends.len() > 1, &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 83c77c9..4ecd89a 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 @@ -99,22 +99,17 @@ where } else { ToolPreCallResult::unchanged() }; - let mut backend_service = - connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &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), + virtual_host.backends.len() > 1, + &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 8bf5f23..d1e46f5 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mod.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mod.rs @@ -8,5 +8,6 @@ mod session_manager; mod session_store; pub use backend_transports::BackendTransports; +pub(crate) use identifier_routing::resolve_tool_route; pub use mcp_service::McpService; pub use session_store::{LocalUserSessionStore, UserSession, UserSessionStore}; diff --git a/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs b/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs new file mode 100644 index 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 83af1e2..ead44cc 100644 --- a/crates/contextforge-data-plane-lib/src/layers/mod.rs +++ b/crates/contextforge-data-plane-lib/src/layers/mod.rs @@ -1,6 +1,7 @@ pub mod claims_id; pub mod mcp_header_limits; pub mod mcp_origin; +pub mod mcp_param_validation; pub mod session_id; pub mod user_config_store; pub mod virtual_host_config; diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index ec33b59..fd275e4 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -44,6 +44,7 @@ use crate::{ claims_id::claims_layer, mcp_header_limits::{McpStandardHeaderLimits, mcp_header_limits_layer}, mcp_origin::mcp_origin_layer, + mcp_param_validation::mcp_param_validation_layer, session_id::{SessionIdState, session_id_layer}, user_config_store::user_config_store_layer, virtual_host_config::virtual_host_config_layer, @@ -115,6 +116,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, LocalSessionManager> = @@ -162,6 +164,7 @@ impl Gateway { let app = axum::Router::new() .nest_service("/servers/{virtual_host_name}/mcp", mcp_service) + .layer(middleware::from_fn_with_state(max_request_body_bytes, mcp_param_validation_layer)) .layer(middleware::from_fn(virtual_host_config_layer)) .layer(middleware::from_fn_with_state(mcp_add_state.clone(), user_config_store_layer)) .layer(middleware::from_fn_with_state(session_id_state, session_id_layer)) diff --git a/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs b/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs index 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 558f98d..4fad052 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 f283bd2..c2c39bf 100644 --- a/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs @@ -224,6 +224,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() +} + impl ServerHandler for TestBackend { fn initialize( &self, @@ -155,6 +166,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()])) } @@ -402,6 +414,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 929d723..07f2dd4 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -379,6 +379,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 4afe3ab..6b408fc 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -67,6 +67,15 @@ "type": "string" } }, + "tool_schemas": { + "description": "Input schemas keyed by the original upstream tool name.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + }, + "default": {} + }, "tool_name_aliases": { "type": "object", "additionalProperties": { From 789afe270178d33792cc0c60e9514ffeb5d0aee8 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 11:56:23 +0100 Subject: [PATCH 03/13] 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 e8c63c1b6349d39f6772e2221cdfc3838a01157f Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 11:59:51 +0100 Subject: [PATCH 04/13] 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 336a3bae61805a5a8680e9055e6cf4764b75d7b9 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 12:07:13 +0100 Subject: [PATCH 05/13] 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 a0619fa..5f3e798 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -64,6 +64,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 3470c4339cd747e4211990e98a46bd2a020091a1 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 12:12:36 +0100 Subject: [PATCH 06/13] 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 edab6088fbf1a7f6011680db39eb7dda020c4b3b Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 13:23:45 +0100 Subject: [PATCH 07/13] 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 +++ .../src/gateway/list_aggregation.rs | 1 + .../tests/gateway_plugins.rs | 9 ++++++--- schemas/user_config.json | 4 ++-- 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 ++-------- 10 files changed, 16 insertions(+), 25 deletions(-) diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 5f3e798..a0619fa 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -64,10 +64,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 29b0feb..a903abb 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -26,7 +26,6 @@ pub struct BackendMCPGateway { pub remove_headers: Vec, pub allowed_tool_names: Vec, /// Input schemas keyed by the original upstream tool name. - #[serde(default)] pub tool_schemas: HashMap>, #[serde(default)] pub tool_name_aliases: 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 b1efa69..663fa85 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -184,6 +184,7 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": ["get_stats", "echo"], + "tool_schemas": {}, "tool_name_aliases": { "Public.Tool": "get_stats", "Echo_Tool": "echo" @@ -215,6 +216,7 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": ["get_stats"], + "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] }, @@ -223,6 +225,7 @@ mod tests { "url": "http://other:9000/mcp", "passthrough_headers": [], "allowed_tool_names": [], + "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] } diff --git a/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs b/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs index deeb243..e33fbae 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs @@ -268,6 +268,7 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": [], + "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] } diff --git a/crates/contextforge-data-plane-lib/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 6b408fc..530d51c 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -73,8 +73,7 @@ "additionalProperties": { "type": "object", "additionalProperties": true - }, - "default": {} + } }, "tool_name_aliases": { "type": "object", @@ -101,6 +100,7 @@ "url", "passthrough_headers", "allowed_tool_names", + "tool_schemas", "allowed_resource_names", "allowed_prompt_names" ] 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 77c7c2a25ec5bc39e5c51a23f7f0d4d69188034b Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 13:52:49 +0100 Subject: [PATCH 08/13] 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 e8d050a..0c35127 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -97,7 +97,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 d0ea6a7..92ea072 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 @@ -321,7 +321,7 @@ fn merge_and_build_capabilities(server_capabilities: Vec<(String, Option( mcp_service: &McpService, backend: (&str, &BackendMCPGateway), - tool_name: Option<&str>, + tool_schema: Option<&JsonObject>, namespace_identifiers: bool, cx: &RequestContext, ) -> Result, ErrorData> @@ -346,7 +346,7 @@ where 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 4ecd89a..ee12744 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 @@ -93,6 +93,10 @@ where 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? @@ -105,7 +109,7 @@ where let mut backend_service = connect_backend_for_request( mcp_service, (&backend_name, backend), - Some(&tool_name), + Some(tool_schema), virtual_host.backends.len() > 1, &cx, ) 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 c2c39bf..e90f18e 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; @@ -224,7 +227,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 95c1b31..f34d923 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() } impl ServerHandler for TestBackend { @@ -167,16 +175,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 c6c95f6e357d4b6e7963af8c5649ecaab0133459 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 14:29:41 +0100 Subject: [PATCH 09/13] fix: satisfy Rust 1.98 clippy Signed-off-by: lucarlig --- .secrets.baseline | 172 +++++++++--------- .../src/handle.rs | 3 + .../tests/support/mod.rs | 2 + 3 files changed, 91 insertions(+), 86 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 01d7538..21bed6d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(Cargo\\.lock$|\\.lock$)|^\\.secrets\\.baseline$", "lines": null }, - "generated_at": "2026-08-19T10:59:12Z", + "generated_at": "2026-08-21T13:28:33Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -80,371 +80,371 @@ "assets/contextforgeCA/contextforge-client.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "assets/contextforgeCA/contextforge-server.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "assets/contextforgeCA/contextforge.ca.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "assets/contextforgeCA/contextforge.intermediate.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "assets/jwt.key": [ { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", + "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "assets/tls_key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", + "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/contextforge-data-plane-lib/src/common.rs": [ { "hashed_secret": "4a4645604f0b9e29503be96a87f6f47a6e4a7890", + "is_secret": false, "is_verified": true, "line_number": 154, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "427f5e1b530d4a544883308d876a11d724060c86", + "is_secret": false, "is_verified": true, "line_number": 157, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad", + "is_secret": false, "is_verified": true, "line_number": 263, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/contextforge-data-plane-lib/src/telemetry.rs": [ { "hashed_secret": "0a24796d4c71ce722a92f450f69dc36c60b21de4", + "is_secret": false, "is_verified": true, "line_number": 87, "type": "Hex High Entropy String", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/contextforge-data-plane-lib/tests/support/client.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", + "is_secret": false, "is_verified": true, "line_number": 12, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/contextforge-data-plane-lib/tests/support/mod.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", + "is_secret": false, "is_verified": true, - "line_number": 17, + "line_number": 19, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/contextforge-data-plane/Cargo.toml": [ { "hashed_secret": "58e7dc38ba3a7d4a720006d2f3cc4cda774d89dc", + "is_secret": false, "is_verified": true, "line_number": 19, "type": "Hex High Entropy String", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/plugins/cpex-secrets-detection/src/lib.rs": [ { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", + "is_secret": false, "is_verified": true, "line_number": 609, "type": "AWS Access Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/plugins/cpex-secrets-detection/src/scanner.rs": [ { "hashed_secret": "9249e2590f5d19742260cb5296cb76fe0677f147", + "is_secret": false, "is_verified": true, "line_number": 238, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "199da8f71b7dced64f82cf6e96483134cace9b14", + "is_secret": false, "is_verified": true, "line_number": 239, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "c0026c4c848882618c987859077ffbae92130625", + "is_secret": false, "is_verified": true, "line_number": 242, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "078553dc10635837abb80f404302c70cba91b879", + "is_secret": false, "is_verified": true, "line_number": 278, "type": "Base64 High Entropy String", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "e175c6f5f2a92e8623bd9a4820edb4e8c1b0fd10", + "is_secret": false, "is_verified": true, "line_number": 278, "type": "GitHub Token", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "97d99a51e5ac827bb36fe6273facfda35245917a", + "is_secret": false, "is_verified": true, "line_number": 279, "type": "Base64 High Entropy String", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", + "is_secret": false, "is_verified": true, "line_number": 282, "type": "Private Key", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "b1775a785f09a6ebaf2dc33d6eaeb98974d9cdb8", + "is_secret": false, "is_verified": true, "line_number": 284, "type": "Hex High Entropy String", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "eae9124e42e2ef05ba727bd1a1c0c6fa61a05b9e", + "is_secret": false, "is_verified": true, "line_number": 302, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", + "is_secret": false, "is_verified": true, "line_number": 401, "type": "AWS Access Key", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "9d7235fe33b6612ed7ebca4b63afd00d4adf5d66", + "is_secret": false, "is_verified": true, "line_number": 410, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "27a39044bff80a4c196689dfa8dcf129cb27fef8", + "is_secret": false, "is_verified": true, "line_number": 431, "type": "Base64 High Entropy String", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "crates/plugins/cpex-secrets-detection/tests/plugin_manager.rs": [ { "hashed_secret": "436da7d4d22c39c0165ab0d5b40073d0f2fc11c5", + "is_secret": false, "is_verified": true, "line_number": 197, "type": "AWS Access Key", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "8b4510a576d82f38bd2730436bf5e20c4e15b30e", + "is_secret": false, "is_verified": true, "line_number": 198, "type": "AWS Access Key", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "e4ea017859bcad962c8ab551fe29da9147877eee", + "is_secret": false, "is_verified": true, "line_number": 199, "type": "AWS Access Key", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", + "is_secret": false, "is_verified": true, "line_number": 268, "type": "AWS Access Key", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "docker/docker-compose-langfuse.yaml": [ { "hashed_secret": "cb1fde0682fbd1ac0faf2a9f297167ac9d06434b", + "is_secret": false, "is_verified": true, "line_number": 16, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "cb58df830a45cc33df1a313e616ecad78cd796c5", + "is_secret": false, "is_verified": true, "line_number": 77, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "2e0c522bfe4e7885492862df2e0b987c0ca02623", + "is_secret": false, "is_verified": true, "line_number": 100, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "d9d007c8de197b3f36a3a0ba4f13c0f7df175d5a", + "is_secret": false, "is_verified": true, "line_number": 255, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "docker/docker-compose.yml": [ { "hashed_secret": "2a8bfc0ce436d55ca907d0162989481bcb7677b4", + "is_secret": false, "is_verified": true, "line_number": 189, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "fdda45b7f6d2ead95d9991fc4678640c3bab0d84", + "is_secret": false, "is_verified": true, "line_number": 363, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "093d378410a5cfa4bd5088f3fef62fbdb8a95665", + "is_secret": false, "is_verified": true, "line_number": 369, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "c3de40d5e3fc71ed62771c2127a8e42585026c97", + "is_secret": false, "is_verified": true, "line_number": 371, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "4d4acd9b084d13f5fdb23807d857e1c48a1cfd0f", + "is_secret": false, "is_verified": true, "line_number": 460, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "bd0160c2cf35d950843c88f3be2b9412ed71f485", + "is_secret": false, "is_verified": true, "line_number": 495, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true }, { "hashed_secret": "293324f6824bb3a6db5c4dc42a60ddd4a9851c99", + "is_secret": false, "is_verified": true, "line_number": 658, "type": "Hex High Entropy String", - "verified_result": true, - "is_secret": false + "verified_result": true } ], "scripts/git/resolve-secrets-baseline-conflict.sh": [ { "hashed_secret": "44ffd1bfb94772d5f91d528e7aca703990edbbd7", + "is_secret": false, "is_verified": true, "line_number": 31, "type": "Secret Keyword", - "verified_result": true, - "is_secret": false + "verified_result": true } ] }, diff --git a/crates/contextforge-data-plane-cpex/src/handle.rs b/crates/contextforge-data-plane-cpex/src/handle.rs index 5251758..4fb85fb 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 4af7e758761db237587ba217370607ea76af893d Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 15:43:20 +0100 Subject: [PATCH 10/13] refactor: forward validated MCP parameter headers Signed-off-by: lucarlig --- .secrets.baseline | 168 +++++++++--------- 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 | 158 +++------------- .../src/gateway/mcp_service/prompts.rs | 2 +- .../src/gateway/mcp_service/resources.rs | 2 +- .../src/gateway/mcp_service/tools.rs | 15 +- .../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, 227 insertions(+), 334 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 21bed6d..5042668 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -80,371 +80,371 @@ "assets/contextforgeCA/contextforge-client.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "assets/contextforgeCA/contextforge-server.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "assets/contextforgeCA/contextforge.ca.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "assets/contextforgeCA/contextforge.intermediate.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "assets/jwt.key": [ { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "assets/tls_key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/src/common.rs": [ { "hashed_secret": "4a4645604f0b9e29503be96a87f6f47a6e4a7890", - "is_secret": false, "is_verified": true, "line_number": 154, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "427f5e1b530d4a544883308d876a11d724060c86", - "is_secret": false, "is_verified": true, "line_number": 157, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad", - "is_secret": false, "is_verified": true, "line_number": 263, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/src/telemetry.rs": [ { "hashed_secret": "0a24796d4c71ce722a92f450f69dc36c60b21de4", - "is_secret": false, "is_verified": true, "line_number": 87, "type": "Hex High Entropy String", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/tests/support/client.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", - "is_secret": false, "is_verified": true, "line_number": 12, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/tests/support/mod.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", - "is_secret": false, "is_verified": true, "line_number": 19, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane/Cargo.toml": [ { "hashed_secret": "58e7dc38ba3a7d4a720006d2f3cc4cda774d89dc", - "is_secret": false, "is_verified": true, "line_number": 19, "type": "Hex High Entropy String", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/src/lib.rs": [ { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, "is_verified": true, "line_number": 609, "type": "AWS Access Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/src/scanner.rs": [ { "hashed_secret": "9249e2590f5d19742260cb5296cb76fe0677f147", - "is_secret": false, "is_verified": true, "line_number": 238, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "199da8f71b7dced64f82cf6e96483134cace9b14", - "is_secret": false, "is_verified": true, "line_number": 239, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "c0026c4c848882618c987859077ffbae92130625", - "is_secret": false, "is_verified": true, "line_number": 242, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "078553dc10635837abb80f404302c70cba91b879", - "is_secret": false, "is_verified": true, "line_number": 278, "type": "Base64 High Entropy String", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "e175c6f5f2a92e8623bd9a4820edb4e8c1b0fd10", - "is_secret": false, "is_verified": true, "line_number": 278, "type": "GitHub Token", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "97d99a51e5ac827bb36fe6273facfda35245917a", - "is_secret": false, "is_verified": true, "line_number": 279, "type": "Base64 High Entropy String", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, "is_verified": true, "line_number": 282, "type": "Private Key", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "b1775a785f09a6ebaf2dc33d6eaeb98974d9cdb8", - "is_secret": false, "is_verified": true, "line_number": 284, "type": "Hex High Entropy String", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "eae9124e42e2ef05ba727bd1a1c0c6fa61a05b9e", - "is_secret": false, "is_verified": true, "line_number": 302, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, "is_verified": true, "line_number": 401, "type": "AWS Access Key", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "9d7235fe33b6612ed7ebca4b63afd00d4adf5d66", - "is_secret": false, "is_verified": true, "line_number": 410, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "27a39044bff80a4c196689dfa8dcf129cb27fef8", - "is_secret": false, "is_verified": true, "line_number": 431, "type": "Base64 High Entropy String", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/tests/plugin_manager.rs": [ { "hashed_secret": "436da7d4d22c39c0165ab0d5b40073d0f2fc11c5", - "is_secret": false, "is_verified": true, "line_number": 197, "type": "AWS Access Key", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "8b4510a576d82f38bd2730436bf5e20c4e15b30e", - "is_secret": false, "is_verified": true, "line_number": 198, "type": "AWS Access Key", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "e4ea017859bcad962c8ab551fe29da9147877eee", - "is_secret": false, "is_verified": true, "line_number": 199, "type": "AWS Access Key", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, "is_verified": true, "line_number": 268, "type": "AWS Access Key", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "docker/docker-compose-langfuse.yaml": [ { "hashed_secret": "cb1fde0682fbd1ac0faf2a9f297167ac9d06434b", - "is_secret": false, "is_verified": true, "line_number": 16, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "cb58df830a45cc33df1a313e616ecad78cd796c5", - "is_secret": false, "is_verified": true, "line_number": 77, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "2e0c522bfe4e7885492862df2e0b987c0ca02623", - "is_secret": false, "is_verified": true, "line_number": 100, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "d9d007c8de197b3f36a3a0ba4f13c0f7df175d5a", - "is_secret": false, "is_verified": true, "line_number": 255, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "docker/docker-compose.yml": [ { "hashed_secret": "2a8bfc0ce436d55ca907d0162989481bcb7677b4", - "is_secret": false, "is_verified": true, "line_number": 189, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "fdda45b7f6d2ead95d9991fc4678640c3bab0d84", - "is_secret": false, "is_verified": true, "line_number": 363, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "093d378410a5cfa4bd5088f3fef62fbdb8a95665", - "is_secret": false, "is_verified": true, "line_number": 369, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "c3de40d5e3fc71ed62771c2127a8e42585026c97", - "is_secret": false, "is_verified": true, "line_number": 371, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "4d4acd9b084d13f5fdb23807d857e1c48a1cfd0f", - "is_secret": false, "is_verified": true, "line_number": 460, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "bd0160c2cf35d950843c88f3be2b9412ed71f485", - "is_secret": false, "is_verified": true, "line_number": 495, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false }, { "hashed_secret": "293324f6824bb3a6db5c4dc42a60ddd4a9851c99", - "is_secret": false, "is_verified": true, "line_number": 658, "type": "Hex High Entropy String", - "verified_result": true + "verified_result": true, + "is_secret": false } ], "scripts/git/resolve-secrets-baseline-conflict.sh": [ { "hashed_secret": "44ffd1bfb94772d5f91d528e7aca703990edbbd7", - "is_secret": false, "is_verified": true, "line_number": 31, "type": "Secret Keyword", - "verified_result": true + "verified_result": true, + "is_secret": false } ] }, 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 0076cb0..758e927 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 0c35127..f03306b 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -99,10 +99,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 92ea072..cb850e5 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,25 +1,17 @@ use std::{collections::HashMap, sync::Arc}; 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, ServiceExt, 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::{info, warn}; use super::McpService; @@ -31,119 +23,6 @@ use crate::gateway::{ }; 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 - } -} - pub(super) async fn initialize( mcp_service: &McpService, request: InitializeRequestParams, @@ -321,7 +200,6 @@ fn merge_and_build_capabilities(server_capabilities: Vec<(String, Option( mcp_service: &McpService, backend: (&str, &BackendMCPGateway), - tool_schema: Option<&JsonObject>, namespace_identifiers: bool, cx: &RequestContext, ) -> Result, ErrorData> @@ -344,12 +222,11 @@ where } 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")), @@ -382,6 +259,19 @@ where }) } +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, @@ -425,6 +315,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", @@ -586,15 +478,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"], @@ -608,9 +499,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 9c4a41c..6cc6fd5 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 @@ -100,7 +100,7 @@ where PromptPreFetchResult::unchanged() }; let mut backend_service = - connect_backend_for_request(mcp_service, (&backend_name, backend), None, virtual_host.backends.len() > 1, &cx) + connect_backend_for_request(mcp_service, (&backend_name, backend), virtual_host.backends.len() > 1, &cx) .await?; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &prompt_name); 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 75210b5..7cf5e49 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 @@ -97,7 +97,7 @@ where let service_name = backend_name.clone(); let mut backend_service = - connect_backend_for_request(mcp_service, (&backend_name, backend), None, virtual_host.backends.len() > 1, &cx) + connect_backend_for_request(mcp_service, (&backend_name, backend), virtual_host.backends.len() > 1, &cx) .await?; let mut routed_request = request; 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 ee12744..0d3f7ab 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 @@ -93,10 +93,6 @@ where 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? @@ -106,14 +102,9 @@ where 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), - virtual_host.backends.len() > 1, - &cx, - ) - .await?; + let mut backend_service = + connect_backend_for_request(mcp_service, (&backend_name, backend), virtual_host.backends.len() > 1, &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 f34d923..7f523b1 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() } impl ServerHandler for TestBackend { @@ -175,11 +190,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( @@ -342,6 +357,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, @@ -389,6 +419,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( { @@ -417,7 +448,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 44144bcbc5d18f4653d70307236e83b4fcb31323 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 16:18:46 +0100 Subject: [PATCH 11/13] 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/list_aggregation.rs | 1 - .../src/gateway/mcp_service/initialization.rs | 31 +--- .../src/gateway/mcp_service/prompts.rs | 3 +- .../src/gateway/mcp_service/resources.rs | 3 +- .../src/gateway/mcp_service/tools.rs | 5 +- .../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 | 9 - tests/conformance/write_client_config.py | 5 +- 26 files changed, 97 insertions(+), 488 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 758e927..cb346b5 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 e41c494..33a55bc 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 f03306b..f4f0c4f 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -93,18 +93,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 a0619fa..3b7fd4d 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -57,10 +57,11 @@ 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 a903abb..eddf845 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -25,8 +25,6 @@ pub struct BackendMCPGateway { #[serde(default)] pub remove_headers: Vec, pub allowed_tool_names: Vec, - /// Input schemas keyed by the original upstream tool name. - pub tool_schemas: HashMap>, #[serde(default)] pub tool_name_aliases: HashMap, pub allowed_resource_names: Vec, diff --git a/crates/contextforge-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 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 663fa85..c02b994 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/identifier_routing.rs @@ -27,7 +27,7 @@ pub(crate) fn prefixed_name(backend_name: &str, rest: &str) -> String { /// Resolves an exact control-plane alias to its backend and upstream name. Without an alias, /// single-backend hosts preserve the upstream name and multi-backend hosts use the legacy prefix. -pub(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], @@ -184,7 +184,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" @@ -216,7 +215,6 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": ["get_stats"], - "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] }, @@ -225,7 +223,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/list_aggregation.rs b/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs index e33fbae..deeb243 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/list_aggregation.rs @@ -268,7 +268,6 @@ mod tests { "url": "http://upstream:9000/mcp", "passthrough_headers": [], "allowed_tool_names": [], - "tool_schemas": {}, "allowed_resource_names": [], "allowed_prompt_names": [] } diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs index cb850e5..68821f6 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs @@ -199,14 +199,14 @@ fn merge_and_build_capabilities(server_capabilities: Vec<(String, Option( mcp_service: &McpService, - backend: (&str, &BackendMCPGateway), + backend_name: &str, + backend: &BackendMCPGateway, namespace_identifiers: bool, cx: &RequestContext, ) -> Result, ErrorData> where T: UserSessionStore + Send + Sync + 'static, { - let (backend_name, backend) = backend; let mut headers = HashMap::new(); let downstream_headers = cx.extensions.get::().map(|parts| &parts.headers); @@ -222,7 +222,6 @@ where } 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); @@ -259,19 +258,6 @@ where }) } -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, @@ -288,6 +274,12 @@ fn apply_header_config( headers.insert(name, value.clone()); } } + headers.extend( + downstream + .iter() + .filter(|(name, _)| mcp_standard_headers::is_param(name)) + .map(|(name, value)| (name.clone(), value.clone())), + ); } for (name, value) in &backend.add_headers { let (Ok(name), Ok(value)) = (http::HeaderName::from_bytes(name.as_bytes()), http::HeaderValue::from_str(value)) @@ -316,7 +308,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", @@ -369,7 +361,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![], @@ -478,7 +469,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(&[ @@ -499,8 +490,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 6cc6fd5..1c3d0a6 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 @@ -100,8 +100,7 @@ where PromptPreFetchResult::unchanged() }; let mut backend_service = - connect_backend_for_request(mcp_service, (&backend_name, backend), virtual_host.backends.len() > 1, &cx) - .await?; + connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &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 7cf5e49..504cad6 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 @@ -97,8 +97,7 @@ where let service_name = backend_name.clone(); let mut backend_service = - connect_backend_for_request(mcp_service, (&backend_name, backend), virtual_host.backends.len() > 1, &cx) - .await?; + connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &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 0d3f7ab..401ae52 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 @@ -99,12 +99,11 @@ where } else { ToolPreCallResult::unchanged() }; + let mut backend_service = + connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &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), virtual_host.backends.len() > 1, &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 d1e46f5..8bf5f23 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mod.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mod.rs @@ -8,6 +8,5 @@ mod session_manager; mod session_store; pub use backend_transports::BackendTransports; -pub(crate) use identifier_routing::resolve_tool_route; pub use mcp_service::McpService; pub use session_store::{LocalUserSessionStore, UserSession, UserSessionStore}; diff --git a/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs b/crates/contextforge-data-plane-lib/src/layers/mcp_param_validation.rs 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 ead44cc..83af1e2 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 session_id; pub mod user_config_store; pub mod virtual_host_config; diff --git a/crates/contextforge-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index fd275e4..ec33b59 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -44,7 +44,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, session_id::{SessionIdState, session_id_layer}, user_config_store::user_config_store_layer, virtual_host_config::virtual_host_config_layer, @@ -116,7 +115,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, LocalSessionManager> = @@ -164,7 +162,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(session_id_state, session_id_layer)) diff --git a/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs b/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs index 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 4fad052..558f98d 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 e90f18e..f283bd2 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; @@ -227,7 +224,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 7f523b1..7c29e9f 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() -} - impl ServerHandler for TestBackend { fn initialize( &self, @@ -184,24 +108,18 @@ impl ServerHandler for TestBackend { .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() @@ -357,21 +275,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, @@ -419,7 +322,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( { @@ -448,7 +350,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 07f2dd4..929d723 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -379,7 +379,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 530d51c..4afe3ab 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -67,14 +67,6 @@ "type": "string" } }, - "tool_schemas": { - "description": "Input schemas keyed by the original upstream tool name.", - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": true - } - }, "tool_name_aliases": { "type": "object", "additionalProperties": { @@ -100,7 +92,6 @@ "url", "passthrough_headers", "allowed_tool_names", - "tool_schemas", "allowed_resource_names", "allowed_prompt_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 e3abd7e300ab44c351cb1c2a8c77960d3d630172 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 21 Aug 2026 17:41:36 +0100 Subject: [PATCH 12/13] 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 5042668..01d7538 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(Cargo\\.lock$|\\.lock$)|^\\.secrets\\.baseline$", "lines": null }, - "generated_at": "2026-08-21T13:28:33Z", + "generated_at": "2026-08-19T10:59:12Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -187,7 +187,7 @@ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", "is_verified": true, - "line_number": 19, + "line_number": 17, "type": "Secret Keyword", "verified_result": true, "is_secret": false diff --git a/crates/contextforge-data-plane-cpex/src/handle.rs b/crates/contextforge-data-plane-cpex/src/handle.rs index 4fb85fb..5251758 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 374730d9ba6ed863315129c9ac60b592985c437d Mon Sep 17 00:00:00 2001 From: lucarlig Date: Sat, 22 Aug 2026 12:02:49 +0100 Subject: [PATCH 13/13] 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 cb346b5..597fcea 100644 --- a/_context/wiki/architecture.md +++ b/_context/wiki/architecture.md @@ -143,7 +143,7 @@ The binary sets `tikv_jemallocator` as the global allocator. jemalloc holds up b - `initialize` opens one backend transport per configured backend concurrently (`futures::future::join_all`); a failed backend degrades that backend only. - List methods fan out to all connected backends concurrently and merge. - Targeted calls (except `call_tool`) resolve exactly one backend service handle from `BackendTransports`. -- `call_tool` creates a fresh per-request backend connection via `connect_backend_for_request`, 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 f4f0c4f..30d164f 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -90,17 +90,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 3b7fd4d..270668d 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -57,12 +57,6 @@ 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 ec33b59..8804520 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -105,13 +105,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 7c29e9f..212f9dc 100644 --- a/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs @@ -188,7 +188,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__":