Skip to content

Commit 006256b

Browse files
committed
fix(lambda): route protocol-2026-07-28 builds to the streamable handler
LambdaMcpHandler::handle() delegated every request to the legacy SessionMcpHandler, and handle_streaming() routed by the request's MCP-Protocol-Version header. On a protocol-2026-07-28 build only StreamableHttpHandler mints the stateless core's internal per-request session and enforces SEP-2243 Server Validation, so tools received session: None (-32602 "session required") even after middleware SessionInjection::set_state, and the buffered lane bypassed header validation entirely. Both entry points now mirror server.rs's feature-gated single-spec routing; the protocol-2025-11-25 lane is unchanged (its suite: 116 passed). Closes the ADR-029 §Consequences "Lambda simplification" drift. Tests: stateless_session_2026_07_28.rs exercises the production path (LambdaMcpServerBuilder → handler() → handle()). Revert-and-fail verified: with the routing fix stashed all three tests fail (the session test with the exact field wire bytes); restored, all pass. middleware_parity.rs gated to protocol-2025-11-25 — it drives the initialize/Mcp-Session-Id lifecycle and passed on the 2026 default only via the wrong legacy routing. Live readback confirmed via cargo lambda invoke in gps-trust-world-model-mcp (authenticated tools/call returns data; unauthenticated still denied -32001).
1 parent c9ab0ae commit 006256b

5 files changed

Lines changed: 290 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
100100
- **`turul-mcp-client` no longer depends on the `turul-mcp-protocol` alias** — closing the ADR-030 drift. The frozen `turul-mcp-protocol-2025-11-25` crate is now an unconditional dependency serving as the public type vocabulary; `turul-mcp-protocol-2026-07-28` stays feature-gated. This is load-bearing beyond hygiene: the alias pin (`protocol-2025-11-25`) made any dependency graph containing both the client and a 2026-default server trip the ADR-029 spec mutex — which is exactly what blocked the new real-server e2e test. Narrowing features now control which wire paths compile (`client-2025-11-25-only = []`). See ADR-030 revision log (2026-06-10).
101101
- **Server-side `subscriptions/listen` (2026-07-28).** The stateless transport now serves the Subscriptions pattern that replaced the GET notification stream and the `resources/subscribe` RPC: a `subscriptions/listen` POST opens a long-lived SSE stream whose first message is `notifications/subscriptions/acknowledged` echoing the honored filter subset (requested types without a corresponding server capability section are omitted); only opted-in types are delivered (`toolsListChanged`/`promptsListChanged`/`resourcesListChanged` plus per-URI `resourceSubscriptions` filtering of `notifications/resources/updated`); every delivered notification is stamped with `io.modelcontextprotocol/subscriptionId` in `_meta`, set to the JSON-RPC id of the listen request. Delivery is gated at the broadcast layer (subscription registry entry created even for an empty filter) with a per-URI + type filter at the stream layer; the client cancels by closing the stream. Real-HTTP acceptance suite `subscriptions_listen_2026.rs` (3 tests: ack-first + cross-request broadcast delivery with filtering, SSE-Accept required, unsupported-type omission in the ack; all failing pre-implementation — revert-and-fail evidence) wired into the default CI lane. Not yet done: capability advertisement reconciliation (`resources.subscribe`) and the client-side listen API.
102102

103+
### Fixed (2026-07-13)
104+
105+
- **Lambda transport routed the 2026-07-28 lane to the sessionless legacy handler — tools received `session: None`.** `LambdaMcpHandler::handle()` (buffered, non-streaming Lambda runtime) delegated every request to `SessionMcpHandler`, and `handle_streaming()` trusted the request's `MCP-Protocol-Version` header for routing — but on a `protocol-2026-07-28` build only `StreamableHttpHandler` mints the stateless core's internal per-request session and enforces SEP-2243 Server Validation. Result: a middleware-authenticated `tools/call` over Lambda reached the tool with no `SessionContext` (field symptom: `-32602 "session required"` even though `SessionInjection::set_state` had run), and the buffered lane bypassed header validation, the POST-only surface, and unknown-method 404 mapping entirely. Both entry points now mirror `server.rs`: under `protocol-2026-07-28` all requests go to the streamable handler (the JSON-framed 2026 path returns buffered bodies, so the non-streaming Lambda runtime is unaffected); the `protocol-2025-11-25` lane keeps its existing unconditional `SessionMcpHandler` delegation for `handle()`, and header-based routing for `handle_streaming()`, unchanged. Closes the ADR-029 §Consequences "Lambda simplification" drift (one Lambda = one feature = one protocol). Tests: `stateless_session_2026_07_28.rs` (production path `LambdaMcpServerBuilder → handler() → handle()`: middleware-injected state readable via `SessionContext::get_typed_state` in the tool, missing `MCP-Protocol-Version` → 400/`-32020`, middleware `Unauthenticated` short-circuit → `-32001`; all three fail with the routing fix reverted — revert-and-fail recorded). `middleware_parity.rs` (drives the `initialize`/`Mcp-Session-Id` lifecycle) is now gated `protocol-2025-11-25` — it only passed on the 2026 default because of the wrong legacy routing.
106+
103107
### Changed
104108

