Snodo.Transport.Plug (snodo_plug v0.3.0)

Copy Markdown View Source

Optional Plug binding for the stateless Streamable HTTP adapter.

The application supplies an immutable :runtime and a supervised :executor. This Plug starts no listener or global runtime. Install it before body parsers, after the application's authentication Plug. Only conn.assigns[:mcp_auth] supplies Snodo.Context.auth; request headers and JSON never supply identity.

Requests are admitted before entering the bounded executor. Valid cancellation notifications bypass that queue. Cross-request cancellation is opt-in through the trusted :mcp_cancellation_scope assign, combined with the complete auth value; applications must distinguish authenticated client instances in this scope. Anonymous requests cannot cancel each other by guessing request IDs.

Portable Plug APIs only reveal a disconnect when a write fails. So that a client disconnect cancels a silent handler, as the 2026-07-28 cancellation rules require, a request still running after :disconnect_probe_ms (default 5,000) turns its response into an event stream and sends a keepalive comment every interval; a failed write cancels the work, and the result arrives as the stream's final event. :infinity keeps plain JSON responses. A finite total :request_timeout bounds pending work and queue wait.

At most :max_subscriptions (default 256) subscriptions/listen streams are open at once. init/1 can run at compile time, so the count is held in the executor, which lives as long as the runtime; Plugs that share an executor share the count. A stream over the limit is closed at its source and the request gets 503.

The request body must arrive within :body_timeout (default 10,000 ms from when this Plug starts reading it) or the request gets 408. The Plug reads the body in slices of at most 64 KiB and checks the deadline between them; each read waits at most :read_timeout (default 5,000) and never past the deadline. Under Bandit a Content-Length body is one socket read per slice, so the deadline bounds the whole body. A request that declares Transfer-Encoding gets 411 without its body being read: an adapter returns from a chunked read only once it has the requested bytes, however many socket reads that takes, so no deadline checked between reads can bound it. Over HTTP/2, Bandit returns from a read after a slice of DATA, at the end of the stream, or after :read_timeout without a frame, and each DATA frame restarts that wait, so a client that keeps sending small or empty frames can hold the request process past the deadline. After a body error over HTTP/1.x the response closes the connection, so the adapter does not read the rest of the body.