Skip to content

Commit 1185f01

Browse files
committed
feat(http): enforce required per-request _meta on the 2026 stateless path (P1)
The audit (+ codex review) flagged a conformance gap: the schema makes RequestParams._meta REQUIRED (RequestMetaObject with protocolVersion/clientInfo/ clientCapabilities) and the Base Protocol MUSTs -32602/HTTP-400 on missing required fields and on header/body protocol-version disagreement, but the 2026 server parsed _meta loosely and never rejected. Now the 2026 transport path validates every JsonRpcMessage::Request before dispatch: missing params._meta, a missing required RequestMetaObject key, or an _meta.protocolVersion that disagrees with the MCP-Protocol-Version header all return JSON-RPC -32602 at HTTP 400. Enforcement is 2026-only; 2025-11-25 is unaffected. Our bilingual client already attaches complete _meta on every 2026 request (incl. the server/discover probe), so negotiation is unchanged. Tests: discover_stateless_2026 gains 3 negative-path wire cases (missing _meta; missing clientCapabilities; header/body version mismatch) → all assert HTTP 400 + -32602. Revert-and-fail recorded: stashing the enforcement makes missing_meta fail (request gets 200). The enforcement surfaced two lambda lifecycle tests sending un-_meta'd tools/list under 2026 — gated 2025-only (they exercise the removed initialize/initialized handshake) along with their now-2025-only fixtures. Default suite: 0 failures, 0 warnings (build + test). Lambda 2025: 105 pass.
1 parent d09b380 commit 1185f01

3 files changed

Lines changed: 139 additions & 5 deletions

File tree

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

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,11 +1437,62 @@ impl StreamableHttpHandler {
14371437
}
14381438
};
14391439

