# `Snodo.Transport.Stdio`
[🔗](https://github.com/joshrotenberg/snodo/blob/v0.4.3/lib/snodo/transport/stdio.ex#L1)

Concurrent newline-delimited JSON-RPC transport.

A small coordinator owns framing and serializes complete stdout writes. Work
is admitted through the optional, transport-neutral `Snodo.Server.Executor`, so
the coordinator never awaits a handler and execution policy is reusable by
other transports.

`:write_timeout` is a positive millisecond limit, defaulting to 5,000. A single
linked, monitored helper performs each write while the coordinator waits
boundedly; there is no writer queue. Cancellation and EOF handling can wait
up to this limit during a blocked write. A timeout or output error is terminal:
the transport cancels its work and closes subscriptions, without attempting
further writes. A timed-out device may already have accepted some bytes, so
the transport never retries. Supplied I/O devices are never stopped or killed.

`:max_line_bytes` limits one input message, counting its newline, and defaults
to 2,000,000 bytes to match the HTTP listener's `:max_body_bytes`. A longer
line is answered with -32600 and a null id without being decoded, the rest of
it is discarded, and the next line is served normally.

How much of a long line is held in memory depends on the input. The default
`:stdio` input is read in chunks that hold at most `:max_line_bytes` of a line
when the VM was started with `-noinput`, and on OTP 28 and later when standard
input is a stream socket, which is what Node.js clients provide. Any other
input is read a line at a time, and the device holds each line in full before
returning it; the VM holds a line from standard input as a character list,
many times its size. That covers a pipe on standard input, which Python and
Erlang clients provide, unless the VM was started with `-noinput`, for example
in a release's `vm.args`. With `-noinput`, nothing else in the VM can read
standard input.

An initialize-era client (2025-11-25 or 2025-06-18, when the runtime enables
those dialects) negotiates its version once with `initialize`. The transport
records the version from a successful result and gives every later message
on the connection an `mcp-protocol-version` request header, the header HTTP
requests carry, so dialect selection and validation are shared with HTTP. A
2026-07-28 client does not send `initialize`, and its messages carry no such
header.

## Application notifications

An application process can send a server-to-client notification on an open
connection with `notify/3`, for example a vendor or experimental hint while
the connection is idle. The handle is the transport's pid or registered
name: the pid returned by `start_link/1`, or the `:name` passed to `serve/2`
or `start_link/1`.

Only methods listed in the `:notification_methods` option may be sent. The
option is a list of method names, each starting with `notifications/`, and
defaults to `[]`, so nothing is sendable unless the application enables it.
The names of notifications the protocol defines, such as
`notifications/progress` and `notifications/cancelled`, are refused at
startup. The frame is `{"jsonrpc", "method", "params"}` with no id, written
through the same serialized, deadline-bound path as responses, so frames never
interleave.

`notify/3` is synchronous and unbuffered: the caller waits for its own write,
so at most one write is in flight and pending callers wait in the transport's
mailbox, each bounded by its call timeout. It returns `{:error, :closed}` if
the connection is gone, or `{:error, {:transport_down, reason}}` if the
transport stopped abnormally while the call was waiting. A failed or
timed-out write is terminal for the connection exactly as for a response,
and the caller gets `{:error, {:output_failed, reason}}`. If the caller's
own call timeout expires first it gets `{:error, :timeout}`, but the request
stays in the transport's mailbox and the frame may still be written
afterward; treat `:timeout` as "unknown". Invalid input returns
`{:error, %Snodo.Error{}}` and writes nothing. Delivery is a hint: nothing is
retried or queued across a failure.

# `state`

```elixir
@type state() :: %{
  runtime: Snodo.Server.Runtime.t(),
  input: IO.device(),
  output: IO.device(),
  writer: {IO.device(), pos_integer()},
  executor: pid(),
  executor_monitor: reference() | nil,
  serve_owner_monitor: reference() | nil,
  owns_executor?: boolean(),
  request_timeout: Snodo.Server.Executor.execution_timeout() | :default,
  reader: pid() | nil,
  connection_ref: reference(),
  executions_by_id: map(),
  executions_by_ref: map(),
  subscriptions_by_id: map(),
  subscriptions_by_worker: map(),
  max_subscriptions: pos_integer(),
  max_line_bytes: pos_integer(),
  notification_methods: [String.t()],
  negotiated_version: String.t() | nil,
  log_level: Snodo.Logging.level() | nil,
  eof?: boolean()
}
```

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `notify`

```elixir
@spec notify(GenServer.server(), String.t(), map(), timeout()) ::
  :ok
  | {:error,
     Snodo.Error.t()
     | :closed
     | :timeout
     | {:output_failed, term()}
     | {:transport_down, term()}}
```

Sends a server-to-client notification on an open connection.

`server` is the transport's pid or registered name. `method` must be listed
in the transport's `:notification_methods`; `params` is a JSON-encodable map.
See the module documentation for the failure behavior.

# `serve`

```elixir
@spec serve(Snodo.Server.Runtime.t(), keyword()) :: :ok | {:error, term()}
```

Runs the stdio transport until EOF and all admitted requests finish.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
