Skip to content

Commit 28524e6

Browse files
committed
feat: SEP-2243 Mcp-Param-* mirroring — client emission + server validation
Protocol crate (turul-mcp-protocol-2026-07-28/headers.rs): pure SEP-2243 logic shared by both sides — scan_x_mcp_headers() (annotation discovery at any nesting depth; constraints: non-empty, tchar field-name syntax, case-insensitive uniqueness, string/integer/boolean only), encode_param_value()/decode_param_value() (string/integer/boolean conversion, JS safe-integer range, =?base64?...?= sentinel incl. the self-matching-sentinel re-encode rule). Unit tests reproduce all five spec encoding examples. base64 workspace dep added. Server: the tools/call handler validates every annotated parameter's mirrored header against the body argument (sentinel-decoded; integers compared numerically). Value-without-header, header-without-value, or a decoded mismatch -> -32001 HeaderMismatch at HTTP 400. The transport surfaces request headers to handlers via the rpc session metadata (2026 path); the inline 2026 JSON path maps -32001 to 400 alongside -32003. Invalid annotations on the server's own tool log a warning and skip enforcement (a conforming client never lists such a tool). Client: tools/list excludes tool definitions with invalid x-mcp-header values (spec MUST; warning logged) and captures per-tool bindings BEFORE the 2025-vocabulary remap (the typed 2025 JsonSchema cannot carry the annotation). tools/call mirrors annotated arguments into Mcp-Param-{name} headers via the new Transport::send_request_with_extra_headers (default delegates to send_request — non-HTTP transports MAY ignore the annotations per spec). Without a cached schema the call goes out headerless per the spec note; the schema-stale auto-retry is left to the application. Tests: mcp_param_2026.rs — 4 real-HTTP server cases (match, Base64-sentinel match, omitted-header rejection, mismatch rejection). Revert-and-fail: with the validation block compiled out, both negative cases fail and both positive controls pass — recorded. Client closed loop in e2e_2026_real_server.rs: the validating server rejects missing mirrors, so the green client calls (plain ASCII + Base64 sentinel) prove emission. CI: mcp_param_2026 gate in ci.yml + ci-gates.sh.
1 parent 0ab53b5 commit 28524e6

13 files changed

Lines changed: 862 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ jobs:
5151
run: cargo test -p turul-mcp-server --no-default-features --features http,sse,protocol-2026-07-28 --test error_mapping_2026
5252
- name: 2026 MRTR (input_required round trip, -32003 capability gate)
5353
run: cargo test -p turul-mcp-server --no-default-features --features http,sse,protocol-2026-07-28 --test mrtr_2026
54+
- name: 2026 Mcp-Param-* mirroring (x-mcp-header validation)
55+
run: cargo test -p turul-mcp-server --no-default-features --features http,sse,protocol-2026-07-28 --test mcp_param_2026
5456
- name: protocol-2026 compliance + upstream wire fixtures
5557
run: cargo test -p turul-mcp-protocol-2026-07-28 --features compliance
5658
- name: Bilingual client (not in default-members)

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4444

4545
### Added (2026-06-10)
4646

