# Transports

The server core is synchronous: `Snodo.Server.dispatch/3` runs one JSON-RPC
request in the calling process against an immutable runtime. Transports add
framing, concurrency, cancellation, and delivery around that core.

```text
raw JSON-RPC map
  -> protocol registry and exact profile inspection
  -> selected dialect: context and admission
  -> core operation or negotiated extension route
  -> router callback (tool, resource, prompt, completion)
  -> protocol-neutral result, or an opened subscription
  -> selected dialect: wire and stream shaping
```

## Executor

`Snodo.Server.Executor` is the transport-neutral execution layer: bounded
admission, a bounded queue, deadlines, cancellation tokens, and supervised
worker tasks, with cleanup when the submitting process dies. The stdio adapter
and the HTTP listener use it; an application can inject its own executor.

## Limits on message content

Every transport decodes JSON with the same limits. An integer literal longer
than 64 digits is a parse error (-32700). A request id must be a string of at
most 256 bytes or an integer in the int64 range (-32600 otherwise), and a
`progressToken` must meet the same bounds (-32602 otherwise). These values are
echoed in every response and notification for a request, so an error for a
request whose id is out of bounds carries a null id instead.

## Stdio

```elixir
:ok = Snodo.Transport.Stdio.serve(MyServer.runtime())
```

`serve/2` runs until EOF and until admitted requests finish, then returns.
Messages are one JSON object per line. Requests run concurrently, responses are
written atomically, and `notifications/cancelled` stops a request with no late
response. Output escapes non-ASCII characters as JSON `\u` escapes, so the
bytes do not depend on the device's encoding.

Options include `:input` and `:output` devices, `:write_timeout` (default
5,000 ms; a blocked write is terminal), `:max_line_bytes` (default 2,000,000;
a longer message is refused with -32600 before decoding), `:request_timeout`,
and `:executor`. A leading UTF-8 byte order mark is ignored.

Standard input is read in chunks that hold at most `:max_line_bytes` of a line
when the VM runs with `-noinput`, and on OTP 28 and later when stdin is a
socket, as Node.js clients provide. Otherwise the VM holds each line in full
before the limit applies. Clients that start the server with a pipe, as Python
and Erlang clients do, are bounded only with `-noinput`: `elixir --erl -noinput`,
`emu_args` for an escript, or `vm.args` for a release.

Logger output is redirected away from stdout by default, because stdout carries
only protocol messages.

## Native HTTP listener

```elixir
children = [
  {Snodo.Transport.StreamableHTTP.Server, runtime: MyServer.runtime(), port: 4000}
]
```

A dependency-free listener that binds to `127.0.0.1` by default and serves
`POST /mcp`. It answers one request per connection:

- `application/json` for ordinary results.
- `text/event-stream` when a handler reports progress, and for
  `subscriptions/listen`, with keepalive comments and proxy buffering disabled.
- 405 for GET and DELETE. No session IDs are issued.

It checks media types, the mirrored `MCP-Protocol-Version`, `Mcp-Method`, and
`Mcp-Name` headers, the `Mcp-Param-*` headers for a tool's `x-mcp-header`
arguments (see [Components](components.md#arguments-in-http-headers)), and
`Origin` when present: loopback names by default,
`:allowed_origin_hosts` to change, where an entry with a port
(`"localhost:3000"`) pins the port and an Origin with userinfo is refused.
`:allowed_hosts` additionally requires the `Host` header to name a listed host.
It is off by default, because a reverse proxy commonly forwards the public name.
A client disconnect cancels the request.
Options include `:ip`, `:port`, `:path`, `:request_timeout`, `:read_timeout`,
`:max_header_bytes`, and `:max_body_bytes` (2 MB).

The listener also bounds what clients can hold open:

- `:max_connections` (default 1,024). A connection accepted at the limit is
  closed without being read.
- `:head_timeout` (default 10,000 ms from accept). The request head must be
  complete by then; `:read_timeout` (5,000 ms) still bounds each read.
- `:body_timeout` (default 10,000 ms from the end of the head). The request
  body must be complete by then; `:read_timeout` still bounds each read.
- `:max_subscriptions` (default 256). A `subscriptions/listen` stream over the
  limit is closed at its source and the request gets 503. A slot returns when
  the connection serving a stream exits, including a client disconnect.

`Snodo.Transport.StreamableHTTP.Server.url/1` returns the endpoint URL, which is
useful with `port: 0` in tests.

## Plug and Bandit

The `snodo_plug` package provides `Snodo.Transport.Plug` for applications that
already run Plug, Bandit, or Phoenix, with their own authentication pipeline,
TLS, and timeouts:

```elixir
children = [
  {Snodo.Server.Executor, name: MyApp.SnodoExecutor, max_concurrency: 32, max_queue: 128},
  {Bandit,
   plug: {Snodo.Transport.Plug, runtime: MyServer.runtime(), executor: MyApp.SnodoExecutor},
   ip: {127, 0, 0, 1},
   port: 4000}
]
```

It supports the same JSON, progress SSE, and subscription SSE lifecycles. Plug
only reveals a disconnect when a write fails, so a request still running after
`:disconnect_probe_ms` (default 5,000) switches to SSE and writes keepalive
comments; a failed write cancels the work. `:max_subscriptions` (default 256)
bounds open subscription streams; the count is held in the executor, so Plugs
that share an executor share it. `:body_timeout` (default 10,000 ms from when
the Plug starts reading) bounds the whole request body and answers 408; a
request that declares `Transfer-Encoding` gets 411 without being read. Over
HTTP/2 the deadline is checked only when an adapter read returns, so it does not
bound a client that keeps sending small DATA frames. See
the [`snodo_plug` documentation](https://hexdocs.pm/snodo_plug) and the
[application stack](application-stack.md) for choosing between the native
listener and Plug.

## Other hosts

`Snodo.Transport.StreamableHTTP.prepare/3` admits and decodes a
`Snodo.Transport.StreamableHTTP.Request` without running a handler. It returns
`{:ok, prepared}`, or `{:response, response}` when admission fails. `execute/3`
runs a prepared request and returns a `Response` or a `StreamResponse`. A
different HTTP server can translate its requests into that shape. `handle/3`
runs both steps synchronously.

## Examples

`examples/04_stdio_concurrency.exs`, `05_http_tools.exs`, `21_plug_bandit.exs`
(from `integrations/plug`), and `24_client_transports.exs`.
