# `ConduitMcp.Client.Transport.Stdio`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/transport/stdio.ex#L1)

A `ConduitMcp.Client.Transport` that runs a local MCP server as an
operating-system process and speaks newline-delimited JSON-RPC over its
stdin and stdout (the MCP stdio transport).

    {ConduitMcp.Client,
     name: :files,
     transport:
       {ConduitMcp.Client.Transport.Stdio,
        command: "npx",
        args: ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"],
        env: %{"NODE_OPTIONS" => "--max-old-space-size=256"}}}

`kind/0` is `:stdio`: protocol discovery follows the stdio rules (a
timeout or a non-modern error means a legacy server) and `:probe_timeout`
defaults to 3 s. Cancelling a request writes `notifications/cancelled` in
both eras.

Stdio configuration is **operator configuration**: whoever chooses
`:command`, `:args` and `:env` runs code as this VM's user. Never build
them from model output, server responses or end-user input.

## The process

The server is started with `Port.open({:spawn_executable, path}, ...)`:
the executable is run directly, never through a shell, so arguments are
not expanded or split. Options are checked when the client starts
(`ConduitMcp.Client.Config.new!/1` raises naming the key); the executable
is resolved on every connection attempt.

A port owner process, linked to the connection, owns the port. It writes
without ever suspending (a server that stops reading stdin cannot stall
it or the connection; at most 16 MiB of unwritten messages are held
before the transport closes with `:write_queue_full`), splits stdout into
lines with `ConduitMcp.Client.Transport.Stdio.Framer`, decodes them and
passes the messages to the connection.

When the server exits, the transport reports `{:closed, {:exit_status,
status}}` and the connection reconnects, starting a new server (with its
backoff and `:max_reconnects`). Requests in flight fail and are not
retried.

## Shutdown

When the transport closes (the client stops, reconnects or gives up, or
the connection process dies, even by `:kill`) the port owner:

  1. closes the port, which closes the server's stdin;
  2. polls `kill -0 <os_pid>` until the server exits, for `:stdin_grace`;
  3. sends `kill -TERM`, and polls again for `:term_grace`;
  4. sends `kill -KILL`.

On Windows step 3 is skipped and step 4 is `taskkill /PID <os_pid> /T /F`.
The signals go through `System.cmd/3`; there is no shell. This runs in
the port owner after it detaches from the connection, so neither a
reconnect nor the client's shutdown waits for it.

Limits:

  * Launchers such as `npx` and `uvx` start the real server as a child.
    Closing stdin normally ends both, but the signals reach only the
    launcher's pid, so a grandchild that ignores EOF can outlive it.
  * If the port owner itself is killed (`Process.exit(pid, :kill)`, or the
    VM halting, or an application stop killing the process while it is
    still escalating), only step 1 happens: a server that ignores EOF
    keeps running. Run such servers under a supervisor that kills on
    parent exit (a container, `systemd-run`, MuonTrap) if that matters.
  * The pid is signalled after the port is closed, so pid reuse in that
    window is possible in theory.

## Options

* `:command` - Required. The executable: a name looked up in `PATH` with
  `System.find_executable/1` when the client connects, or an
  absolute path. It must not contain whitespace - arguments go
  in `:args` - so `"npx -y server"` raises.

* `:args` (list of `t:String.t/0`) - The arguments, passed to the executable as they are. No shell
  sees them, so `"; rm -rf ~"` arrives as one literal argument. The default value is `[]`.

* `:env` - Variables to set in the server's environment, as a map or a
  list of `{name, value}` string pairs. A value may be a
  `ConduitMcp.Client.Secret` (exposed only when the process is
  spawned). The server inherits **only** the allowlisted
  variables of this VM's environment (`HOME LOGNAME PATH SHELL
  TERM USER`; on Windows `APPDATA HOMEDRIVE HOMEPATH LOCALAPPDATA
  PATH PATHEXT PROCESSOR_ARCHITECTURE SYSTEMDRIVE SYSTEMROOT TEMP
  USERNAME USERPROFILE PROGRAMFILES`), minus any whose value
  starts with `()` (an exported shell function); every other
  inherited variable is removed. `:env` wins over inherited
  values. The default value is `[]`.

* `:cd` - The server's working directory: an absolute path to an
  existing directory. Defaults to this VM's working directory. The default value is `nil`.

* `:stderr` - Where the server's stderr goes: `:inherit` (this VM's stderr),
  `:discard`, or `{:file, absolute_path}` (appended). `:discard`
  and `{:file, path}` are POSIX only: the server is started as
  `/bin/sh -c 'f=$1; shift; exec "$@" 2>>"$f"' conduit_mcp PATH
  EXECUTABLE ARGS...`, where the path, the executable and the
  arguments are positional parameters that the shell never
  parses, and `exec` keeps the server's pid. stderr is never
  merged into stdout, and never captured into `Logger`. The default value is `:inherit`.

* `:allow_batch_files` (`t:boolean/0`) - Allow a `.bat` or `.cmd` executable. They are refused by
  default because Windows runs them through `cmd.exe`, which
  re-parses their arguments (BatBadBut); when allowed, an
  argument containing any of `"%^&|<>` is still refused. On
  Windows, launchers such as `npx` resolve to `npx.cmd`. The default value is `false`.

* `:max_message_bytes` (`t:pos_integer/0`) - The largest message the server may write, in bytes. A longer
  line closes the transport with `:message_too_large` and stops
  the server. The default value is `8388608`.

* `:max_junk_lines` (`t:non_neg_integer/0`) - How many lines in a row that are not JSON-RPC messages are
  tolerated (each is logged at debug level and dropped). One more
  closes the transport with `:junk_output`, and the connection
  restarts the server. The default value is `10`.

* `:stdin_grace` (`t:non_neg_integer/0`) - Milliseconds the server gets to exit after its stdin is closed
  before it is sent `SIGTERM` (on Windows, killed). The default value is `2000`.

* `:term_grace` (`t:non_neg_integer/0`) - Milliseconds the server gets after `SIGTERM` before `SIGKILL`. The default value is `2000`.

---

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