Skip to main content

turul_mcp_aws_lambda/
lib.rs

1//! AWS Lambda integration for turul-mcp-framework
2//!
3//! This crate provides seamless integration between the turul-mcp-framework and AWS Lambda,
4//! enabling serverless deployment of MCP servers with proper session management, CORS handling,
5//! and SSE streaming support.
6//!
7//! ## Architecture
8//!
9//! The crate bridges the gap between Lambda's HTTP execution model and the framework's
10//! hyper-based architecture through:
11//!
12//! - **Type Conversion**: Clean conversion between `lambda_http` and `hyper` types
13//! - **Handler Registration**: Direct tool registration with `JsonRpcDispatcher`
14//! - **Session Management**: DynamoDB-backed session persistence across invocations
15//! - **CORS Support**: Proper CORS header injection for browser clients
16//! - **SSE Streaming**: Server-Sent Events adaptation through Lambda's streaming response
17//!
18//! ## Quick Start
19//!
20//! ```rust,no_run
21//! use turul_mcp_aws_lambda::LambdaMcpServerBuilder;
22//! use turul_mcp_derive::McpTool;
23//! use turul_mcp_server::{McpResult, SessionContext};
24//!
25//! #[derive(McpTool, Clone, Default)]
26//! #[tool(name = "example", description = "Example tool")]
27//! struct ExampleTool {
28//!     #[param(description = "Example parameter")]
29//!     value: String,
30//! }
31//!
32//! impl ExampleTool {
33//!     async fn execute(&self, _session: Option<SessionContext>) -> McpResult<String> {
34//!         Ok(format!("Got: {}", self.value))
35//!     }
36//! }
37//!
38//! #[tokio::main]
39//! async fn main() -> Result<(), lambda_http::Error> {
40//!     let server = LambdaMcpServerBuilder::new()
41//!         .tool(ExampleTool::default())
42//!         .cors_allow_all_origins()
43//!         .build()
44//!         .await?;
45//!
46//!     let handler = server.handler().await?;
47//!
48//!     // run_streaming handles API Gateway completion invocations gracefully
49//!     turul_mcp_aws_lambda::run_streaming(handler).await
50//! }
51//! ```
52//!
53//! ## Streaming Entry Points
54//!
55//! Two entry points replace `lambda_http::run_with_streaming_response()`:
56//!
57//! - [`run_streaming()`] — standard path: pass a [`LambdaMcpHandler`] directly
58//! - [`run_streaming_with()`] — custom dispatch: provide your own closure for
59//!   pre-dispatch logic (e.g., `.well-known` routing) while still getting
60//!   completion-invocation handling for free
61
62pub mod adapter;
63pub mod builder;
64pub mod error;
65pub mod handler;
66pub mod prelude;
67pub mod server;
68
69#[cfg(feature = "cors")]
70pub mod cors;
71
72#[cfg(feature = "sse")]
73pub mod streaming;
74
75// Re-exports for convenience
76/// Builder for creating Lambda MCP servers with fluent configuration API
77pub use builder::LambdaMcpServerBuilder;
78/// Lambda-specific error types and result aliases
79pub use error::{LambdaError, Result};
80/// Lambda request handler with session management and protocol conversion
81pub use handler::LambdaMcpHandler;
82/// Core Lambda MCP server implementation with DynamoDB integration
83pub use server::LambdaMcpServer;
84
85#[cfg(feature = "cors")]
86pub use cors::{CorsConfig, create_preflight_response, inject_cors_headers};
87
88/// Classification of a raw Lambda runtime event payload.
89///
90/// Used by [`run_streaming()`] to distinguish API Gateway requests from
91/// streaming completion invocations and unknown event shapes.
92#[derive(Debug)]
93enum RuntimeEventClassification {
94    /// Valid API Gateway / ALB / Function URL event.
95    ///
96    /// Stores `Box<LambdaRequest>` to avoid a large enum variant (clippy
97    /// `large_enum_variant`). Callers dereference with `(*lambda_request).into()`
98    /// to move the inner `LambdaRequest` into an `http::Request`.
99    ApiGatewayEvent(Box<lambda_http::request::LambdaRequest>),
100    /// AWS streaming completion invocation (contains `invokeCompletionStatus`)
101    StreamingCompletion,
102    /// Unrecognized payload — not API Gateway, not completion
103    UnrecognizedEvent,
104}
105
106/// Classify a raw JSON payload into one of three categories.
107///
108/// Order matters:
109/// 1. Try API Gateway/ALB/WebSocket deserialization first (most common path)
110/// 2. Check for streaming completion signature (`invokeCompletionStatus` at top level)
111/// 3. Everything else is unrecognized
112///
113/// # Completion Detection Heuristic
114///
115/// Streaming completion payloads are identified by the presence of an
116/// `invokeCompletionStatus` field at the top level. This is a compatibility
117/// heuristic based on observed AWS behavior as of 2026-03 — AWS does not
118/// officially document this payload shape.
119///
120/// As of this writing, no API Gateway v1/v2, ALB, or WebSocket event
121/// produced by `lambda_http` contains this field at the top level.
122/// The fixture corpus in `src/fixtures/` guards against drift.
123///
124/// **Precedence**: API Gateway deserialization is attempted first.
125/// If a payload is both a valid API Gateway event AND contains
126/// `invokeCompletionStatus`, it will be classified as `ApiGatewayEvent`.
127/// The completion heuristic only applies to payloads that fail API
128/// Gateway parsing. This means false-positive completion detection is
129/// preferred over retry storms — an intentional design choice.
130fn classify_runtime_event(payload: serde_json::Value) -> RuntimeEventClassification {
131    // Fast path: try API Gateway event deserialization
132    if let Ok(request) =
133        serde_json::from_value::<lambda_http::request::LambdaRequest>(payload.clone())
134    {
135        return RuntimeEventClassification::ApiGatewayEvent(Box::new(request));
136    }
137
138    // Check for streaming completion signature
139    if payload.get("invokeCompletionStatus").is_some() {
140        return RuntimeEventClassification::StreamingCompletion;
141    }
142
143    // Unknown payload shape
144    RuntimeEventClassification::UnrecognizedEvent
145}
146
147type StreamBody = http_body_util::combinators::UnsyncBoxBody<bytes::Bytes, hyper::Error>;
148type StreamResult =
149    lambda_runtime::StreamResponse<http_body_util::BodyDataStream<EnsureOneFrame<StreamBody>>>;
150
151/// Result of [`handle_runtime_payload()`], carrying both the Lambda response
152/// and a static string identifying the event type for logging/observability.
153struct HandleResult {
154    response: StreamResult,
155    /// One of `"api_gateway_event"`, `"streaming_completion"`, or
156    /// `"unrecognized_lambda_payload"`.
157    event_type: &'static str,
158}
159
160/// Process a raw Lambda runtime payload into a streaming response.
161///
162/// Classifies the payload via [`classify_runtime_event()`], dispatches API
163/// Gateway events through `dispatch`, and acknowledges non-API payloads with
164/// an empty 200 response. Returns a [`HandleResult`] so the caller can
165/// inspect `event_type` for logging decisions.
166async fn handle_runtime_payload<F, Fut>(
167    payload: serde_json::Value,
168    context: lambda_runtime::Context,
169    dispatch: F,
170) -> std::result::Result<HandleResult, lambda_http::Error>
171where
172    F: FnOnce(lambda_http::Request) -> Fut,
173    Fut: std::future::Future<
174            Output = std::result::Result<http::Response<StreamBody>, lambda_http::Error>,
175        >,
176{
177    match classify_runtime_event(payload) {
178        RuntimeEventClassification::ApiGatewayEvent(lambda_request) => {
179            use lambda_http::RequestExt;
180            let request: lambda_http::Request = (*lambda_request).into();
181            let request = request.with_lambda_context(context);
182            let response = dispatch(request).await?;
183            Ok(HandleResult {
184                response: into_lambda_stream_response(response),
185                event_type: "api_gateway_event",
186            })
187        }
188        RuntimeEventClassification::StreamingCompletion => Ok(HandleResult {
189            response: into_lambda_stream_response(empty_streaming_response()),
190            event_type: "streaming_completion",
191        }),
192        RuntimeEventClassification::UnrecognizedEvent => Ok(HandleResult {
193            response: into_lambda_stream_response(empty_streaming_response()),
194            event_type: "unrecognized_lambda_payload",
195        }),
196    }
197}
198
199/// Map an event type string to the appropriate tracing log level.
200///
201/// Returns `Some(Level::WARN)` for unrecognized payloads (surfaced in
202/// CloudWatch), `Some(Level::DEBUG)` for completion acks (normally silent),
203/// and `None` for API Gateway events (no extra logging needed).
204fn event_log_level(event_type: &str) -> Option<tracing::Level> {
205    match event_type {
206        "streaming_completion" => Some(tracing::Level::DEBUG),
207        "unrecognized_lambda_payload" => Some(tracing::Level::WARN),
208        _ => None,
209    }
210}
211
212/// Run the Lambda MCP handler with streaming response support.
213///
214/// This replaces `lambda_http::run_with_streaming_response(service_fn(...))` and
215/// gracefully handles API Gateway streaming completion invocations that would
216/// otherwise cause deserialization errors in the Lambda runtime.
217///
218/// ## Problem
219///
220/// When API Gateway uses `response-streaming-invocations`, it sends a secondary
221/// "completion" invocation after the streaming response finishes. This invocation
222/// is NOT an API Gateway proxy event — `lambda_http` cannot deserialize it, causing
223/// ERROR logs and CloudWatch Lambda Error metrics for every streaming response.
224///
225/// ## Solution
226///
227/// This function uses `lambda_runtime::run()` directly with `serde_json::Value`
228/// (which always deserializes), then classifies the payload three ways via
229/// [`classify_runtime_event()`]:
230///
231/// - **`ApiGatewayEvent`** — dispatched to the handler normally
232/// - **`StreamingCompletion`** — acknowledged silently (`debug` log)
233/// - **`UnrecognizedEvent`** — acknowledged with a `warn` log to surface
234///   unexpected payload shapes in CloudWatch without triggering Lambda retries
235///
236/// ## Usage
237///
238/// ```rust,ignore
239/// let handler = server.handler().await?;
240/// turul_mcp_aws_lambda::run_streaming(handler).await
241/// ```
242pub async fn run_streaming(
243    handler: LambdaMcpHandler,
244) -> std::result::Result<(), lambda_http::Error> {
245    use lambda_runtime::{LambdaEvent, service_fn};
246
247    lambda_runtime::run(service_fn(move |event: LambdaEvent<serde_json::Value>| {
248        let handler = handler.clone();
249        async move {
250            let result = handle_runtime_payload(event.payload, event.context, |req| {
251                handler.handle_streaming(req)
252            })
253            .await?;
254
255            match event_log_level(result.event_type) {
256                Some(level) if level == tracing::Level::WARN => {
257                    tracing::warn!(
258                        event_type = result.event_type,
259                        "Received unrecognized Lambda invocation payload"
260                    );
261                }
262                Some(_) => {
263                    tracing::debug!(
264                        event_type = result.event_type,
265                        "Acknowledging streaming completion"
266                    );
267                }
268                None => {}
269            }
270
271            Ok::<_, lambda_http::Error>(result.response)
272        }
273    }))
274    .await
275}
276
277/// Run a custom dispatch function with streaming response support.
278///
279/// Like [`run_streaming()`], but accepts a custom dispatch closure instead of
280/// a [`LambdaMcpHandler`]. Use this when you need pre-dispatch logic
281/// (e.g., `.well-known` routing) that runs before the MCP handler.
282///
283/// The dispatch closure is called once per API Gateway invocation. Streaming
284/// completion invocations and unrecognized payloads are acknowledged
285/// automatically without invoking the closure.
286///
287/// ## Usage
288///
289/// ```rust,ignore
290/// async fn lambda_handler(request: Request) -> Result<Response, Error> {
291///     // pre-dispatch logic here (e.g., .well-known short-circuit)
292///     let handler = HANDLER.get_or_try_init(|| async { ... }).await?;
293///     handler.handle_streaming(request).await
294/// }
295///
296/// turul_mcp_aws_lambda::run_streaming_with(lambda_handler).await
297/// ```
298pub async fn run_streaming_with<F, Fut>(dispatch: F) -> std::result::Result<(), lambda_http::Error>
299where
300    F: Fn(lambda_http::Request) -> Fut + Clone + Send + 'static,
301    Fut: std::future::Future<
302            Output = std::result::Result<http::Response<StreamBody>, lambda_http::Error>,
303        > + Send,
304{
305    use lambda_runtime::{LambdaEvent, service_fn};
306
307    lambda_runtime::run(service_fn(move |event: LambdaEvent<serde_json::Value>| {
308        let dispatch = dispatch.clone();
309        async move {
310            let result = handle_runtime_payload(event.payload, event.context, dispatch).await?;
311
312            match event_log_level(result.event_type) {
313                Some(level) if level == tracing::Level::WARN => {
314                    tracing::warn!(
315                        event_type = result.event_type,
316                        "Received unrecognized Lambda invocation payload"
317                    );
318                }
319                Some(_) => {
320                    tracing::debug!(
321                        event_type = result.event_type,
322                        "Acknowledging streaming completion"
323                    );
324                }
325                None => {}
326            }
327
328            Ok::<_, lambda_http::Error>(result.response)
329        }
330    }))
331    .await
332}
333
334/// Body adapter that guarantees at least one data frame is yielded.
335///
336/// Lambda Response Streaming framing requires the multipart body stream
337/// to produce at least one chunk before EOF. If the wrapped body yields
338/// zero data frames (e.g. `Empty::new()`, or `Full::new(Bytes::new())`
339/// after `Full` short-circuits, or any body whose first poll returns
340/// `None`), the Runtime API request body terminates without matching
341/// the framing contract, the connection closes with `hyper::Error`
342/// `IncompleteMessage`, and AWS reports the invocation as a 60-second
343/// timeout. API Gateway then emits 502 to the client.
344///
345/// This adapter is invisible for bodies that already produce ≥1 data
346/// frame. For zero-frame bodies, it emits one zero-length `Bytes` data
347/// frame, which satisfies the multipart framing contract without
348/// changing the visible response body.
349///
350/// See ADR-026.
351struct EnsureOneFrame<B> {
352    inner: B,
353    /// Tracks whether we have observed (or fabricated) the first data
354    /// frame, so we only inject the fallback once and only if needed.
355    first_frame_state: FirstFrameState,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359enum FirstFrameState {
360    /// Haven't yet seen a frame from the underlying body. If the
361    /// underlying poll returns `Ready(None)`, we inject the fallback.
362    Initial,
363    /// At least one frame (real or fallback-injected) has been emitted
364    /// downstream. Subsequent polls forward unchanged.
365    Done,
366    /// We injected the fallback frame on the last poll; the next poll
367    /// must return `Ready(None)` to terminate the stream.
368    FallbackEmitted,
369}
370
371impl<B> http_body::Body for EnsureOneFrame<B>
372where
373    B: http_body::Body<Data = bytes::Bytes> + Unpin,
374{
375    type Data = bytes::Bytes;
376    type Error = B::Error;
377
378    fn poll_frame(
379        mut self: std::pin::Pin<&mut Self>,
380        cx: &mut std::task::Context<'_>,
381    ) -> std::task::Poll<Option<std::result::Result<http_body::Frame<Self::Data>, Self::Error>>>
382    {
383        use std::task::Poll;
384        match self.first_frame_state {
385            FirstFrameState::FallbackEmitted => Poll::Ready(None),
386            FirstFrameState::Done => std::pin::Pin::new(&mut self.inner).poll_frame(cx),
387            FirstFrameState::Initial => {
388                match std::pin::Pin::new(&mut self.inner).poll_frame(cx) {
389                    Poll::Pending => Poll::Pending,
390                    Poll::Ready(Some(Ok(frame))) => {
391                        self.first_frame_state = FirstFrameState::Done;
392                        Poll::Ready(Some(Ok(frame)))
393                    }
394                    Poll::Ready(Some(Err(e))) => {
395                        // An error before any frame is yielded still satisfies
396                        // the "we have something to send" contract from
397                        // lambda_runtime's perspective — let the error
398                        // propagate as-is. No fallback needed.
399                        self.first_frame_state = FirstFrameState::Done;
400                        Poll::Ready(Some(Err(e)))
401                    }
402                    Poll::Ready(None) => {
403                        self.first_frame_state = FirstFrameState::FallbackEmitted;
404                        Poll::Ready(Some(Ok(http_body::Frame::data(bytes::Bytes::new()))))
405                    }
406                }
407            }
408        }
409    }
410
411    fn is_end_stream(&self) -> bool {
412        match self.first_frame_state {
413            FirstFrameState::Initial => false,
414            FirstFrameState::Done => self.inner.is_end_stream(),
415            FirstFrameState::FallbackEmitted => true,
416        }
417    }
418
419    fn size_hint(&self) -> http_body::SizeHint {
420        // Defer to inner. The fallback frame is zero bytes so adds nothing
421        // to the upper/lower bound; consumers reading `size_hint` are
422        // typically only concerned with content-length, which is irrelevant
423        // to streaming framing.
424        self.inner.size_hint()
425    }
426}
427
428/// Convert an HTTP response into a Lambda `StreamResponse`.
429///
430/// Replicates `lambda_http::streaming::into_stream_response` (which is private)
431/// by extracting status/headers/cookies into `MetadataPrelude` and converting
432/// the body into a `Stream`.
433///
434/// The body is wrapped in [`EnsureOneFrame`] to guarantee the resulting
435/// stream yields at least one chunk — Lambda Response Streaming framing
436/// requires this. See ADR-026.
437fn into_lambda_stream_response<B>(
438    response: http::Response<B>,
439) -> lambda_runtime::StreamResponse<http_body_util::BodyDataStream<EnsureOneFrame<B>>>
440where
441    B: http_body::Body<Data = bytes::Bytes> + Unpin + Send + 'static,
442{
443    let (parts, body) = response.into_parts();
444    let mut headers = parts.headers;
445
446    // Extract Set-Cookie headers into the cookies vec (Lambda streaming protocol)
447    let cookies = headers
448        .get_all(http::header::SET_COOKIE)
449        .iter()
450        .map(|c| String::from_utf8_lossy(c.as_bytes()).to_string())
451        .collect::<Vec<_>>();
452    headers.remove(http::header::SET_COOKIE);
453
454    let body = EnsureOneFrame {
455        inner: body,
456        first_frame_state: FirstFrameState::Initial,
457    };
458
459    lambda_runtime::StreamResponse {
460        metadata_prelude: lambda_runtime::MetadataPrelude {
461            headers,
462            status_code: parts.status,
463            cookies,
464        },
465        stream: http_body_util::BodyDataStream::new(body),
466    }
467}
468
469/// Build an empty 200 response for acknowledging completion invocations.
470fn empty_streaming_response()
471-> http::Response<http_body_util::combinators::UnsyncBoxBody<bytes::Bytes, hyper::Error>> {
472    use http_body_util::{BodyExt, Full};
473    let body = Full::new(bytes::Bytes::new())
474        .map_err(|e: std::convert::Infallible| match e {})
475        .boxed_unsync();
476    http::Response::builder().status(200).body(body).unwrap()
477}
478
479#[cfg(test)]
480mod streaming_completion_tests {
481    use super::*;
482    use serde_json::json;
483
484    /// Load a test fixture from `src/fixtures/` via `include_str!`.
485    /// Compile-time verified — missing files cause a build error.
486    fn load_fixture(name: &str) -> serde_json::Value {
487        let json_str = match name {
488            "apigw_v1" => include_str!("fixtures/apigw_v1_proxy_event.json"),
489            "apigw_v2" => include_str!("fixtures/apigw_v2_http_api_event.json"),
490            "completion_success" => include_str!("fixtures/streaming_completion_success.json"),
491            "completion_failure" => include_str!("fixtures/streaming_completion_failure.json"),
492            "completion_extra" => include_str!("fixtures/streaming_completion_extra_fields.json"),
493            "completion_api_like" => {
494                include_str!("fixtures/completion_with_api_like_fields.json")
495            }
496            other => panic!("Unknown fixture: {other}"),
497        };
498        serde_json::from_str(json_str).unwrap_or_else(|e| panic!("Bad fixture {name}: {e}"))
499    }
500
501    // ── Fixture tests: API Gateway events → ApiGatewayEvent ──
502
503    #[test]
504    fn test_classify_api_gateway_v1_event() {
505        let payload = load_fixture("apigw_v1");
506        assert!(
507            matches!(
508                classify_runtime_event(payload),
509                RuntimeEventClassification::ApiGatewayEvent(_)
510            ),
511            "API Gateway v1 proxy event must classify as ApiGatewayEvent"
512        );
513    }
514
515    #[test]
516    fn test_classify_api_gateway_v2_event() {
517        let payload = load_fixture("apigw_v2");
518        assert!(
519            matches!(
520                classify_runtime_event(payload),
521                RuntimeEventClassification::ApiGatewayEvent(_)
522            ),
523            "API Gateway v2 HTTP API event must classify as ApiGatewayEvent"
524        );
525    }
526
527    // ── Fixture tests: Streaming completion → StreamingCompletion ──
528
529    #[test]
530    fn test_classify_streaming_completion() {
531        let payload = load_fixture("completion_success");
532        assert!(matches!(
533            classify_runtime_event(payload),
534            RuntimeEventClassification::StreamingCompletion
535        ));
536    }
537
538    #[test]
539    fn test_classify_completion_failure_status() {
540        let payload = load_fixture("completion_failure");
541        assert!(matches!(
542            classify_runtime_event(payload),
543            RuntimeEventClassification::StreamingCompletion
544        ));
545    }
546
547    #[test]
548    fn test_classify_completion_extra_fields() {
549        let payload = load_fixture("completion_extra");
550        assert!(matches!(
551            classify_runtime_event(payload),
552            RuntimeEventClassification::StreamingCompletion
553        ));
554    }
555
556    // ── R5: Precedence edge case ──
557
558    #[test]
559    fn test_classify_completion_with_api_like_fields() {
560        // Intentional: prefer false-positive ack over retries.
561        // A payload with invokeCompletionStatus + partial API Gateway fields
562        // is classified as StreamingCompletion (not UnrecognizedEvent),
563        // because invokeCompletionStatus is the discriminator.
564        //
565        // NOTE: This fixture is intentionally NOT a valid API Gateway event.
566        // Do not "fix" it into one — that would change the expected classification.
567        let payload = load_fixture("completion_api_like");
568        assert!(matches!(
569            classify_runtime_event(payload),
570            RuntimeEventClassification::StreamingCompletion
571        ));
572    }
573
574    // ── Inline tests: Unrecognized payloads → UnrecognizedEvent ──
575
576    #[test]
577    fn test_classify_empty_object() {
578        assert!(matches!(
579            classify_runtime_event(json!({})),
580            RuntimeEventClassification::UnrecognizedEvent
581        ));
582    }
583
584    #[test]
585    fn test_classify_random_object() {
586        assert!(matches!(
587            classify_runtime_event(json!({"foo": "bar", "baz": 123})),
588            RuntimeEventClassification::UnrecognizedEvent
589        ));
590    }
591
592    #[test]
593    fn test_classify_null_payload() {
594        assert!(matches!(
595            classify_runtime_event(json!(null)),
596            RuntimeEventClassification::UnrecognizedEvent
597        ));
598    }
599
600    #[test]
601    fn test_classify_string_payload() {
602        assert!(matches!(
603            classify_runtime_event(json!("hello")),
604            RuntimeEventClassification::UnrecognizedEvent
605        ));
606    }
607
608    #[test]
609    fn test_classify_array_payload() {
610        assert!(matches!(
611            classify_runtime_event(json!([1, 2, 3])),
612            RuntimeEventClassification::UnrecognizedEvent
613        ));
614    }
615
616    #[test]
617    fn test_classify_nested_invoke_status() {
618        // invokeCompletionStatus must be at top level to match
619        let payload = json!({
620            "data": {"invokeCompletionStatus": "Success"}
621        });
622        assert!(matches!(
623            classify_runtime_event(payload),
624            RuntimeEventClassification::UnrecognizedEvent
625        ));
626    }
627
628    // ── Property-style tests ──
629
630    #[test]
631    fn test_classify_never_panics_on_arbitrary_json() {
632        // R4: Only assert no panics — no brittle !ApiGatewayEvent assertion.
633        let payloads = vec![
634            json!(null),
635            json!(true),
636            json!(false),
637            json!(42),
638            json!(-1.5),
639            json!(""),
640            json!("some string"),
641            json!([]),
642            json!([1, "two", null, false]),
643            json!({}),
644            json!({"a": 1}),
645            json!({"requestContext": null}),
646            json!({"requestContext": "not-an-object"}),
647            json!({"httpMethod": "POST"}),
648            json!({"version": "2.0"}),
649            json!({"version": "2.0", "routeKey": "GET /"}),
650            json!({"resource": "/", "httpMethod": "GET"}),
651            json!({"deeply": {"nested": {"invokeCompletionStatus": "Success"}}}),
652            // Large payload
653            serde_json::Value::Object((0..100).map(|i| (format!("key_{i}"), json!(i))).collect()),
654        ];
655
656        for payload in payloads {
657            let _result = classify_runtime_event(payload);
658        }
659    }
660
661    #[test]
662    fn test_classify_invoke_completion_status_always_wins() {
663        // Any object with top-level invokeCompletionStatus that doesn't parse as API GW
664        // should be classified as StreamingCompletion
665        let payloads = vec![
666            json!({"invokeCompletionStatus": "Success"}),
667            json!({"invokeCompletionStatus": "Failure"}),
668            json!({"invokeCompletionStatus": "Unknown"}),
669            json!({"invokeCompletionStatus": 42}),
670            json!({"invokeCompletionStatus": null}),
671            json!({"invokeCompletionStatus": "Success", "requestId": "abc-123"}),
672            json!({"invokeCompletionStatus": "Success", "extra": "field", "nested": {"a": 1}}),
673        ];
674
675        for payload in payloads {
676            let result = classify_runtime_event(payload.clone());
677            assert!(
678                matches!(result, RuntimeEventClassification::StreamingCompletion),
679                "Payload with top-level invokeCompletionStatus must be StreamingCompletion: {payload}"
680            );
681        }
682    }
683
684    // ── event_log_level contract tests (R1) ──
685
686    #[test]
687    fn test_unrecognized_logs_at_warn_level() {
688        assert_eq!(
689            event_log_level("unrecognized_lambda_payload"),
690            Some(tracing::Level::WARN)
691        );
692    }
693
694    #[test]
695    fn test_completion_logs_at_debug_level() {
696        assert_eq!(
697            event_log_level("streaming_completion"),
698            Some(tracing::Level::DEBUG)
699        );
700    }
701
702    #[test]
703    fn test_api_gateway_has_no_extra_logging() {
704        assert_eq!(event_log_level("api_gateway_event"), None);
705    }
706
707    // ── handle_runtime_payload action-path tests (R3) ──
708
709    #[tokio::test]
710    async fn test_handle_completion_does_not_dispatch() {
711        let dispatched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
712        let dispatched_clone = dispatched.clone();
713
714        let result = handle_runtime_payload(
715            load_fixture("completion_success"),
716            lambda_runtime::Context::default(),
717            |_req| {
718                let d = dispatched_clone.clone();
719                async move {
720                    d.store(true, std::sync::atomic::Ordering::SeqCst);
721                    Ok(empty_streaming_response())
722                }
723            },
724        )
725        .await
726        .expect("handle should succeed");
727
728        assert!(
729            !dispatched.load(std::sync::atomic::Ordering::SeqCst),
730            "Completion events must not dispatch to handler"
731        );
732        assert_eq!(result.event_type, "streaming_completion");
733        assert_eq!(result.response.metadata_prelude.status_code, 200);
734    }
735
736    #[tokio::test]
737    async fn test_handle_unrecognized_does_not_dispatch() {
738        let dispatched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
739        let dispatched_clone = dispatched.clone();
740
741        let result = handle_runtime_payload(
742            json!({"foo": "bar"}),
743            lambda_runtime::Context::default(),
744            |_req| {
745                let d = dispatched_clone.clone();
746                async move {
747                    d.store(true, std::sync::atomic::Ordering::SeqCst);
748                    Ok(empty_streaming_response())
749                }
750            },
751        )
752        .await
753        .expect("handle should succeed");
754
755        assert!(
756            !dispatched.load(std::sync::atomic::Ordering::SeqCst),
757            "Unrecognized events must not dispatch to handler"
758        );
759        assert_eq!(result.event_type, "unrecognized_lambda_payload");
760        assert_eq!(result.response.metadata_prelude.status_code, 200);
761    }
762
763    #[tokio::test]
764    async fn test_handle_apigw_v1_dispatches() {
765        let dispatched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
766        let dispatched_clone = dispatched.clone();
767
768        let result = handle_runtime_payload(
769            load_fixture("apigw_v1"),
770            lambda_runtime::Context::default(),
771            |_req| {
772                let d = dispatched_clone.clone();
773                async move {
774                    d.store(true, std::sync::atomic::Ordering::SeqCst);
775                    Ok(empty_streaming_response())
776                }
777            },
778        )
779        .await
780        .expect("handle should succeed");
781
782        assert!(
783            dispatched.load(std::sync::atomic::Ordering::SeqCst),
784            "API Gateway v1 events must dispatch to handler"
785        );
786        assert_eq!(result.event_type, "api_gateway_event");
787    }
788
789    #[tokio::test]
790    async fn test_handle_apigw_v2_dispatches() {
791        let dispatched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
792        let dispatched_clone = dispatched.clone();
793
794        let result = handle_runtime_payload(
795            load_fixture("apigw_v2"),
796            lambda_runtime::Context::default(),
797            |_req| {
798                let d = dispatched_clone.clone();
799                async move {
800                    d.store(true, std::sync::atomic::Ordering::SeqCst);
801                    Ok(empty_streaming_response())
802                }
803            },
804        )
805        .await
806        .expect("handle should succeed");
807
808        assert!(
809            dispatched.load(std::sync::atomic::Ordering::SeqCst),
810            "API Gateway v2 events must dispatch to handler"
811        );
812        assert_eq!(result.event_type, "api_gateway_event");
813    }
814
815    #[tokio::test]
816    async fn test_handle_unrecognized_surfaces_distinct_event_type() {
817        let result = handle_runtime_payload(
818            json!({"unknown": true}),
819            lambda_runtime::Context::default(),
820            |_req| async { Ok(empty_streaming_response()) },
821        )
822        .await
823        .expect("handle should succeed");
824
825        assert_eq!(result.event_type, "unrecognized_lambda_payload");
826    }
827
828    // ── Existing response conversion tests ──
829
830    #[test]
831    fn test_empty_streaming_response() {
832        let resp = empty_streaming_response();
833        assert_eq!(resp.status(), 200);
834    }
835
836    #[test]
837    fn test_into_lambda_stream_response_preserves_metadata() {
838        use http_body_util::{BodyExt, Full};
839
840        let response = http::Response::builder()
841            .status(401)
842            .header("WWW-Authenticate", "Bearer realm=\"mcp\"")
843            .header("X-Custom", "test")
844            .body(
845                Full::new(bytes::Bytes::from("Unauthorized"))
846                    .map_err(|e: std::convert::Infallible| match e {})
847                    .boxed_unsync(),
848            )
849            .unwrap();
850
851        let stream_resp = into_lambda_stream_response(response);
852        assert_eq!(stream_resp.metadata_prelude.status_code, 401);
853        assert_eq!(
854            stream_resp
855                .metadata_prelude
856                .headers
857                .get("WWW-Authenticate")
858                .unwrap(),
859            "Bearer realm=\"mcp\""
860        );
861        assert_eq!(
862            stream_resp
863                .metadata_prelude
864                .headers
865                .get("X-Custom")
866                .unwrap(),
867            "test"
868        );
869    }
870
871    #[test]
872    fn test_into_lambda_stream_response_extracts_cookies() {
873        use http_body_util::{BodyExt, Full};
874
875        let response = http::Response::builder()
876            .status(200)
877            .header("Set-Cookie", "session=abc; Path=/")
878            .header("Set-Cookie", "theme=dark")
879            .body(
880                Full::new(bytes::Bytes::new())
881                    .map_err(|e: std::convert::Infallible| match e {})
882                    .boxed_unsync(),
883            )
884            .unwrap();
885
886        let stream_resp = into_lambda_stream_response(response);
887        assert_eq!(stream_resp.metadata_prelude.cookies.len(), 2);
888        assert!(
889            stream_resp
890                .metadata_prelude
891                .cookies
892                .contains(&"session=abc; Path=/".to_string())
893        );
894        assert!(
895            stream_resp
896                .metadata_prelude
897                .cookies
898                .contains(&"theme=dark".to_string())
899        );
900        assert!(
901            stream_resp
902                .metadata_prelude
903                .headers
904                .get("Set-Cookie")
905                .is_none()
906        );
907    }
908
909    // ── run_streaming_with dispatch tests ──
910
911    #[tokio::test]
912    async fn test_run_streaming_with_dispatches_apigw_events() {
913        let dispatched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
914        let dispatched_clone = dispatched.clone();
915
916        let dispatch = move |_req: lambda_http::Request| {
917            let d = dispatched_clone.clone();
918            async move {
919                d.store(true, std::sync::atomic::Ordering::SeqCst);
920                Ok(empty_streaming_response())
921            }
922        };
923
924        let result = handle_runtime_payload(
925            load_fixture("apigw_v1"),
926            lambda_runtime::Context::default(),
927            dispatch,
928        )
929        .await
930        .expect("handle should succeed");
931
932        assert!(
933            dispatched.load(std::sync::atomic::Ordering::SeqCst),
934            "run_streaming_with dispatch must be called for API Gateway events"
935        );
936        assert_eq!(result.event_type, "api_gateway_event");
937    }
938
939    // ── Empty-body Lambda streaming envelope contract ──
940    //
941    // `run_streaming_with` accepts any `Response<B>` where `B: Body + Unpin
942    // + Send + 'static`. Consumers (notably custom OPTIONS short-circuits
943    // for `.well-known` routes) construct empty-body responses two ways:
944    //
945    //   - `Empty::new()`              — zero data frames, immediate end-of-stream
946    //   - `Full::new(Bytes::new())`   — at most one zero-byte data frame
947    //
948    // `BodyDataStream` adapts a `Body` into a `Stream<Item = Bytes>`,
949    // yielding only Data frames (Trailers frames are dropped). When the
950    // underlying body produces zero data frames, the resulting Lambda
951    // `StreamResponse` carries a stream that completes without ever
952    // yielding an item — the Runtime API multipart streaming framing
953    // never sees a body chunk, the request body terminates without
954    // matching the expected framing, and the Runtime API connection
955    // closes with `IncompleteMessage`. Production symptom: APIGW 502
956    // after the function timeout while the dispatch closure already
957    // returned `Ok(resp)`.
958    //
959    // Contract these tests enforce (ADR-026): the framework MUST produce
960    // a Lambda streaming response whose `BodyDataStream` yields at least
961    // one data frame for any input `Response<B>`, including bodies that
962    // would otherwise produce zero data frames natively.
963
964    async fn drain_stream<S, B>(
965        stream: lambda_runtime::StreamResponse<S>,
966    ) -> (u16, Vec<bytes::Bytes>)
967    where
968        S: futures::Stream<Item = std::result::Result<bytes::Bytes, B>> + Unpin,
969    {
970        use futures::StreamExt;
971        let status = stream.metadata_prelude.status_code.as_u16();
972        let mut frames = Vec::new();
973        let mut s = stream.stream;
974        while let Some(item) = s.next().await {
975            frames.push(item.unwrap_or_else(|_| bytes::Bytes::new()));
976        }
977        (status, frames)
978    }
979
980    /// Adapter-level: an `Empty::new()` body must still produce at least
981    /// one data frame after passing through `into_lambda_stream_response`.
982    /// This is the smallest possible repro of the production failure.
983    #[tokio::test]
984    async fn test_into_lambda_stream_response_empty_body_yields_data_frame() {
985        use http_body_util::{BodyExt, Empty};
986
987        let body = Empty::<bytes::Bytes>::new()
988            .map_err(|_: std::convert::Infallible| -> hyper::Error { unreachable!() })
989            .boxed_unsync();
990        let response = http::Response::builder()
991            .status(204)
992            .header("Access-Control-Allow-Origin", "*")
993            .header("Access-Control-Allow-Methods", "GET, OPTIONS")
994            .body(body)
995            .unwrap();
996
997        let stream = into_lambda_stream_response(response);
998        let (status, frames) = drain_stream(stream).await;
999
1000        assert_eq!(status, 204);
1001        assert!(
1002            !frames.is_empty(),
1003            "empty-body streaming response must yield at least one data frame \
1004             (got zero frames — Runtime API will see IncompleteMessage); \
1005             frames: {:?}",
1006            frames,
1007        );
1008    }
1009
1010    /// Service-fn-level: mirror sd-mcp's exact dispatch shape — custom
1011    /// OPTIONS short-circuit returning `Response<UnsyncBoxBody>` with
1012    /// `Empty::new()` body — through `handle_runtime_payload` (which is
1013    /// what `run_streaming_with` wraps around the dispatch closure).
1014    /// Production failure repros here if (and only if) the adapter
1015    /// layer doesn't guarantee at least one data frame.
1016    #[tokio::test]
1017    async fn test_run_streaming_with_empty_body_dispatch_yields_data_frame() {
1018        let dispatch = |_req: lambda_http::Request| async move {
1019            use http_body_util::{BodyExt, Empty};
1020            let body = Empty::<bytes::Bytes>::new()
1021                .map_err(|_: std::convert::Infallible| -> hyper::Error { unreachable!() })
1022                .boxed_unsync();
1023            let resp = http::Response::builder()
1024                .status(204)
1025                .header("Access-Control-Allow-Origin", "https://client.example.test")
1026                .header("Access-Control-Allow-Methods", "GET, OPTIONS")
1027                .header("Access-Control-Allow-Headers", "Content-Type")
1028                .body(body)
1029                .unwrap();
1030            Ok::<_, lambda_http::Error>(resp)
1031        };
1032
1033        let result = handle_runtime_payload(
1034            load_fixture("apigw_v2"),
1035            lambda_runtime::Context::default(),
1036            dispatch,
1037        )
1038        .await
1039        .expect("handle_runtime_payload must succeed");
1040
1041        assert_eq!(result.event_type, "api_gateway_event");
1042        let (status, frames) = drain_stream(result.response).await;
1043        assert_eq!(status, 204);
1044        assert!(
1045            !frames.is_empty(),
1046            "run_streaming_with dispatch returning Empty::new() body must yield \
1047             at least one data frame through the envelope; got zero — this is \
1048             the production .well-known OPTIONS 502 / IncompleteMessage path; \
1049             frames: {:?}",
1050            frames,
1051        );
1052    }
1053
1054    /// Negative control: a non-empty body must of course pass through.
1055    /// Catches a faulty fix that suppresses all data frames.
1056    #[tokio::test]
1057    async fn test_into_lambda_stream_response_non_empty_body_preserves_bytes() {
1058        use http_body_util::{BodyExt, Full};
1059
1060        let payload = bytes::Bytes::from_static(b"hello");
1061        let body = Full::new(payload.clone())
1062            .map_err(|_: std::convert::Infallible| -> hyper::Error { unreachable!() })
1063            .boxed_unsync();
1064        let response = http::Response::builder().status(200).body(body).unwrap();
1065
1066        let stream = into_lambda_stream_response(response);
1067        let (status, frames) = drain_stream(stream).await;
1068        assert_eq!(status, 200);
1069        let joined: Vec<u8> = frames.iter().flat_map(|f| f.iter().copied()).collect();
1070        assert_eq!(
1071            joined,
1072            payload.to_vec(),
1073            "non-empty body must round-trip its bytes through the envelope",
1074        );
1075    }
1076
1077    #[tokio::test]
1078    async fn test_run_streaming_with_acks_completion_without_dispatch() {
1079        let dispatched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1080        let dispatched_clone = dispatched.clone();
1081
1082        let dispatch = move |_req: lambda_http::Request| {
1083            let d = dispatched_clone.clone();
1084            async move {
1085                d.store(true, std::sync::atomic::Ordering::SeqCst);
1086                Ok(empty_streaming_response())
1087            }
1088        };
1089
1090        let result = handle_runtime_payload(
1091            load_fixture("completion_success"),
1092            lambda_runtime::Context::default(),
1093            dispatch,
1094        )
1095        .await
1096        .expect("handle should succeed");
1097
1098        assert!(
1099            !dispatched.load(std::sync::atomic::Ordering::SeqCst),
1100            "run_streaming_with dispatch must NOT be called for completion events"
1101        );
1102        assert_eq!(result.event_type, "streaming_completion");
1103        assert_eq!(result.response.metadata_prelude.status_code, 200);
1104    }
1105}