From 8b963fbad825c7e43f0ee56503d234b651b119bc Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 1 Sep 2026 11:55:26 +0100 Subject: [PATCH 1/2] fix: allow calls without published tool schemas Signed-off-by: lucarlig --- _context/wiki/architecture.md | 11 ++++++----- _context/wiki/config.md | 8 +++++++- _context/wiki/security.md | 8 +++++--- _context/wiki/testing.md | 3 +++ .../src/user_store.rs | 1 + .../tests/user_store.rs | 15 +++++++++++++++ .../src/gateway/mcp_service/tools.rs | 7 +++---- .../tests/gateway_plugins.rs | 8 +++++--- schemas/user_config.json | 6 +++--- 9 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 crates/contextforge-data-plane-apis/tests/user_store.rs diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index 593e7c72..d1a148a7 100644 --- a/_context/wiki/architecture.md +++ b/_context/wiki/architecture.md @@ -81,11 +81,12 @@ flowchart TD RMCP enforces its configured request-body cap and validates modern standard headers before dispatch. The `tools/call` handler then resolves the request's -backend and original tool name and validates `Mcp-Param-*` from the request -context against the schema published in `UserConfig`; it does not call backend -`tools/list`. -Validated headers are forwarded unchanged; request plugins run afterward, so a -plugin that changes an annotated argument also owns the resulting upstream +backend and original tool name. When `UserConfig` contains that tool's input +schema, it validates recognized `Mcp-Param-*` headers against the request body; +it does not call backend `tools/list`. Without a published schema, parameter +headers are unrecognized and forwarded without local validation. +Parameter headers are forwarded unchanged; request plugins run afterward, so a +plugin that changes an annotated argument also owns any resulting upstream mismatch. Order is invariant: auth/config before backend selection; request plugins before upstream; response plugins before returning. diff --git a/_context/wiki/config.md b/_context/wiki/config.md index 315ef53d..ec233574 100644 --- a/_context/wiki/config.md +++ b/_context/wiki/config.md @@ -129,12 +129,18 @@ BackendMCPGateway add_headers: HashMap ← injected after passthrough remove_headers: Vec ← stripped after add allowed_tool_names: Vec ← model exists, NOT currently enforced - tool_schemas: HashMap ← required; upstream name → input schema + tool_schemas: HashMap ← optional, defaults to {}; upstream name → input schema tool_name_aliases: HashMap ← downstream_alias → upstream_original allowed_resource_names: Vec ← model exists, NOT currently enforced allowed_prompt_names: Vec ← model exists, NOT currently enforced ``` +`tool_schemas` lets the dataplane recognize and validate `x-mcp-header` +annotations without calling backend `tools/list`. The control plane may omit the +field or individual unannotated tools. Without a published schema, parameter +headers are forwarded as unrecognized intermediary headers and are not locally +validated. + **Header apply order:** `passthrough_headers` → `add_headers` (override passthrough) → `remove_headers` (applied last). **`passthrough_headers` is session-scoped.** Values are snapshotted from the `initialize` request and baked into the backend transport for the session lifetime. Post-`initialize` calls (tool calls, list calls) reuse those headers. Request-scoped propagation requires per-request transport reconstruction (future work). diff --git a/_context/wiki/security.md b/_context/wiki/security.md index 29c14732..a39db335 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -94,9 +94,11 @@ bounded by the HTTP transport. Backend header policy cannot add, remove, or replace MCP standard or parameter headers. For modern `tools/call`, the dataplane resolves the authenticated user, virtual host, backend, and original tool name before validating -`Mcp-Param-*` against the control-plane-published input schema. A missing schema -or header/body mismatch fails closed with JSON-RPC `-32020`. -Validation does not call backend `tools/list`. Validated values are forwarded +recognized `Mcp-Param-*` against the control-plane-published input schema. A +recognized header/body mismatch fails closed with JSON-RPC `-32020`. When no +schema is published, parameter headers are unrecognized and forwarded without +local validation; their absence does not block the tool call. +Validation does not call backend `tools/list`. Parameter values are forwarded unchanged, while RMCP regenerates method, routed-name, and protocol-version headers. If a plugin later changes an annotated argument, the original header remains and the upstream server may reject the mismatch. diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index cf17c7d9..551a62b3 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -34,6 +34,9 @@ Protocol-sensitive tests and fixtures must cover MCP `2026-07-28` and `2025-11-2 These run in `cargo nextest run` with no Docker dependencies. +Parameter-header integration tests verify that calls without a published tool +schema skip local `Mcp-Param-*` validation and still reach the backend. + ## MCP Conformance [`cf-integration`](https://crates.io/crates/cf-integration) diff --git a/crates/contextforge-data-plane-apis/src/user_store.rs b/crates/contextforge-data-plane-apis/src/user_store.rs index 3712765b..aebdb4cf 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -68,6 +68,7 @@ pub struct BackendMCPGateway { #[serde(default)] pub completion: HashMap, /// Input schemas keyed by the original upstream tool name. + #[serde(default)] pub tool_schemas: HashMap>, } diff --git a/crates/contextforge-data-plane-apis/tests/user_store.rs b/crates/contextforge-data-plane-apis/tests/user_store.rs new file mode 100644 index 00000000..ecde5387 --- /dev/null +++ b/crates/contextforge-data-plane-apis/tests/user_store.rs @@ -0,0 +1,15 @@ +use contextforge_data_plane_apis::user_store::BackendMCPGateway; +use serde_json::json; + +#[test] +fn backend_config_without_tool_schemas_defaults_to_empty_map() { + let config: BackendMCPGateway = serde_json::from_value(json!({ + "name": "backend", + "url": "http://localhost:8000/mcp", + "mcp_protocol_version": "2026_07_28", + "passthrough_headers": [] + })) + .expect("backend config without tool schemas should deserialize"); + + assert!(config.tool_schemas.is_empty()); +} 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 d820a33d..b334a562 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -46,15 +46,14 @@ pub(super) async fn call_tool( data: None, })?; - if cx.protocol_version().is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS) { + if cx.protocol_version().is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS) + && let Some(tool_schema) = backend.tool_schemas.get(&tool_name) + { let downstream_headers = cx .extensions .get::() .map(|parts| &parts.headers) .ok_or_else(|| ErrorData::internal_error("Routing problem... request headers not found", None))?; - let tool_schema = backend.tool_schemas.get(&tool_name).ok_or_else(|| { - ErrorData::header_mismatch(format!("Missing published schema for tool '{tool_name}'"), None) - })?; mcp_standard_headers::validate_tool_params(downstream_headers, request.arguments.as_ref(), tool_schema) .map_err(|message| ErrorData::header_mismatch(message, None))?; } diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 7be3d352..ed3f3654 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -435,7 +435,7 @@ async fn stateless_tool_call_with_mismatched_parameter_header_is_rejected() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stateless_tool_call_without_published_schema_fails_closed() { +async fn stateless_tool_call_without_published_schema_reaches_backend() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; let service = support::connect_modern_client( gateway.gateway_url(), @@ -447,8 +447,10 @@ async fn stateless_tool_call_without_published_schema_fails_closed() { let rmcp::service::ServiceError::McpError(error) = error else { panic!("expected backend MCP error, got {error:?}"); }; - assert_eq!(ErrorCode::HEADER_MISMATCH, error.code); - assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); + + assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); + let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned"); + assert_eq!("missing_schema_tool", backend_calls[0].tool_name); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] diff --git a/schemas/user_config.json b/schemas/user_config.json index 38869f27..d177ce9d 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -101,15 +101,15 @@ "additionalProperties": { "type": "object", "additionalProperties": true - } + }, + "default": {} } }, "required": [ "name", "url", "mcp_protocol_version", - "passthrough_headers", - "tool_schemas" + "passthrough_headers" ] }, "ProtocolVersion": { From 9e0c3a68645b2e0885fa76170b7b79129fb8b93f Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 1 Sep 2026 12:31:55 +0100 Subject: [PATCH 2/2] fix: enforce MCP parameter header requirements Signed-off-by: lucarlig --- _context/wiki/architecture.md | 4 + _context/wiki/config.md | 7 +- _context/wiki/security.md | 11 +- _context/wiki/testing.md | 6 +- .../src/mcp_standard_headers.rs | 535 ++++++++++++++++-- .../tests/gateway_plugins.rs | 26 +- 6 files changed, 540 insertions(+), 49 deletions(-) diff --git a/_context/wiki/architecture.md b/_context/wiki/architecture.md index d1a148a7..2a196b90 100644 --- a/_context/wiki/architecture.md +++ b/_context/wiki/architecture.md @@ -85,6 +85,10 @@ backend and original tool name. When `UserConfig` contains that tool's input schema, it validates recognized `Mcp-Param-*` headers against the request body; it does not call backend `tools/list`. Without a published schema, parameter headers are unrecognized and forwarded without local validation. +Published annotations are validated for MCP token, uniqueness, primitive type, +and properties-only reachability constraints. Nested annotations read the exact +argument path. Present non-null values require a matching header; absent or +null values require no header. Parameter headers are forwarded unchanged; request plugins run afterward, so a plugin that changes an annotated argument also owns any resulting upstream mismatch. diff --git a/_context/wiki/config.md b/_context/wiki/config.md index ec233574..4489abbb 100644 --- a/_context/wiki/config.md +++ b/_context/wiki/config.md @@ -139,7 +139,12 @@ BackendMCPGateway annotations without calling backend `tools/list`. The control plane may omit the field or individual unannotated tools. Without a published schema, parameter headers are forwarded as unrecognized intermediary headers and are not locally -validated. +validated. A published annotation must name a non-empty, case-insensitively +unique HTTP token on a `string`, `integer`, or `boolean` property reachable from +the schema root through `properties` keys only. Nested properties use their +exact property path. For a recognized annotation, a non-null argument requires +an equal header; an absent or null argument requires the header to be absent. +Integer values are limited to the IEEE 754 safe range. **Header apply order:** `passthrough_headers` → `add_headers` (override passthrough) → `remove_headers` (applied last). diff --git a/_context/wiki/security.md b/_context/wiki/security.md index a39db335..551eb11c 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -95,9 +95,14 @@ Backend header policy cannot add, remove, or replace MCP standard or parameter headers. For modern `tools/call`, the dataplane resolves the authenticated user, virtual host, backend, and original tool name before validating recognized `Mcp-Param-*` against the control-plane-published input schema. A -recognized header/body mismatch fails closed with JSON-RPC `-32020`. When no -schema is published, parameter headers are unrecognized and forwarded without -local validation; their absence does not block the tool call. +recognized missing, malformed, unexpected, conflicting repeated, or mismatched header fails closed +with JSON-RPC `-32020`. Schema annotations also fail closed unless their names +are non-empty, case-insensitively unique HTTP tokens, their properties have an +allowed primitive type, and their paths are statically reachable through +`properties` only. Nested values are checked at their exact path, and integers +must remain in the IEEE 754 safe range. When no schema is published, parameter +headers are unrecognized and forwarded without local validation; their absence +does not block the tool call. Validation does not call backend `tools/list`. Parameter values are forwarded unchanged, while RMCP regenerates method, routed-name, and protocol-version headers. If a plugin later changes an annotated argument, the original header diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 551a62b3..85177610 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -35,7 +35,11 @@ Protocol-sensitive tests and fixtures must cover MCP `2026-07-28` and `2025-11-2 These run in `cargo nextest run` with no Docker dependencies. Parameter-header integration tests verify that calls without a published tool -schema skip local `Mcp-Param-*` validation and still reach the backend. +schema skip local `Mcp-Param-*` validation and still reach the backend. Unit and +integration coverage also includes missing, malformed, unexpected, repeated, +and mismatched recognized headers; Base64 encoding; nested paths; numerically +equivalent integers; and invalid annotation names, types, duplicates, and +non-`properties` paths. ## MCP Conformance 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 31243dc5..ae807115 100644 --- a/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs +++ b/crates/contextforge-data-plane-lib/src/mcp_standard_headers.rs @@ -1,5 +1,7 @@ use base64::{Engine, prelude::BASE64_STANDARD}; -use http::{HeaderMap, HeaderName}; +use std::collections::HashSet; + +use http::{HeaderMap, HeaderName, HeaderValue}; 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, @@ -8,6 +10,23 @@ use serde_json::{Map, Value}; type JsonObject = Map; +const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; +const MIN_SAFE_INTEGER: i64 = -MAX_SAFE_INTEGER; + +#[derive(Clone, Copy)] +enum ParameterType { + Boolean, + Integer, + String, +} + +struct ParamHeaderAnnotation { + header_name: HeaderName, + header_name_display: String, + parameter_type: ParameterType, + property_path: Vec, +} + pub(crate) fn is_limited(name: &HeaderName) -> bool { is_exact(name, HEADER_MCP_METHOD) || is_exact(name, HEADER_MCP_NAME) @@ -39,25 +58,37 @@ pub(crate) fn validate_tool_params( 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); + for annotation in param_header_annotations(input_schema)? { + let header_values = headers.get_all(&annotation.header_name); + let mut header_values = header_values.iter(); + let header_value = header_values.next(); + let body_value = value_at_property_path(arguments, &annotation.property_path).filter(|value| !value.is_null()); + let property_path = annotation.property_path.join("."); match (header_value, body_value) { (None, None) => {}, (Some(_), None) => { - return Err(format!("unexpected {header_name} header for absent or null `{property}`")); + return Err(format!( + "unexpected {} header for absent or null `{property_path}`", + annotation.header_name_display + )); }, - (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}`")); + (None, Some(_)) => { + return Err(format!("missing {} header for `{property_path}`", annotation.header_name_display)); + }, + (Some(first), Some(body_value)) => { + let expected = parameter_value(body_value, annotation.parameter_type, &property_path)?; + for raw in std::iter::once(first).chain(header_values) { + let decoded = decode_header_value(raw).map_err(|reason| { + format!("{} header is malformed: {reason}", annotation.header_name_display) + })?; + if !parameter_values_match(&decoded, &expected) { + return Err(format!( + "{} header `{decoded}` does not match body value `{}`", + annotation.header_name_display, + expected.display() + )); + } } }, } @@ -65,36 +96,259 @@ pub(crate) fn validate_tool_params( Ok(()) } -fn param_header_annotations(input_schema: &JsonObject) -> Vec<(String, String)> { - input_schema - .get("properties") - .and_then(Value::as_object) - .into_iter() - .flatten() - .filter_map(|(property, schema)| { - schema - .get("x-mcp-header") - .and_then(Value::as_str) - .filter(|annotation| !annotation.is_empty()) - .map(|annotation| (property.clone(), annotation.to_owned())) - }) - .collect() +fn param_header_annotations(input_schema: &JsonObject) -> Result, String> { + let mut annotations = Vec::new(); + let mut seen_headers = HashSet::new(); + visit_schema(input_schema, "$", None, &mut seen_headers, &mut annotations)?; + Ok(annotations) } -fn primitive_to_string(value: &Value) -> Option { +fn visit_schema( + schema: &JsonObject, + schema_path: &str, + property_path: Option<&[String]>, + seen_headers: &mut HashSet, + annotations: &mut Vec, +) -> Result<(), String> { + if let Some(raw_header) = schema.get("x-mcp-header") { + let Some(property_path) = property_path else { + return Err(format!("schema `{schema_path}`: x-mcp-header is not on a statically reachable property")); + }; + let property_path_display = property_path.join("."); + let Value::String(header) = raw_header else { + return Err(format!("property `{property_path_display}`: x-mcp-header must be a string")); + }; + if header.is_empty() { + return Err(format!("property `{property_path_display}`: x-mcp-header must not be empty")); + } + if !header.bytes().all(is_tchar) { + return Err(format!( + "property `{property_path_display}`: x-mcp-header `{header}` is not a valid HTTP token" + )); + } + if !seen_headers.insert(header.to_ascii_lowercase()) { + return Err(format!( + "property `{property_path_display}`: duplicate x-mcp-header `{header}` (case-insensitive)" + )); + } + let parameter_type = match schema.get("type").and_then(Value::as_str) { + Some("boolean") => ParameterType::Boolean, + Some("integer") => ParameterType::Integer, + Some("string") => ParameterType::String, + other => { + return Err(format!( + "property `{property_path_display}`: x-mcp-header requires type string, integer, or boolean; got {other:?}" + )); + }, + }; + let header_name_display = format!("{HEADER_MCP_PARAM_PREFIX}{header}"); + let header_name = HeaderName::from_bytes(header_name_display.as_bytes()) + .map_err(|_| format!("property `{property_path_display}`: invalid header name `{header_name_display}`"))?; + annotations.push(ParamHeaderAnnotation { + header_name, + header_name_display, + parameter_type, + property_path: property_path.to_vec(), + }); + } + + for (keyword, value) in schema { + if keyword == "properties" { + let Some(properties) = value.as_object() else { + reject_unreachable_annotations(value, &format!("{schema_path}.properties"))?; + continue; + }; + for (property, property_schema) in properties { + let mut nested_property_path = property_path.map_or_else(Vec::new, <[String]>::to_vec); + nested_property_path.push(property.clone()); + let nested_schema_path = format!("{schema_path}.properties.{property}"); + if let Some(property_schema) = property_schema.as_object() { + visit_schema( + property_schema, + &nested_schema_path, + Some(&nested_property_path), + seen_headers, + annotations, + )?; + } else { + reject_unreachable_annotations(property_schema, &nested_schema_path)?; + } + } + } else if keyword != "x-mcp-header" { + reject_unreachable_annotations(value, &format!("{schema_path}.{keyword}"))?; + } + } + Ok(()) +} + +fn reject_unreachable_annotations(value: &Value, schema_path: &str) -> Result<(), String> { match value { - Value::String(value) => Some(value.clone()), - Value::Bool(value) => Some(value.to_string()), - Value::Number(value) => Some(value.to_string()), - _ => None, + Value::Object(object) => { + if object.contains_key("x-mcp-header") { + return Err(format!("schema `{schema_path}`: x-mcp-header is not on a statically reachable property")); + } + for (key, nested) in object { + reject_unreachable_annotations(nested, &format!("{schema_path}.{key}"))?; + } + }, + Value::Array(array) => { + for (index, nested) in array.iter().enumerate() { + reject_unreachable_annotations(nested, &format!("{schema_path}[{index}]"))?; + } + }, + _ => {}, } + Ok(()) } -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(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~' + ) +} + +fn value_at_property_path<'a>(arguments: Option<&'a JsonObject>, property_path: &[String]) -> Option<&'a Value> { + let (first, rest) = property_path.split_first()?; + let mut value = arguments?.get(first)?; + for property in rest { + value = value.as_object()?.get(property)?; } + Some(value) +} + +enum ParameterValue<'a> { + Boolean(bool), + Integer(i64), + String(&'a str), +} + +impl ParameterValue<'_> { + fn display(&self) -> String { + match self { + Self::Boolean(value) => value.to_string(), + Self::Integer(value) => value.to_string(), + Self::String(value) => (*value).to_owned(), + } + } +} + +fn parameter_value<'a>( + value: &'a Value, + parameter_type: ParameterType, + property_path: &str, +) -> Result, String> { + match (parameter_type, value) { + (ParameterType::Boolean, Value::Bool(value)) => Ok(ParameterValue::Boolean(*value)), + (ParameterType::Integer, Value::Number(value)) => { + let integer = value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .or_else(|| parse_integer_header_value(&value.to_string())) + .filter(|value| (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(value)) + .ok_or_else(|| { + format!( + "body value for `{property_path}` must be an integer between {MIN_SAFE_INTEGER} and {MAX_SAFE_INTEGER}" + ) + })?; + Ok(ParameterValue::Integer(integer)) + }, + (ParameterType::String, Value::String(value)) => Ok(ParameterValue::String(value)), + (ParameterType::Boolean, _) => Err(format!("body value for `{property_path}` must be a boolean")), + (ParameterType::Integer, _) => Err(format!( + "body value for `{property_path}` must be an integer between {MIN_SAFE_INTEGER} and {MAX_SAFE_INTEGER}" + )), + (ParameterType::String, _) => Err(format!("body value for `{property_path}` must be a string")), + } +} + +fn parameter_values_match(header_value: &str, body_value: &ParameterValue<'_>) -> bool { + match body_value { + ParameterValue::Boolean(value) => header_value == value.to_string(), + ParameterValue::Integer(value) => { + parse_integer_header_value(header_value).is_some_and(|header| header == *value) + }, + ParameterValue::String(value) => header_value == *value, + } +} + +fn parse_integer_header_value(value: &str) -> Option { + let (negative, value) = match value.as_bytes().first() { + Some(b'-') => (true, value.get(1..)?), + Some(b'+') => (false, value.get(1..)?), + _ => (false, value), + }; + let (mantissa, exponent) = match value.split_once(['e', 'E']) { + Some((mantissa, exponent)) => (mantissa, exponent.parse::().ok()?), + None => (value, 0), + }; + let (whole, fraction) = match mantissa.split_once('.') { + Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction), + Some(_) => return None, + None => (mantissa, ""), + }; + if whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + + let mut digits = String::with_capacity(whole.len().checked_add(fraction.len())?); + digits.push_str(whole); + digits.push_str(fraction); + let scale = i64::try_from(fraction.len()).ok()?.checked_sub(exponent)?; + let (integer_digits, trailing_zeroes) = if scale > 0 { + let scale = usize::try_from(scale).ok()?; + if scale > digits.len() { + if digits.bytes().all(|byte| byte == b'0') { + return Some(0); + } + return None; + } + let integer_end = digits.len() - scale; + if !digits.as_bytes()[integer_end..].iter().all(|byte| *byte == b'0') { + return None; + } + (&digits[..integer_end], 0) + } else { + (&*digits, usize::try_from(scale.checked_neg()?).ok()?) + }; + let integer_digits = integer_digits.trim_start_matches('0'); + if integer_digits.is_empty() { + return Some(0); + } + if integer_digits.len().checked_add(trailing_zeroes)? > MAX_SAFE_INTEGER.to_string().len() { + return None; + } + let magnitude = + integer_digits.parse::().ok()?.checked_mul(10_u64.checked_pow(u32::try_from(trailing_zeroes).ok()?)?)?; + if magnitude > MAX_SAFE_INTEGER.unsigned_abs() { + return None; + } + let magnitude = i64::try_from(magnitude).ok()?; + if negative { magnitude.checked_neg() } else { Some(magnitude) } +} + +fn decode_header_value(value: &HeaderValue) -> Result { + let raw = value.as_bytes(); + let raw = std::str::from_utf8(raw).map_err(|_| "value is not ASCII or UTF-8")?; + if let Some(inner) = + raw.strip_prefix(BASE64_HEADER_PREFIX).and_then(|inner| inner.strip_suffix(BASE64_HEADER_SUFFIX)) + { + let decoded = BASE64_STANDARD.decode(inner).map_err(|_| "sentinel contains invalid Base64")?; + return String::from_utf8(decoded).map_err(|_| "sentinel does not contain UTF-8"); + } + + let bytes = raw.as_bytes(); + if matches!(bytes.first(), Some(b' ' | b'\t')) || matches!(bytes.last(), Some(b' ' | b'\t')) { + return Err("plain value has leading or trailing whitespace"); + } + if bytes.iter().any(|byte| !matches!(byte, b'\t' | 0x20..=0x7e)) { + return Err("plain value contains characters that require Base64 encoding"); + } + Ok(raw.to_owned()) } #[cfg(test)] @@ -118,10 +372,17 @@ mod tests { .clone() } + fn object(value: &Value) -> JsonObject { + value.as_object().expect("JSON object").clone() + } + + fn as_arguments(value: &Value) -> &JsonObject { + value.as_object().expect("object arguments") + } + #[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([ @@ -130,19 +391,207 @@ mod tests { (HeaderName::from_static("mcp-param-dry-run"), HeaderValue::from_static("false")), ]); - validate_tool_params(&headers, Some(arguments), &schema()).expect("headers match arguments"); + validate_tool_params(&headers, Some(as_arguments(&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"); + validate_tool_params(&HeaderMap::new(), Some(as_arguments(&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()); + assert!(validate_tool_params(&headers, Some(as_arguments(&arguments)), &schema()).is_err()); + } + + #[test] + fn missing_header_for_present_parameter_is_rejected() { + let arguments = json!({ "region": "eu-west" }); + + let error = validate_tool_params(&HeaderMap::new(), Some(as_arguments(&arguments)), &schema()) + .expect_err("present annotated parameter requires a header"); + + assert_eq!("missing Mcp-Param-Region header for `region`", error); + } + + #[test] + fn nested_property_header_uses_the_exact_property_path() { + let schema = object(&json!({ + "type": "object", + "properties": { + "request": { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + } + })); + let arguments = json!({ "region": "wrong", "request": { "region": "eu-west" } }); + let headers = + HeaderMap::from_iter([(HeaderName::from_static("mcp-param-region"), HeaderValue::from_static("eu-west"))]); + + validate_tool_params(&headers, Some(as_arguments(&arguments)), &schema) + .expect("nested header matches its exact property path"); + } + + #[test] + fn annotations_outside_properties_only_paths_are_rejected() { + let invalid_schemas = [ + json!({ "type": "object", "x-mcp-header": "Root" }), + json!({ + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "string", "x-mcp-header": "Item" } + } + } + }), + json!({ + "type": "object", + "properties": { + "value": { + "oneOf": [{ "type": "string", "x-mcp-header": "Choice" }] + } + } + }), + json!({ + "type": "object", + "$defs": { "value": { "type": "string", "x-mcp-header": "Reference" } }, + "properties": { "value": { "$ref": "#/$defs/value" } } + }), + json!({ + "type": "object", + "properties": { + "value": { + "if": { "type": "string", "x-mcp-header": "Conditional" } + } + } + }), + ]; + + for invalid_schema in invalid_schemas { + let error = validate_tool_params(&HeaderMap::new(), None, &object(&invalid_schema)) + .expect_err("unreachable annotation invalidates the schema"); + assert!(error.contains("not on a statically reachable property"), "unexpected error: {error}"); + } + } + + #[test] + fn annotation_names_and_types_must_meet_mcp_constraints() { + let invalid_schemas = [ + json!({ "type": "object", "properties": { "value": { + "type": "string", "x-mcp-header": "" + } } }), + json!({ "type": "object", "properties": { "value": { + "type": "string", "x-mcp-header": "not valid" + } } }), + json!({ "type": "object", "properties": { "value": { + "type": "string", "x-mcp-header": 7 + } } }), + json!({ "type": "object", "properties": { "value": { + "type": "number", "x-mcp-header": "Value" + } } }), + json!({ "type": "object", "properties": { "value": { + "x-mcp-header": "Value" + } } }), + ]; + + for invalid_schema in invalid_schemas { + validate_tool_params(&HeaderMap::new(), None, &object(&invalid_schema)) + .expect_err("invalid annotation is rejected"); + } + } + + #[test] + fn annotation_names_are_unique_case_insensitively_across_nested_properties() { + let schema = object(&json!({ + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "request": { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "region" } + } + } + } + })); + + let error = validate_tool_params(&HeaderMap::new(), None, &schema) + .expect_err("case-insensitive duplicate annotation is rejected"); + + assert!(error.contains("duplicate x-mcp-header `region`")); + } + + #[test] + fn integer_headers_are_compared_numerically_within_the_safe_range() { + assert_eq!(Some(42), parse_integer_header_value("4.2e1")); + assert_eq!(Some(-7), parse_integer_header_value("-7.00")); + assert_eq!(None, parse_integer_header_value("42.1")); + assert_eq!(None, parse_integer_header_value("9007199254740991.1")); + + let arguments = json!({ "count": MAX_SAFE_INTEGER }); + let headers = HeaderMap::from_iter([( + HeaderName::from_static("mcp-param-count"), + HeaderValue::from_static("9007199254740991.0"), + )]); + + validate_tool_params(&headers, Some(as_arguments(&arguments)), &schema()) + .expect("numerically equivalent safe integers match"); + + let arguments = json!({ "count": 42.0 }); + let headers = + HeaderMap::from_iter([(HeaderName::from_static("mcp-param-count"), HeaderValue::from_static("4.2e1"))]); + validate_tool_params(&headers, Some(as_arguments(&arguments)), &schema()) + .expect("JSON numbers with an integral value satisfy an integer schema"); + + let arguments = json!({ "count": 9_007_199_254_740_992_i64 }); + let headers = HeaderMap::from_iter([( + HeaderName::from_static("mcp-param-count"), + HeaderValue::from_static("9007199254740992"), + )]); + let error = validate_tool_params(&headers, Some(as_arguments(&arguments)), &schema()) + .expect_err("integer outside the safe range is rejected"); + assert!(error.contains("must be an integer between")); + } + + #[test] + fn malformed_recognized_header_values_are_rejected() { + let arguments = json!({ "region": "hello" }); + let invalid_base64 = HeaderMap::from_iter([( + HeaderName::from_static("mcp-param-region"), + HeaderValue::from_static("=?base64?%%%?="), + )]); + let leading_whitespace = + HeaderMap::from_iter([(HeaderName::from_static("mcp-param-region"), HeaderValue::from_static(" hello"))]); + + validate_tool_params(&invalid_base64, Some(as_arguments(&arguments)), &schema()) + .expect_err("invalid Base64 is rejected"); + validate_tool_params(&leading_whitespace, Some(as_arguments(&arguments)), &schema()) + .expect_err("unsafe plain value is rejected"); + } + + #[test] + fn every_repeated_recognized_header_value_must_match() { + let arguments = json!({ "region": "eu-west" }); + let mut headers = HeaderMap::new(); + headers.append(HeaderName::from_static("mcp-param-region"), HeaderValue::from_static("eu-west")); + headers.append(HeaderName::from_static("mcp-param-region"), HeaderValue::from_static("us-east")); + + validate_tool_params(&headers, Some(as_arguments(&arguments)), &schema()) + .expect_err("a conflicting repeated header is rejected"); + } + + #[test] + fn unknown_parameter_headers_are_ignored_without_a_published_annotation() { + let headers = + HeaderMap::from_iter([(HeaderName::from_static("mcp-param-region"), HeaderValue::from_static("anything"))]); + + validate_tool_params(&headers, None, &JsonObject::new()).expect("unknown header is ignored"); } } diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index ed3f3654..cde1b098 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -434,12 +434,32 @@ async fn stateless_tool_call_with_mismatched_parameter_header_is_rejected() { assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_tool_call_without_required_parameter_headers_is_rejected() { + let gateway = + start_gateway_with_parameter_headers(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let service = support::connect_modern_client( + gateway.gateway_url(), + support::create_client(TEST_USER_ID), + support::modern_client_info(), + ) + .await; + + let error = service.call_tool(sum_request("sum", 1, 2)).await.expect_err("missing annotated headers are rejected"); + let rmcp::service::ServiceError::McpError(error) = error else { + panic!("expected backend MCP error, got {error:?}"); + }; + + assert_eq!(ErrorCode::HEADER_MISMATCH, error.code); + assert!(gateway.backend_state.calls.lock().expect("backend calls lock poisoned").is_empty()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_without_published_schema_reaches_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), + client_with_parameter_headers("1", "2"), support::modern_client_info(), ) .await; @@ -451,6 +471,10 @@ async fn stateless_tool_call_without_published_schema_reaches_backend() { assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); let backend_calls = gateway.backend_state.calls.lock().expect("backend calls lock poisoned"); assert_eq!("missing_schema_tool", backend_calls[0].tool_name); + drop(backend_calls); + 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)]