47+
- **SEP-2243 `Mcp-Param-*` custom-header mirroring (client emission + server validation).** Completes the deferred remainder of the request-metadata headers work. Protocol crate: pure SEP-2243 logic in `headers.rs` — `scan_x_mcp_headers()` (annotation discovery at any nesting depth with the full constraint set: non-empty, `tchar` syntax, case-insensitive uniqueness, string/integer/boolean only), `encode_param_value()` / `decode_param_value()` (string/integer/boolean conversion, JS safe-integer range, `=?base64?…?=` sentinel incl. the self-matching-sentinel re-encode rule; unit tests reproduce all five spec encoding examples). Server: the tools/call handler validates every annotated parameter's mirrored header against the body argument (sentinel-decoded; integers compared numerically) — value-without-header, header-without-value, or decoded mismatch → `-32001 HeaderMismatch` at HTTP 400 (the transport surfaces request headers to handlers via the rpc session metadata; the inline 2026 JSON path now maps `-32001` to 400 alongside `-32003`). Client: `tools/list` rejects tool definitions with invalid `x-mcp-header` values (excluded + warning, per spec) and captures per-tool bindings BEFORE the 2025-vocabulary remap (which cannot carry the annotation); `tools/call` mirrors annotated arguments into `Mcp-Param-{name}` headers via a new `Transport::send_request_with_extra_headers` (default delegates to `send_request` — non-HTTP transports MAY ignore the annotations). Tests: `mcp_param_2026.rs` (4 real-HTTP server cases; revert-and-fail recorded — both negative cases fail with validation disabled) and a closed-loop client e2e (the validating server rejects missing mirrors, so the green client call proves emission; covers plain ASCII and Base64-sentinel values). Not implemented (acceptable per spec): the client's optional schema-stale auto-retry (`tools/list` + retry on rejection is left to the application).
4748
- **Schema pin re-vendored (content sha256 `1bf94a60…`, fixture pin `1304c8fe`).** One substantive upstream change: `ElicitationCompleteNotificationParams` extracted into a named interface extending `NotificationParams` (surface 159 → 160). The Rust binding already modeled the optional `_meta`, so the previously recorded deviation resolved upstream. ADR-027 revision log + COMPLIANCE.md/schema README hash records updated.
4849
- **Docs/ADR reconciliation to implemented behavior**: ADR-029 §CI surface rewritten to the as-built lanes (the prescribed `cargo test --workspace` matrices never compiled — spec-pinned workspace members trip the ADR's own mutex; per-crate matrix is the operative shape); ADR-025 revision entry recording the shim's branch reality (still consumed by four non-frozen crates; manifest carries an unpublishable 0.4.0 from the version sweep; the framework-wide cut remains 0.4.0 release-prep work); `docs/plans/2026-07-28-schema-coverage-matrix.md` marked STALE/superseded (authoritative coverage = COMPLIANCE.md + the compliance harness); COMPLIANCE.md elicitation-union and extension-crate-name notes refreshed.
4950
- **MRTR (SEP-2322): `InputRequiredResult` production (server) and consumption (client).** Server: a tool returning the new `McpError::InputRequired { input_requests, request_state }` sentinel (2026 protocol crate; NOT a wire error — the only return channel available to tool impls) is converted by the `tools/call` handler into a successful `InputRequiredResult` (`resultType: "input_required"`), after enforcing that every input request targets a capability the client declared in that request's `_meta` `clientCapabilities` — undeclared → `-32003 MissingRequiredClientCapability` at HTTP 400 (the 2026 JSON-framed response path now dispatches inline so the HTTP status can reflect the JSON-RPC outcome; SSE-framed responses inherently stay 200). On the retry leg, `CallToolRequestParams.inputResponses`/`requestState` are surfaced to tools via `SessionContext::input_responses()` / `mrtr_request_state()` (requestState documented as attacker-controlled). Client: `call_tool` surfaces `resultType: "input_required"` as the new `McpClientError::InputRequired` carrying `inputRequests`/`requestState`; the application gathers inputs and retries via `call_tool_with_input_responses(name, args, responses, request_state)` (fresh JSON-RPC id; 2026 connections only). New `ClientConfig.declared_capabilities` (elicitation/sampling/roots, all off by default) feeds both the 2026 per-request `_meta` and the 2025 `initialize` capabilities — previously hardcoded empty. Tests: `mrtr_2026.rs` (real-HTTP two-leg round trip + `-32003`/400 capability rejection; the suite does not compile against the pre-slice tree — the sentinel variant did not exist) and a full client-driven MRTR e2e in `e2e_2026_real_server.rs`. Limitations (tracked): MRTR production is wired for `tools/call` only (`resources/read`/`prompts/get` handlers have no input hooks yet); the framework does not generate or verify `requestState` integrity (HMAC is the tool author's concern).

crates/turul-http-mcp-server/src/streamable_http.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2093,9 +2093,21 @@ impl StreamableHttpHandler {
20932093
);
20942094
let broadcaster_any = Arc::new(broadcaster) as Arc<dyn std::any::Any + Send + Sync>;
20952095