1440-
// DRAFT-2026-v1 stateless core: there is no Mcp-Session-Id handshake, so a
1441-
// request is never rejected for lacking a session. Use a client-pinned
1442-
// session if one was supplied, otherwise mint an ephemeral session so the
1443-
// dispatch pipeline (which carries a SessionContext) proceeds unchanged; the
1444-
// client neither sends nor needs a session id.
1440+
// 2026-07-28: every request MUST carry a per-request `_meta` (RequestMetaObject)
1441+
// with protocolVersion/clientInfo/clientCapabilities (schema: RequestParams._meta
1442+
// is required), and its protocolVersion MUST match the MCP-Protocol-Version header.
1443+
// Reject -32602 (HTTP 400) on a missing/incomplete `_meta` or a header/body mismatch.
1444+
#[cfg(feature = "protocol-2026-07-28")]
1445+
if let JsonRpcMessage::Request(req) = &message {
1446+
let v = serde_json::to_value(req).unwrap_or(serde_json::Value::Null);
1447+
let header_version = context.protocol_version.as_str();
1448+
let reason = match v.get("params").and_then(|p| p.get("_meta")) {
1449+
Some(meta) if meta.is_object() => {
1450+
let pv = meta
1451+
.get("io.modelcontextprotocol/protocolVersion")
1452+
.and_then(|x| x.as_str());
1453+
if pv.is_none() {
1454+
Some("missing required _meta.io.modelcontextprotocol/protocolVersion".to_string())
1455+
} else if meta.get("io.modelcontextprotocol/clientInfo").is_none() {
1456+
Some("missing required _meta.io.modelcontextprotocol/clientInfo".to_string())
1457+
} else if meta.get("io.modelcontextprotocol/clientCapabilities").is_none() {
1458+
Some("missing required _meta.io.modelcontextprotocol/clientCapabilities".to_string())
1459+
} else if pv != Some(header_version) {
1460+
Some(format!(
1461+
"_meta protocolVersion '{}' disagrees with MCP-Protocol-Version header '{}'",
1462+
pv.unwrap_or(""),
1463+
header_version
1464+
))
1465+
} else {
1466+
None
1467+
}
1468+
}
1469+
_ => Some("missing required params._meta (RequestMetaObject)".to_string()),
1470+
};
1471+
if let Some(reason) = reason {
1472+
warn!("Rejecting 2026 request (method={}): {}", req.method, reason);
1473+
let err = turul_mcp_json_rpc_server::JsonRpcError::new(
1474+
Some(req.id.clone()),
1475+
turul_mcp_json_rpc_server::error::JsonRpcErrorObject::invalid_params(&reason),
1476+
);
1477+
let body = serde_json::to_string(&err).unwrap_or_else(|_| "{}".to_string());
1478+
return Response::builder()
1479+
.status(StatusCode::BAD_REQUEST)
1480+
.header(CONTENT_TYPE, "application/json")
1481+
.header("MCP-Protocol-Version", header_version)
1482+
.body(
1483+
Full::new(Bytes::from(body))
1484+
.map_err(|never| match never {})
1485+
.boxed_unsync(),
1486+
)
1487+
.unwrap();
1488+
}
1489+
}
1490+
1491+
// The stateless core has no Mcp-Session-Id handshake, so a request is never
1492+
// rejected for lacking a session. Use a client-pinned session if one was
1493+
// supplied, otherwise mint an ephemeral session so the dispatch pipeline (which
1494+
// carries a SessionContext) proceeds unchanged; the client neither sends nor
1495+
// needs a session id.
14451496
#[cfg(feature = "protocol-2026-07-28")]
14461497
let session_id = match &context.session_id {
14471498
Some(existing_id) => existing_id.clone(),

crates/turul-mcp-aws-lambda/src/handler.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,7 @@ mod tests {
915915
// ── Strict lifecycle tests over handle_streaming() ────────────────
916916

917917
/// Helper: build a Lambda handler with strict lifecycle and a test tool via the builder.
918+
#[cfg(feature = "protocol-2025-11-25")]
918919
async fn build_strict_streaming_handler() -> LambdaMcpHandler {
919920
use crate::LambdaMcpServerBuilder;
920921
use turul_mcp_session_storage::InMemorySessionStorage;
@@ -934,45 +935,55 @@ mod tests {
934935
}
935936

936937
// Test tool for lifecycle tests — satisfies all required traits
938+
#[cfg(feature = "protocol-2025-11-25")]
937939
#[derive(Clone, Default)]
938940
struct LifecycleTestTool;
939941

942+
#[cfg(feature = "protocol-2025-11-25")]
940943
impl turul_mcp_builders::traits::HasBaseMetadata for LifecycleTestTool {
941944
fn name(&self) -> &str {
942945
"ping_tool"
943946
}
944947
}
948+
#[cfg(feature = "protocol-2025-11-25")]
945949
impl turul_mcp_builders::traits::HasDescription for LifecycleTestTool {
946950
fn description(&self) -> Option<&str> {
947951
Some("test tool")
948952
}
949953
}
954+
#[cfg(feature = "protocol-2025-11-25")]
950955
impl turul_mcp_builders::traits::HasInputSchema for LifecycleTestTool {
951956
fn input_schema(&self) -> &turul_mcp_protocol::ToolSchema {
952957
static SCHEMA: std::sync::OnceLock<turul_mcp_protocol::ToolSchema> =
953958
std::sync::OnceLock::new();
954959
SCHEMA.get_or_init(turul_mcp_protocol::ToolSchema::object)
955960
}
956961
}
962+
#[cfg(feature = "protocol-2025-11-25")]
957963
impl turul_mcp_builders::traits::HasOutputSchema for LifecycleTestTool {
958964
fn output_schema(&self) -> Option<&turul_mcp_protocol::ToolSchema> {
959965
None
960966
}
961967
}
968+
#[cfg(feature = "protocol-2025-11-25")]
962969
impl turul_mcp_builders::traits::HasAnnotations for LifecycleTestTool {
963970
fn annotations(&self) -> Option<&turul_mcp_protocol::tools::ToolAnnotations> {
964971
None
965972
}
966973
}
974+
#[cfg(feature = "protocol-2025-11-25")]
967975
impl turul_mcp_builders::traits::HasToolMeta for LifecycleTestTool {
968976
fn tool_meta(&self) -> Option<&std::collections::HashMap<String, serde_json::Value>> {
969977
None
970978
}
971979
}
980+
#[cfg(feature = "protocol-2025-11-25")]
972981
impl turul_mcp_builders::traits::HasIcons for LifecycleTestTool {}
982+
#[cfg(feature = "protocol-2025-11-25")]
973983
impl turul_mcp_builders::traits::HasExecution for LifecycleTestTool {}
974984

975985
#[async_trait::async_trait]
986+
#[cfg(feature = "protocol-2025-11-25")]
976987
impl turul_mcp_server::McpTool for LifecycleTestTool {
977988
async fn call(
978989
&self,
@@ -986,6 +997,7 @@ mod tests {
986997
}
987998

988999
/// Helper: create a Lambda POST request for handle_streaming()
1000+
#[cfg(feature = "protocol-2025-11-25")]
9891001
fn streaming_mcp_request(body: &str, session_id: Option<&str>) -> LambdaRequest {
9901002
let mut builder = Request::builder()
9911003
.method("POST")
@@ -1002,6 +1014,7 @@ mod tests {
10021014
}
10031015

10041016
/// Helper: collect streaming response body into a string
1017+
#[cfg(feature = "protocol-2025-11-25")]
10051018
async fn collect_streaming_body(
10061019
response: lambda_http::Response<
10071020
http_body_util::combinators::UnsyncBoxBody<bytes::Bytes, hyper::Error>,
@@ -1026,6 +1039,7 @@ mod tests {
10261039
}
10271040

10281041
/// Helper: extract session ID from a streaming response
1042+
#[cfg(feature = "protocol-2025-11-25")]
10291043
fn extract_session_id(
10301044
response: &lambda_http::Response<
10311045
http_body_util::combinators::UnsyncBoxBody<bytes::Bytes, hyper::Error>,
@@ -1039,6 +1053,7 @@ mod tests {
10391053
}
10401054

10411055
/// Helper: parse JSON from a response body (handles SSE "data: " prefix)
1056+
#[cfg(feature = "protocol-2025-11-25")]
10421057
fn parse_response_json(body: &str) -> serde_json::Value {
10431058
// Strip SSE framing if present
10441059
let json_str = body
@@ -1222,6 +1237,9 @@ mod tests {
12221237
}
12231238

12241239
/// P0: tools/list succeeds immediately after notifications/initialized (race fix proof)
1240+
// The initialize/initialized handshake is 2025-11-25 only; the 2026-07-28 stateless
1241+
// core has no handshake and requires a per-request _meta on tools/list.
1242+
#[cfg(feature = "protocol-2025-11-25")]
12251243
#[tokio::test]
12261244
async fn test_lambda_streaming_initialized_is_effective_immediately() {
12271245
let handler = build_strict_streaming_handler().await;
@@ -1279,6 +1297,7 @@ mod tests {
12791297
}
12801298

12811299
/// P1: Lenient mode allows operations without notifications/initialized
1300+
#[cfg(feature = "protocol-2025-11-25")]
12821301
#[tokio::test]
12831302
async fn test_lambda_streaming_lenient_mode_allows_without_initialized() {
12841303
use crate::LambdaMcpServerBuilder;

crates/turul-mcp-server/tests/discover_stateless_2026.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,67 @@ async fn prompts_list_dispatches_statelessly_with_cacheable_result() {
194194
assert!(body["result"]["cacheScope"].is_string(), "missing cacheScope: {body}");
195195
assert!(body["result"]["prompts"].is_array(), "missing prompts array: {body}");
196196
}
197+
198+
/// POST `body`, asserting the 2026 server rejects it with HTTP 400 + JSON-RPC -32602.
199+
async fn assert_rejected_invalid_params(url: &str, header_version: Option<&str>, body: serde_json::Value) {
200+
let client = reqwest::Client::new();
201+
let mut req = client.post(url).header("Accept", "application/json");
202+
if let Some(v) = header_version {
203+
req = req.header("MCP-Protocol-Version", v);
204+
}
205+
let resp = req.json(&body).send().await.expect("POST");
206+
assert_eq!(resp.status(), 400, "must be rejected with HTTP 400: {body}");
207+
let out: serde_json::Value = resp.json().await.expect("json body");
208+
assert_eq!(out["error"]["code"], -32602, "must be JSON-RPC -32602: {out}");
209+
}
210+
211+
#[tokio::test]
212+
async fn missing_meta_is_rejected_with_invalid_params() {
213+
let url = start_server().await;
214+
// 2026 requires params._meta on every request — a tools/call without it is invalid.
215+
assert_rejected_invalid_params(
216+
&url,
217+
None,
218+
serde_json::json!({
219+
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
220+
"params": { "name": "echo", "arguments": { "message": "hi" } }
221+
}),
222+
)
223+
.await;
224+
}
225+
226+
#[tokio::test]
227+
async fn incomplete_meta_missing_client_capabilities_is_rejected() {
228+
let url = start_server().await;
229+
assert_rejected_invalid_params(
230+
&url,
231+
None,
232+
serde_json::json!({
233+
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
234+
"params": {
235+
"_meta": {
236+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
237+
"io.modelcontextprotocol/clientInfo": { "name": "t", "version": "1.0.0" }
238+
// missing io.modelcontextprotocol/clientCapabilities
239+
},
240+
"name": "echo", "arguments": { "message": "hi" }
241+
}
242+
}),
243+
)
244+
.await;
245+
}
246+
247+
#[tokio::test]
248+
async fn header_body_protocol_version_mismatch_is_rejected() {
249+
let url = start_server().await;
250+
// _meta says 2026-07-28 (via meta()); the MCP-Protocol-Version header says 2025-11-25.
251+
assert_rejected_invalid_params(
252+
&url,
253+
Some("2025-11-25"),
254+
serde_json::json!({
255+
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
256+
"params": { "_meta": meta(), "name": "echo", "arguments": { "message": "hi" } }
257+
}),
258+
)
259+
.await;
260+
}

0 commit comments

Comments
 (0)