Skip to content

Commit b364fc4

Browse files
authored
fix: enforce MCP parameter header requirements (#126)
* fix: allow calls without published tool schemas Signed-off-by: lucarlig <luca.carlig@ibm.com> * fix: enforce MCP parameter header requirements Signed-off-by: lucarlig <luca.carlig@ibm.com> --------- Signed-off-by: lucarlig <luca.carlig@ibm.com>
1 parent 8e18980 commit b364fc4

10 files changed

Lines changed: 582 additions & 62 deletions

File tree

_context/wiki/architecture.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,16 @@ flowchart TD
8181

8282
RMCP enforces its configured request-body cap and validates modern standard
8383
headers before dispatch. The `tools/call` handler then resolves the request's
84-
backend and original tool name and validates `Mcp-Param-*` from the request
85-
context against the schema published in `UserConfig`; it does not call backend
86-
`tools/list`.
87-
Validated headers are forwarded unchanged; request plugins run afterward, so a
88-
plugin that changes an annotated argument also owns the resulting upstream
84+
backend and original tool name. When `UserConfig` contains that tool's input
85+
schema, it validates recognized `Mcp-Param-*` headers against the request body;
86+
it does not call backend `tools/list`. Without a published schema, parameter
87+
headers are unrecognized and forwarded without local validation.
88+
Published annotations are validated for MCP token, uniqueness, primitive type,
89+
and properties-only reachability constraints. Nested annotations read the exact
90+
argument path. Present non-null values require a matching header; absent or
91+
null values require no header.
92+
Parameter headers are forwarded unchanged; request plugins run afterward, so a
93+
plugin that changes an annotated argument also owns any resulting upstream
8994
mismatch.
9095

9196
Order is invariant: auth/config before backend selection; request plugins before upstream; response plugins before returning.

_context/wiki/config.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,23 @@ BackendMCPGateway
129129
add_headers: HashMap<String, String> ← injected after passthrough
130130
remove_headers: Vec<String> ← stripped after add
131131
allowed_tool_names: Vec<String> ← model exists, NOT currently enforced
132-
tool_schemas: HashMap<String, JsonObject> ← required; upstream name → input schema
132+
tool_schemas: HashMap<String, JsonObject> ← optional, defaults to {}; upstream name → input schema
133133
tool_name_aliases: HashMap<String, String> ← downstream_alias → upstream_original
134134
allowed_resource_names: Vec<String> ← model exists, NOT currently enforced
135135
allowed_prompt_names: Vec<String> ← model exists, NOT currently enforced
136136
```
137137

138+
`tool_schemas` lets the dataplane recognize and validate `x-mcp-header`
139+
annotations without calling backend `tools/list`. The control plane may omit the
140+
field or individual unannotated tools. Without a published schema, parameter
141+
headers are forwarded as unrecognized intermediary headers and are not locally
142+
validated. A published annotation must name a non-empty, case-insensitively
143+
unique HTTP token on a `string`, `integer`, or `boolean` property reachable from
144+
the schema root through `properties` keys only. Nested properties use their
145+
exact property path. For a recognized annotation, a non-null argument requires
146+
an equal header; an absent or null argument requires the header to be absent.
147+
Integer values are limited to the IEEE 754 safe range.
148+
138149
**Header apply order:** `passthrough_headers``add_headers` (override passthrough) → `remove_headers` (applied last).
139150

140151
**`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).

_context/wiki/security.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,16 @@ bounded by the HTTP transport.
9494
Backend header policy cannot add, remove, or replace MCP standard or parameter
9595
headers. For modern `tools/call`, the dataplane resolves the authenticated
9696
user, virtual host, backend, and original tool name before validating
97-
`Mcp-Param-*` against the control-plane-published input schema. A missing schema
98-
or header/body mismatch fails closed with JSON-RPC `-32020`.
99-
Validation does not call backend `tools/list`. Validated values are forwarded
97+
recognized `Mcp-Param-*` against the control-plane-published input schema. A
98+
recognized missing, malformed, unexpected, conflicting repeated, or mismatched header fails closed
99+
with JSON-RPC `-32020`. Schema annotations also fail closed unless their names
100+
are non-empty, case-insensitively unique HTTP tokens, their properties have an
101+
allowed primitive type, and their paths are statically reachable through
102+
`properties` only. Nested values are checked at their exact path, and integers
103+
must remain in the IEEE 754 safe range. When no schema is published, parameter
104+
headers are unrecognized and forwarded without local validation; their absence
105+
does not block the tool call.
106+
Validation does not call backend `tools/list`. Parameter values are forwarded
100107
unchanged, while RMCP regenerates method, routed-name, and protocol-version
101108
headers. If a plugin later changes an annotated argument, the original header
102109
remains and the upstream server may reject the mismatch.

_context/wiki/testing.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ Protocol-sensitive tests and fixtures must cover MCP `2026-07-28` and `2025-11-2
3434

3535
These run in `cargo nextest run` with no Docker dependencies.
3636

37+
Parameter-header integration tests verify that calls without a published tool
38+
schema skip local `Mcp-Param-*` validation and still reach the backend. Unit and
39+
integration coverage also includes missing, malformed, unexpected, repeated,
40+
and mismatched recognized headers; Base64 encoding; nested paths; numerically
41+
equivalent integers; and invalid annotation names, types, duplicates, and
42+
non-`properties` paths.
43+
3744
## MCP Conformance
3845

3946
[`cf-integration`](https://crates.io/crates/cf-integration)

crates/contextforge-data-plane-apis/src/user_store.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub struct BackendMCPGateway {
3636
#[serde(default)]
3737
pub completion: HashMap<String, String>,
3838
/// Input schemas keyed by the original upstream tool name.
39+
#[serde(default)]
3940
pub tool_schemas: HashMap<String, serde_json::Map<String, serde_json::Value>>,
4041
}
4142

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use contextforge_data_plane_apis::user_store::BackendMCPGateway;
2+
use serde_json::json;
3+
4+
#[test]
5+
fn backend_config_without_tool_schemas_defaults_to_empty_map() {
6+
let config: BackendMCPGateway = serde_json::from_value(json!({
7+
"name": "backend",
8+
"url": "http://localhost:8000/mcp",
9+
"mcp_protocol_version": "2026_07_28",
10+
"passthrough_headers": []
11+
}))
12+
.expect("backend config without tool schemas should deserialize");
13+
14+
assert!(config.tool_schemas.is_empty());
15+
}

crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,14 @@ pub(super) async fn call_tool(
4040
data: None,
4141
})?;
4242

43-
if cx.protocol_version().is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS) {
43+
if cx.protocol_version().is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS)
44+
&& let Some(tool_schema) = backend.tool_schemas.get(&tool_name)
45+
{
4446
let downstream_headers = cx
4547
.extensions
4648
.get::<Parts>()
4749
.map(|parts| &parts.headers)
4850
.ok_or_else(|| ErrorData::internal_error("Routing problem... request headers not found", None))?;
49-
let tool_schema = backend.tool_schemas.get(&tool_name).ok_or_else(|| {
50-
ErrorData::header_mismatch(format!("Missing published schema for tool '{tool_name}'"), None)
51-
})?;
5251
mcp_standard_headers::validate_tool_params(downstream_headers, request.arguments.as_ref(), tool_schema)
5352
.map_err(|message| ErrorData::header_mismatch(message, None))?;
5453
}

0 commit comments

Comments
 (0)