2096+
// 2026: surface the request headers to handlers — Mcp-Param-* validation
2097+
// happens at the tools/call handler, the only layer that knows the
2098+
// tool's inputSchema annotations.
2099+
#[cfg(feature = "protocol-2026-07-28")]
2100+
let handler_metadata: std::collections::HashMap<String, serde_json::Value> = context
2101+
.headers
2102+
.iter()
2103+
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
2104+
.collect();
2105+
#[cfg(feature = "protocol-2025-11-25")]
2106+
let handler_metadata = std::collections::HashMap::new();
2107+
20962108
let session_context = SessionContext {
20972109
session_id: session_id.clone(),
2098-
metadata: std::collections::HashMap::new(),
2110+
metadata: handler_metadata,
20992111
broadcaster: Some(broadcaster_any),
21002112
timestamp: chrono::Utc::now().timestamp_millis() as u64,
21012113
extensions: std::collections::HashMap::new(),
@@ -2122,8 +2134,10 @@ impl StreamableHttpHandler {
21222134
)
21232135
.await;
21242136
let status = match &response {
2137+
// -32003 MissingRequiredClientCapability and -32001
2138+
// HeaderMismatch both mandate HTTP 400.
21252139
turul_mcp_json_rpc_server::JsonRpcResponse::Error(err)
2126-
if err.error.code == -32003 =>
2140+
if matches!(err.error.code, -32001 | -32003) =>
21272141
{
21282142
StatusCode::BAD_REQUEST
21292143
}

crates/turul-mcp-client/src/client.rs

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ use turul_mcp_protocol_2025_11_25::{
3131
pub type NotificationCallback = Arc<dyn Fn(&str, Option<&Value>) + Send + Sync>;
3232

3333
/// Main MCP client
34+
/// Per-tool SEP-2243 bindings: tool name → `(header name, argument JSON
35+
/// pointer, declared type)` per `x-mcp-header` annotation.
36+
type ParamBindingMap = std::collections::HashMap<String, Vec<(String, String, String)>>;
37+
3438
pub struct McpClient {
3539
/// Transport layer — `Arc<BoxedTransport>` (no Mutex) so concurrent
3640
/// `call_tool`/`list_tools`/etc. go through the transport in parallel.
@@ -48,6 +52,11 @@ pub struct McpClient {
4852
response_consumer_handle: Arc<parking_lot::Mutex<Option<tokio::task::JoinHandle<()>>>>,
4953
/// Cached tool list (invalidated by `notifications/tools/list_changed`)
5054
cached_tools: Arc<RwLock<Option<Vec<Tool>>>>,
55+
/// SEP-2243 `Mcp-Param-*` bindings per tool, from the raw 2026 `tools/list`
56+
/// result. The public `Tool` vocabulary (2025 types) cannot carry the
57+
/// `x-mcp-header` annotation, so the bindings are captured before the
58+
/// type remap.
59+
cached_param_bindings: Arc<RwLock<ParamBindingMap>>,
5160
/// Cached resource list (invalidated by `notifications/resources/list_changed`)
5261
cached_resources: Arc<RwLock<Option<Vec<Resource>>>>,
5362
/// Cached prompt list (invalidated by `notifications/prompts/list_changed`)
@@ -128,6 +137,7 @@ impl McpClient {
128137
request_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
129138
response_consumer_handle: Arc::new(parking_lot::Mutex::new(None)),
130139
cached_tools: Arc::new(RwLock::new(None)),
140+
cached_param_bindings: Arc::new(RwLock::new(std::collections::HashMap::new())),
131141
cached_resources: Arc::new(RwLock::new(None)),
132142
cached_prompts: Arc::new(RwLock::new(None)),
133143
notification_callback,
@@ -815,9 +825,15 @@ impl McpClient {
815825
);
816826

817827
let response = self.send_request_internal(request).await?;
818-
let tools = crate::protocol::v2026_07_28::parse_list_tools(
819-
&response.get("result").cloned().unwrap_or(Value::Null),
820-
)?;
828+
let raw_result = response.get("result").cloned().unwrap_or(Value::Null);
829+
let tools = crate::protocol::v2026_07_28::parse_list_tools(&raw_result)?;
830+
831+
// Capture the SEP-2243 Mcp-Param bindings before the vocabulary remap
832+
// drops the x-mcp-header annotations.
833+
{
834+
let bindings = crate::protocol::v2026_07_28::collect_param_bindings(&raw_result);
835+
*self.cached_param_bindings.write().await = bindings;
836+
}
821837

822838
debug!(count = tools.len(), "Retrieved tools");
823839
Ok(tools)
@@ -887,6 +903,64 @@ impl McpClient {
887903
parse(&response.get("result").cloned().unwrap_or(Value::Null))
888904
}
889905

906+
/// SEP-2243: the `Mcp-Param-*` headers for a `tools/call`, from the cached
907+
/// bindings. Empty when the tool schema has not been fetched yet — per the
908+
/// spec the call then goes out without custom headers, and a rejecting
909+
/// server prompts the application to `list_tools()` and retry.
910+
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
911+
async fn mcp_param_headers_for(
912+
&self,
913+
tool_name: &str,
914+
arguments: &Value,
915+
) -> Vec<(String, String)> {
916+
let cache = self.cached_param_bindings.read().await;
917+
match cache.get(tool_name) {
918+
Some(bindings) => {
919+
crate::protocol::v2026_07_28::encode_param_headers(bindings, arguments)
920+
}
921+
None => Vec::new(),
922+
}
923+
}
924+
925+
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
926+
async fn send_2026_07_28_with_extra_headers<T>(
927+
&self,
928+
method: &str,
929+
extra: Value,
930+
extra_headers: &[(String, String)],
931+
parse: impl Fn(&Value) -> McpClientResult<T>,
932+
) -> McpClientResult<T> {
933+
let meta = crate::protocol::v2026_07_28::request_meta(
934+
&self.config.client_info.name,
935+
&self.config.client_info.version,
936+
&self.config.declared_capabilities,
937+
);
938+
let request = self.build_request(
939+
method,
940+
crate::protocol::v2026_07_28::params_with_meta(&meta, extra),
941+
);
942+
if !self.session.is_ready().await {
943+
return Err(crate::error::SessionError::NotInitialized.into());
944+
}
945+
let response = tokio::time::timeout(
946+
self.config.timeouts.request,
947+
self.transport
948+
.send_request_with_extra_headers(request, extra_headers),
949+
)
950+
.await
951+
.map_err(|_| McpClientError::Timeout)??;
952+
if let Some(error) = response.get("error") {
953+
let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
954+
let message = error
955+
.get("message")
956+
.and_then(|m| m.as_str())
957+
.unwrap_or("Unknown error");
958+
let data = error.get("data").cloned();
959+
return Err(McpClientError::server_error(code, message, data));
960+
}
961+
parse(&response.get("result").cloned().unwrap_or(Value::Null))
962+
}
963+
890964
/// Reject an operation whose method was removed from the 2026-07-28 core when the
891965
/// connection negotiated 2026. The method remains available on a 2025-11-25 connection.
892966
async fn reject_if_2026_07_28(&self, _method: &str) -> McpClientResult<()> {
@@ -906,10 +980,12 @@ impl McpClient {
906980

907981
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
908982
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
983+
let extra_headers = self.mcp_param_headers_for(name, &arguments).await;
909984
return self
910-
.send_2026_07_28(
985+
.send_2026_07_28_with_extra_headers(
911986
"tools/call",
912987
json!({ "name": name, "arguments": arguments.clone() }),
988+
&extra_headers,
913989
crate::protocol::v2026_07_28::parse_call_tool,
914990
)
915991
.await;

crates/turul-mcp-client/src/protocol/v2026_07_28.rs

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,80 @@ pub(crate) fn parse_list_tools(
7979
result: &Value,
8080
) -> McpClientResult<Vec<turul_mcp_protocol_2025_11_25::Tool>> {
8181
let r: p::tools::ListToolsResult = serde_json::from_value(result.clone())?;
82-
r.tools.iter().map(remap).collect()
82+
// SEP-2243: clients on Streamable HTTP MUST reject tool definitions whose
83+
// x-mcp-header annotations violate the constraints — exclude the tool and
84+
// log a warning, so one malformed definition doesn't block the rest.
85+
r.tools
86+
.iter()
87+
.filter(|tool| {
88+
let schema = serde_json::to_value(&tool.input_schema).unwrap_or_default();
89+
match p::headers::scan_x_mcp_headers(&schema) {
90+
Ok(_) => true,
91+
Err(reason) => {
92+
tracing::warn!(
93+
tool = %tool.name,
94+
%reason,
95+
"excluding tool from tools/list: invalid x-mcp-header annotation"
96+
);
97+
false
98+
}
99+
}
100+
})
101+
.map(remap)
102+
.collect()
103+
}
104+
105+
/// SEP-2243: per-tool `Mcp-Param-*` bindings from a raw `tools/list` result —
106+
/// `(header name, argument JSON pointer, declared type)` per annotation.
107+
/// Tools with invalid annotations yield no entry (they are excluded from the
108+
/// list result anyway).
109+
pub(crate) fn collect_param_bindings(
110+
result: &Value,
111+
) -> std::collections::HashMap<String, Vec<(String, String, String)>> {
112+
let mut map = std::collections::HashMap::new();
113+
let Some(tools) = result.get("tools").and_then(|t| t.as_array()) else {
114+
return map;
115+
};
116+
for tool in tools {
117+
let (Some(name), Some(schema)) = (
118+
tool.get("name").and_then(|n| n.as_str()),
119+
tool.get("inputSchema"),
120+
) else {
121+
continue;
122+
};
123+
if let Ok(bindings) = p::headers::scan_x_mcp_headers(schema)
124+
&& !bindings.is_empty()
125+
{
126+
map.insert(
127+
name.to_string(),
128+
bindings
129+
.into_iter()
130+
.map(|b| (b.header_name, b.argument_pointer, b.schema_type))
131+
.collect(),
132+
);
133+
}
134+
}
135+
map
136+
}
137+
138+
/// Encode the `Mcp-Param-*` headers for a `tools/call`, per the cached
139+
/// bindings: absent/null arguments omit their header (per the SEP-2243
140+
/// client-behavior table); unencodable values also omit (the server rejects
141+
/// the call, which is the correct surfacing of a schema-violating argument).
142+
pub(crate) fn encode_param_headers(
143+
bindings: &[(String, String, String)],
144+
arguments: &Value,
145+
) -> Vec<(String, String)> {
146+
let mut headers = Vec::new();
147+
for (header_name, pointer, _schema_type) in bindings {
148+
let Some(value) = arguments.pointer(pointer).filter(|v| !v.is_null()) else {
149+
continue;
150+
};
151+
if let Some(encoded) = p::headers::encode_param_value(value) {
152+
headers.push((format!("Mcp-Param-{header_name}"), encoded));
153+
}
154+
}
155+
headers
83156
}
84157

85158
pub(crate) fn parse_call_tool(

crates/turul-mcp-client/src/transport.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ pub trait Transport: Send + Sync {
118118
/// Send a request and wait for response
119119
async fn send_request(&self, request: Value) -> McpClientResult<Value>;
120120

121+
/// Send a request with additional per-request HTTP headers (SEP-2243
122+
/// `Mcp-Param-*` mirrors). Non-HTTP transports MAY ignore the annotations
123+
/// entirely, so the default delegates to [`Self::send_request`].
124+
async fn send_request_with_extra_headers(
125+
&self,
126+
request: Value,
127+
_extra_headers: &[(String, String)],
128+
) -> McpClientResult<Value> {
129+
self.send_request(request).await
130+
}
131+
121132
/// Send a request and return response with headers (for initialization)
122133
async fn send_request_with_headers(&self, request: Value)
123134
-> McpClientResult<TransportResponse>;

crates/turul-mcp-client/src/transport/http.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,43 @@ impl Transport for HttpTransport {
688688
Ok(result)
689689
}
690690

691+
async fn send_request_with_extra_headers(
692+
&self,
693+
request: Value,
694+
extra_headers: &[(String, String)],
695+
) -> McpClientResult<Value> {
696+
if !self.is_connected() {
697+
return Err(TransportError::ConnectionFailed("Not connected".to_string()).into());
698+
}
699+
let mut request = request;
700+
if request.get("id").is_none() {
701+
request["id"] = Value::String(self.next_request_id());
702+
}
703+
self.update_stats(|stats| stats.requests_sent += 1);
704+
705+
let mut req_builder = self
706+
.client
707+
.post(self.endpoint.clone())
708+
.header("Content-Type", "application/json")
709+
.header("Accept", MCP_POST_ACCEPT)
710+
.header("MCP-Protocol-Version", self.protocol_version.read().clone());
711+
req_builder = self.apply_request_metadata_headers(req_builder, &request);
712+
for (name, value) in extra_headers {
713+
req_builder = req_builder.header(name, value);
714+
}
715+
req_builder = self.apply_auth_override(req_builder);
716+
if let Some(ref session_id) = *self.session_id.lock() {
717+
req_builder = req_builder.header("Mcp-Session-Id", session_id);
718+
}
719+
720+
let response = req_builder
721+
.json(&request)
722+
.send()
723+
.await
724+
.map_err(|e| TransportError::Http(format!("Failed to send request: {}", e)))?;
725+
self.handle_response(response).await
726+
}
727+
691728
async fn send_request_with_headers(
692729
&self,
693730
request: Value,

0 commit comments

Comments
 (0)