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.

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

: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

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), 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:

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 and the application stack 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.