ConduitMcp.Client.Transport.Stdio (ConduitMCP v0.11.0)

Copy Markdown View Source

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 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 (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 (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 (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 (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 (non_neg_integer/0) - Milliseconds the server gets after SIGTERM before SIGKILL. The default value is 2000.