Stdio transports¶
The stdio binding has two classes. StdioServerTransport serves line-framed JSON-RPC over STDIN and STDOUT.
StdioClientTransport starts a server as a subprocess and speaks the same framing. Both obey
the transport contract.
StdioServerTransport¶
use Amp\ByteStream\ReadableStream;
use Amp\ByteStream\WritableStream;
use Nexus\Mcp\Server\Transport\StdioServerTransport;
use Psr\Log\NullLogger;
$transport = new StdioServerTransport(
stdin: $stream, // optional ReadableStream; default: new ReadableResourceStream(\STDIN)
stdout: $writableStream, // optional WritableStream; default: new WritableResourceStream(\STDOUT)
logger: $psrLogger, // optional; default: new NullLogger
maxLineBytes: 4_194_304, // optional cap on a single inbound line; default 4 MiB
);
The stdin and stdout parameters take Amp\ByteStream\ReadableStream and WritableStream implementations,
not raw PHP stream resources. The defaults wrap the live process streams: new ReadableResourceStream(\STDIN) and
new WritableResourceStream(\STDOUT) from amphp/byte-stream.
Framing¶
The transport reads one JSON-RPC envelope per line on STDIN. Each send() writes one envelope as a single line
that ends in \n, and each write is flushed. An inbound line is capped at $maxLineBytes (default 4 MiB). A line
that reaches the cap before its \n raises a read error and unwinds the loop, so a peer cannot exhaust memory
with an unterminated stream.
The read loop¶
start() spawns the read loop. The loop parses each line as JSON and answers by shape:
| Inbound line | Result |
|---|---|
| Fails to decode | A -32700 ParseError response |
| Decodes, but is not a JSON object (JSON-RPC batches included, which the SDK does not accept) | A -32600 InvalidRequest response |
| A valid envelope | Emitted to the onMessage listeners |
When STDIN closes, the read loop unwinds, and its finally calls close().
Close¶
close() is idempotent, and it is the only place onDrain fires. Every close path drains exactly once, a cold
close() on a never-started transport included. A close runs these steps in order:
- Wait for the read loop and any side-channel loop to finish.
- Fire
onDrain, so the dispatcher can await its pending coroutines. - Transition to the
Closedstate. - Fire
onClose.
The state flips only after the drain, so a drain listener that settles its last exchange can still send(). A
close() from another fiber blocks until the running close settles. A close() that re-enters from a listener
or a drained loop returns immediately. After the close, send() and start() throw
TransportAlreadyClosedException.
A concurrent close, for example EOF on the read loop, can land while a send() is suspended in the byte-stream
write(). The transport wraps that stream failure into TransportAlreadyClosedException and keeps the original
throwable as getPrevious(), so callers can demote uniformly. On the same path it emits a per-message-shape
DEBUG log with the request ID, the method, and the underlying throwable. Operators keep a granular audit trail
even though the dispatcher reports the symptom at INFO.
STDOUT discipline¶
MCP servers MUST NOT write anything to STDOUT outside the JSON-RPC stream. Send all diagnostic logs to STDERR through the PSR-3 logger you pass in.
Stdin / stdout substitution¶
Useful in tests, when you want to drive the transport from synthetic streams:
use Amp\ByteStream\BufferedReader;
use Amp\ByteStream\WritableBuffer;
$reader = new BufferedReader(/* … */);
$writer = new WritableBuffer();
$transport = new StdioServerTransport(stdin: $reader, stdout: $writer);
StdioClientTransport¶
use Nexus\Mcp\Client\Transport\StdioClientTransport;
use Psr\Log\NullLogger;
$transport = new StdioClientTransport(
command: ['php', 'examples/stdio-server.php'], // argv. No shell interpretation
workingDirectory: null, // optional cwd; defaults to current
env: null, // optional; null prunes to a safe allowlist
logger: $psrLogger, // optional; default: NullLogger
maxLineBytes: 4_194_304, // optional cap; default 4 MiB
);
The transport launches an MCP server as a subprocess. It exchanges line-framed JSON-RPC envelopes over the subprocess's STDIN and STDOUT, with the same framing rules as the server transport. Outbound writes go to the subprocess's stdin, and inbound lines come from its stdout.
Launch and environment¶
start() runs the command through Amp\Process\Process. The first array element is the executable. The rest
are its arguments. There is no shell interpretation, so pass the arguments separately to avoid quoting bugs.
The env parameter has three modes:
| Value | Effect |
|---|---|
null (default) |
Passes a pruned allowlist of safe names (PATH, HOME, TERM, …) from the parent. Everything else is dropped, secrets included, and exported shell-function values are skipped. |
[] |
Inherits the full parent environment. |
| A non-empty array | Passed verbatim. |
The stderr pump¶
A second pump runs in parallel. It forwards every subprocess stderr line to the logger as
info('Subprocess stderr: {line}', ['line' => $line]). Each line is sanitised first. Non-printable bytes are
escaped to \xNN, and the line is capped at 80 bytes. A hostile subprocess therefore cannot smuggle control
sequences into the logs. An error during the pump logs a warning and does not change the transport state.
Close¶
close() closes the subprocess's stdin, which signals EOF. If the subprocess still runs, the transport sends
SIGKILL. SIGTERM would be preferable, but amphp/process runs subprocesses behind a shell wrapper that
ignores SIGTERM, so SIGKILL is the only signal guaranteed to terminate the child.
Unexpected exit¶
The transport implements
SupervisableTransportInterface.
onUnexpectedExit(fn (?int $exitCode) => ...) reports a teardown nobody asked for: the subprocess exited on its
own, or it stopped serving and was killed. Calling close() notifies nobody. The transport is spent once this
fires, so a supervisor respawns by building a fresh StdioClientTransport, not by restarting this one.
$transport->onUnexpectedExit(static function (?int $exitCode) use ($logger): void {
$logger->warning('MCP server died with code {code}.', ['code' => $exitCode ?? 'unknown']);
});
The exit code is null when the peer ended without reporting a status.