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 shapingExecutor
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/jsonfor ordinary results.text/event-streamwhen a handler reports progress, and forsubscriptions/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_timeoutstill bounds each read.:max_subscriptions(default 256). Asubscriptions/listenstream 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.