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:
- closes the port, which closes the server's stdin;
- polls
kill -0 <os_pid>until the server exits, for:stdin_grace; - sends
kill -TERM, and polls again for:term_grace; - 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
npxanduvxstart 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 inPATHwithSystem.find_executable/1when the client connects, or an absolute path. It must not contain whitespace - arguments go in:args- so"npx -y server"raises.:args(list ofString.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 aConduitMcp.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 WindowsAPPDATA 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.:envwins 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 isnil.:stderr- Where the server's stderr goes::inherit(this VM's stderr),:discard, or{:file, absolute_path}(appended).:discardand{: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, andexeckeeps the server's pid. stderr is never merged into stdout, and never captured intoLogger. The default value is:inherit.:allow_batch_files(boolean/0) - Allow a.bator.cmdexecutable. They are refused by default because Windows runs them throughcmd.exe, which re-parses their arguments (BatBadBut); when allowed, an argument containing any of"%^&|<>is still refused. On Windows, launchers such asnpxresolve tonpx.cmd. The default value isfalse.:max_message_bytes(pos_integer/0) - The largest message the server may write, in bytes. A longer line closes the transport with:message_too_largeand stops the server. The default value is8388608.: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 is10.:stdin_grace(non_neg_integer/0) - Milliseconds the server gets to exit after its stdin is closed before it is sentSIGTERM(on Windows, killed). The default value is2000.:term_grace(non_neg_integer/0) - Milliseconds the server gets afterSIGTERMbeforeSIGKILL. The default value is2000.