105109
- **2026 HTTP surface gated to POST-only (2026-06-10).** Under the `protocol-2026-07-28` default, the MCP endpoint now answers legacy-era traffic per the Streamable HTTP binding's Backward Compatibility rules: HTTP GET (the removed standalone SSE stream) and DELETE (the removed session termination) return `405 Method Not Allowed` with `Allow: POST, OPTIONS`; an inbound `Mcp-Session-Id` header is ignored at parse time (never honored as a session, never echoed — internal per-request sessions are no longer client-pinnable); `Last-Event-ID` is ignored (streams are not resumable in this revision); notification `202 Accepted` responses no longer carry a session header. The 2025-11-25 opt-in lane keeps the full stateful GET-SSE/session surface unchanged. New real-HTTP acceptance suite `stateless_2026_http_surface.rs` (5 tests, all failing pre-fix — revert-and-fail evidence) wired into the default CI lane (`ci.yml` + `scripts/ci-gates.sh`).

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,18 @@ impl LambdaMcpHandler {
344344
}
345345
}
346346

347-
// 🚀 DELEGATION: Use SessionMcpHandler for all business logic
347+
// A protocol-2026-07-28 build serves a single spec: every request goes
348+
// to the streamable handler, which mints the stateless core's
349+
// per-request session (so middleware-injected state reaches tool
350+
// handlers) and enforces Server Validation. SessionMcpHandler would
351+
// dispatch without a session and bypass both contracts.
352+
#[cfg(feature = "protocol-2026-07-28")]
353+
let hyper_resp = self.streamable_handler.handle_request(hyper_req).await;
354+
355+
// protocol-2025-11-25 buffered lane: SessionMcpHandler owns the
356+
// Mcp-Session-Id lifecycle and returns buffered JSON bodies suited to
357+
// non-streaming Lambda responses.
358+
#[cfg(feature = "protocol-2025-11-25")]
348359
let hyper_resp = self
349360
.session_handler
350361
.handle_mcp_request(hyper_req)
@@ -459,8 +470,18 @@ impl LambdaMcpHandler {
459470
.and_then(McpProtocolVersion::parse_version)
460471
.unwrap_or(McpProtocolVersion::V2025_06_18);
461472

473+
// A protocol-2026-07-28 build serves a single spec: route everything
474+
// to the streamable handler, whose Server Validation rejects
475+
// unsupported MCP-Protocol-Version values. Routing legacy version
476+
// headers to SessionMcpHandler would bypass that contract and dispatch
477+
// without a per-request session.
478+
#[cfg(feature = "protocol-2026-07-28")]
479+
let route_streamable = true;
480+
#[cfg(feature = "protocol-2025-11-25")]
481+
let route_streamable = protocol_version.supports_streamable_http();
482+
462483
// Route based on protocol version
463-
let hyper_resp = if protocol_version.supports_streamable_http() {
484+
let hyper_resp = if route_streamable {
464485
// Use StreamableHttpHandler for MCP 2025-11-25 (proper headers, chunked SSE)
465486
debug!(
466487
"Using StreamableHttpHandler for protocol {}",

crates/turul-mcp-aws-lambda/tests/middleware_parity.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
//! Lambda middleware parity test
1+
//! Lambda middleware parity test (2025-11-25 lane)
22
//!
33
//! Verifies that middleware works identically in Lambda transport as it does in HTTP transport.
44
//! This ensures the LambdaMcpHandler correctly uses middleware through StreamableHttpHandler
55
//! and SessionMcpHandler.
6+
//!
7+
//! These tests drive the 2025-11-25 session lifecycle (`initialize` handshake,
8+
//! `Mcp-Session-Id` header), which the 2026-07-28 stateless core removes.
9+
//! The 2026-lane equivalents live in `stateless_session_2026_07_28.rs`.
10+
#![cfg(feature = "protocol-2025-11-25")]
611

712
use async_trait::async_trait;
813
use serde_json::json;
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
//! 2026-07-28 stateless-lane session construction over the buffered Lambda transport.
2+
//!
3+
//! The stateless core removes the client-visible `Mcp-Session-Id`, but every
4+
//! dispatched request still carries an internally-minted per-request session.
5+
//! Middleware state written via `SessionInjection::set_state` must therefore be
6+
//! readable from the tool's `SessionContext` — over the Lambda transport exactly
7+
//! as over the local `turul-http-mcp-server` transport.
8+
//!
9+
//! Exercises the production path: `LambdaMcpServerBuilder` → `server.handler()`
10+
//! → `LambdaMcpHandler::handle()` (buffered, non-streaming Lambda runtime).
11+
#![cfg(feature = "protocol-2026-07-28")]
12+
13+
use std::sync::Arc;
14+
15+
use async_trait::async_trait;
16+
use serde_json::json;
17+
18+
use turul_http_mcp_server::middleware::{
19+
DispatcherResult, McpMiddleware, MiddlewareError, RequestContext, SessionInjection,
20+
};
21+
use turul_mcp_aws_lambda::{LambdaMcpHandler, LambdaMcpServerBuilder};
22+
use turul_mcp_derive::McpTool;
23+
use turul_mcp_protocol::McpError;
24+
use turul_mcp_server::{McpResult, SessionContext};
25+
use turul_mcp_session_storage::SessionView;
26+
27+
/// Simulates an authenticating middleware (e.g. API Gateway authorizer bridge)
28+
/// that stamps the caller's identity into per-request session state.
29+
struct AccountInjectingMiddleware;
30+
31+
#[async_trait]
32+
impl McpMiddleware for AccountInjectingMiddleware {
33+
async fn before_dispatch(
34+
&self,
35+
_ctx: &mut RequestContext<'_>,
36+
_session: Option<&dyn SessionView>,
37+
injection: &mut SessionInjection,
38+
) -> Result<(), MiddlewareError> {
39+
injection.set_state("account_id", json!("acct-42"));
40+
Ok(())
41+
}
42+
43+
async fn after_dispatch(
44+
&self,
45+
_ctx: &RequestContext<'_>,
46+
_result: &mut DispatcherResult,
47+
) -> Result<(), MiddlewareError> {
48+
Ok(())
49+
}
50+
}
51+
52+
/// Tool that requires a session and echoes the middleware-injected account id.
53+
#[derive(McpTool, Clone, Default)]
54+
#[tool(
55+
name = "whoami",
56+
description = "Return the middleware-injected account id",
57+
output = String
58+
)]
59+
struct WhoamiTool {}
60+
61+
impl WhoamiTool {
62+
async fn execute(&self, session: Option<SessionContext>) -> McpResult<String> {
63+
let session = session.ok_or_else(|| McpError::InvalidRequest {
64+
message: "session required".to_string(),
65+
})?;
66+
session
67+
.get_typed_state::<String>("account_id")
68+
.await
69+
.ok_or_else(|| McpError::InvalidRequest {
70+
message: "account_id missing from session".to_string(),
71+
})
72+
}
73+
}
74+
75+
fn request_meta() -> serde_json::Value {
76+
json!({
77+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
78+
"io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "1.0.0" },
79+
"io.modelcontextprotocol/clientCapabilities": {}
80+
})
81+
}
82+
83+
async fn build_handler() -> LambdaMcpHandler {
84+
let server = LambdaMcpServerBuilder::new()
85+
.name("stateless-session-test")
86+
.version("1.0.0")
87+
.tool(WhoamiTool::default())
88+
.middleware(Arc::new(AccountInjectingMiddleware))
89+
.build()
90+
.await
91+
.expect("build lambda server");
92+
server.handler().await.expect("create lambda handler")
93+
}
94+
95+
fn whoami_request() -> lambda_http::Request {
96+
let body = json!({
97+
"jsonrpc": "2.0",
98+
"id": 1,
99+
"method": "tools/call",
100+
"params": {
101+
"name": "whoami",
102+
"arguments": {},
103+
"_meta": request_meta()
104+
}
105+
});
106+
http::Request::builder()
107+
.method("POST")
108+
.uri("/mcp")
109+
.header("Content-Type", "application/json")
110+
.header("Accept", "application/json")
111+
.header("MCP-Protocol-Version", "2026-07-28")
112+
.header("Mcp-Method", "tools/call")
113+
.header("Mcp-Name", "whoami")
114+
.body(lambda_http::Body::from(
115+
serde_json::to_string(&body).unwrap(),
116+
))
117+
.unwrap()
118+
}
119+
120+
fn response_json(response: &lambda_http::Response<lambda_http::Body>) -> serde_json::Value {
121+
let bytes = match response.body() {
122+
lambda_http::Body::Text(s) => s.as_bytes().to_vec(),
123+
lambda_http::Body::Binary(b) => b.clone(),
124+
_ => Vec::new(),
125+
};
126+
serde_json::from_slice(&bytes).expect("response body must be JSON")
127+
}
128+
129+
/// A middleware-authenticated tools/call over the buffered Lambda transport
130+
/// receives a per-request session carrying the injected state.
131+
#[tokio::test]
132+
async fn tool_receives_session_with_middleware_state_over_lambda_handle() {
133+
let handler = build_handler().await;
134+
let response = handler.handle(whoami_request()).await.expect("handle");
135+
136+
assert_eq!(response.status(), 200, "tools/call must succeed");
137+
assert_eq!(
138+
response
139+
.headers()
140+
.get("content-type")
141+
.and_then(|v| v.to_str().ok()),
142+
Some("application/json"),
143+
"JSON framing expected for Accept: application/json"
144+
);
145+
146+
let body = response_json(&response);
147+
assert!(
148+
body.get("error").is_none(),
149+
"expected success result, got error: {body}"
150+
);
151+
let text = body["result"]["content"][0]["text"]
152+
.as_str()
153+
.unwrap_or_else(|| panic!("missing result.content[0].text in: {body}"));
154+
assert!(
155+
text.contains("acct-42"),
156+
"tool must read middleware-injected account_id, got: {text}"
157+
);
158+
}
159+
160+
/// Middleware that rejects every request, simulating failed authentication.
161+
struct BlockingMiddleware;
162+
163+
#[async_trait]
164+
impl McpMiddleware for BlockingMiddleware {
165+
async fn before_dispatch(
166+
&self,
167+
_ctx: &mut RequestContext<'_>,
168+
_session: Option<&dyn SessionView>,
169+
_injection: &mut SessionInjection,
170+
) -> Result<(), MiddlewareError> {
171+
Err(MiddlewareError::Unauthenticated(
172+
"Lambda auth required".to_string(),
173+
))
174+
}
175+
176+
async fn after_dispatch(
177+
&self,
178+
_ctx: &RequestContext<'_>,
179+
_result: &mut DispatcherResult,
180+
) -> Result<(), MiddlewareError> {
181+
Ok(())
182+
}
183+
}
184+
185+
/// A middleware rejection short-circuits dispatch and surfaces as a JSON-RPC
186+
/// error (-32001 Unauthenticated) over the buffered Lambda transport.
187+
#[tokio::test]
188+
async fn middleware_error_short_circuits_over_lambda_handle() {
189+
let server = LambdaMcpServerBuilder::new()
190+
.name("stateless-session-test")
191+
.version("1.0.0")
192+
.tool(WhoamiTool::default())
193+
.middleware(Arc::new(BlockingMiddleware))
194+
.build()
195+
.await
196+
.expect("build lambda server");
197+
let handler = server.handler().await.expect("create lambda handler");
198+
199+
let response = handler.handle(whoami_request()).await.expect("handle");
200+
assert_eq!(response.status(), 200, "JSON-RPC errors ride HTTP 200");
201+
202+
let body = response_json(&response);
203+
assert_eq!(
204+
body["error"]["code"].as_i64(),
205+
Some(-32001),
206+
"expected Unauthenticated, got: {body}"
207+
);
208+
assert!(
209+
body["error"]["message"]
210+
.as_str()
211+
.unwrap_or_default()
212+
.contains("Lambda auth required"),
213+
"middleware message must surface, got: {body}"
214+
);
215+
}
216+
217+
/// The buffered Lambda lane enforces 2026-07-28 Server Validation: a request
218+
/// missing the MCP-Protocol-Version header is rejected with HTTP 400 and
219+
/// JSON-RPC -32020 (HeaderMismatch), not silently dispatched.
220+
#[tokio::test]
221+
async fn missing_protocol_version_header_rejected_over_lambda_handle() {
222+
let handler = build_handler().await;
223+
224+
let body = json!({
225+
"jsonrpc": "2.0",
226+
"id": 1,
227+
"method": "tools/call",
228+
"params": {
229+
"name": "whoami",
230+
"arguments": {},
231+
"_meta": request_meta()
232+
}
233+
});
234+
let request = http::Request::builder()
235+
.method("POST")
236+
.uri("/mcp")
237+
.header("Content-Type", "application/json")
238+
.header("Accept", "application/json")
239+
.header("Mcp-Method", "tools/call")
240+
.header("Mcp-Name", "whoami")
241+
.body(lambda_http::Body::from(
242+
serde_json::to_string(&body).unwrap(),
243+
))
244+
.unwrap();
245+
246+
let response = handler.handle(request).await.expect("handle");
247+
assert_eq!(response.status(), 400, "header validation must reject");
248+
249+
let body = response_json(&response);
250+
assert_eq!(
251+
body["error"]["code"].as_i64(),
252+
Some(turul_mcp_protocol::headers::ERROR_CODE_HEADER_MISMATCH),
253+
"expected -32020 HeaderMismatch, got: {body}"
254+
);
255+
}

docs/adr/029-spec-coexistence-via-cargo-features.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,5 @@ The user-locked steelman from the decision phase (`docs/plans/2026-07-28-archite
224224
- **2026-06-08 (doc reconciliation + 2025 regression coverage)** — Current-state prose in this ADR that still named the 2026 spec `DRAFT-2026-v1` (Context status line, the §"Why coexistence" cleavage points, the §Constraints "Server default" line, the §Decision "0.4.0 defaults to…" line, the §Consequences release-notes wording) was reconciled to the finalized wire literal `2026-07-28`. Remaining `DRAFT-2026-v1` mentions are deliberate history: ADR-027's title/subject references and the dated RC-instability rationale. Separately, `roots-server` was found pinned to the 2026 default (its `Root` type resolved to the deprecated 2026 binding), so the 2025 `mcp-roots-tests` e2e suite could not handshake it — all 14 tests failed at server start. Pinning `roots-server` to the 2025 opt-in (matching `sampling-server`/`elicitation-server`) turns the suite green; the roots/sampling/elicitation suites and a `client-initialise-server` build were added to the opt-in-2025 CI lane (they had been absent). `client-initialise-server` itself, an inherently-stateful `initialize`/`Mcp-Session-Id` demo, was moved out of `default-members` and pinned to the 2025 opt-in.
225225

226226
- **2026-06-10** — §"CI surface" rewritten to the as-built lanes. The prescribed `cargo test --workspace [--no-default-features --features protocol-2025-11-25]` commands never compiled: spec-pinned workspace members (2025-pinned e2e crates, 2026 default examples) make any workspace-wide build unify both alias protocol features and trip this ADR's `compile_error!` mutex. The operative CI shape is default-members for the 2026 lane plus a per-crate matrix for the 2025 lane (`.github/workflows/ci.yml`, `scripts/ci-gates.sh`).
227+
228+
- **2026-07-13 (Lambda transport drift corrected)** — §Consequences "Lambda simplification" claimed the Lambda handler stack "does not multiplex protocols… all collapse to one branch", but `turul-mcp-aws-lambda`'s `LambdaMcpHandler` never got the single-spec routing the local server received on 2026-06-10 (`server.rs` force-routes to the streamable handler under `protocol-2026-07-28`): the buffered `handle()` delegated everything to the 2025-era `SessionMcpHandler`, and `handle_streaming()` routed by the request's `MCP-Protocol-Version` header. On a 2026 build this dispatched without the stateless core's internal per-request session (tools saw `session: None`; middleware `SessionInjection::set_state` was unreadable) and bypassed SEP-2243 Server Validation. Both entry points now carry the same feature-gated routing as `server.rs`; the 2025-11-25 opt-in lane's behavior is unchanged. No contract change — code brought into compliance with this ADR. Regression net: `crates/turul-mcp-aws-lambda/tests/stateless_session_2026_07_28.rs`.

0 commit comments

Comments
 (0)