Transport stdio - ChristianPresley/zig-sdk GitHub Wiki

Transport stdio

The stdio transport carries JSON-RPC messages over the standard input and the standard output of a process1. The server reads one message per line and writes one message per line. The client starts the server as a child process and manages its life. This page describes the server side and the client side of the SDK.

Applies to: MCP 2026-07-28, Zig 0.16.0, zig-sdk 0.1.0. The cancellation log, the shutdown flag of the cancel token and the default request timeout: zig-sdk 0.2.0. The on_notification spawn option, inline notifications and the frames that the client cannot read: zig-sdk 0.4.0. exitStatus and the reap of a server that ends its output: the same release.

Framing

Each message is one line of UTF-8 text that ends with a line feed1. A message never contains a raw line feed. The SDK writes the compact JSON form, so a line feed in a string travels as \n.

The line reader obeys these rules:

  • It removes a carriage return at the end of a line.
  • It skips blank lines.
  • A line that is not valid UTF-8 gets the error -32700 with the id null.
  • A line longer than limits.stdio.max_line_bytes (16 MiB) goes to no handler. The server drops it and logs a warning. On the client, the request of the line fails, see Frames that the client cannot read.
  • The read buffer has limits.stdio.read_buffer (64 KiB). For a longer line, the reader uses a buffer that grows.

Standard output carries MCP messages only. Logs go to standard error. The client lets the standard error of the server go to its own standard error.

The server

mcp.transport.stdio.serve(io, gpa, &server) runs a server on the standard input and the standard output of the process. It returns when the standard input ends.

try mcp.transport.stdio.serve(init.io, init.gpa, &server);

For other streams, make a mcp.transport.stdio.Server with init(io, gpa, &server, writer) and call run(reader). Tests use this form with memory buffers.

The server reads messages in a loop:

Message What the server does
A request It runs the request in its own task.
notifications/cancelled It cancels the request in flight with the same id2. It logs the id and the reason at the debug level of the log scope mcp_cancel.
Another notification It ignores the notification.
A response from the client It logs a warning and ignores the response.
Text that is not JSON It sends -32700 with the id null.
JSON that is not a valid message It sends -32600 with the id of the message, when it can read the id.

Requests run at the same time, up to limits.max_in_flight_requests (256). When all permits are in use, the reader waits. A request that cannot start a task gets -32603 with the message "Server busy".

After a cancellation by the client, the server writes nothing more for that request. A handler that ignores the cancellation and returns a result gets no response out.

When the standard input ends, the server ends each listen stream3. A listen stream that the server ends gets a result and then a notifications/cancelled notification with the reason "server shutdown". Only a listen stream that the server ends at shutdown gets this notification. The server knows a shutdown from a flag of the cancel token (CancelToken.server_shutdown), not from the reason text. Thus a client cancellation with the reason "server shutdown" stays a client cancellation. Then the server waits for the requests in flight and returns.

The client

mcp.transport.stdio.Client.spawn(io, gpa, options) starts the server process and a reader task. transport() gives the transport for mcp.Client.

const child = try mcp.transport.stdio.Client.spawn(io, gpa, .{ .argv = &.{"./zig-out/bin/stdio_server"} });
defer child.deinit();
var client: mcp.Client = .init(gpa, io, .{ .info = .{ .name = "cli", .version = "0.1.0" } });
defer client.deinit();
client.connect(child.transport());

The options of spawn:

Option Default Meaning
argv none The command and its arguments. The slices must stay valid while the client lives.
environ_map null The environment of the child. Null gives the environment of the parent.
cwd .inherit The current directory of the child.
limits .{} The limits of the transport.
poll_interval 50 ms How often a request that waits checks for cancellation.
process_group true Put the child in its own process group (POSIX) or job object (Windows).
max_restarts 0 How many times the client starts the server again after the server exits.
on_notification null The function that receives each notification without a progress token and without a subscription id. See Routed notifications.
userdata null The first argument of on_notification.

When a request waits too long, or the application cancels it, the client sends notifications/cancelled to the server. A request without a timeout gets limits.request_timeout (60 s). The reader task logs each cancellation from the server at the debug level of the log scope mcp_cancel.

Routed notifications

The reader task sends each response to the request with the same id. It routes a notification only by its progress token4 or its subscription id3. The callbacks on_progress, on_log and on_notification of RequestOptions receive only these routed notifications. The reader task drops a notification with a progress token or a subscription id that names no request in flight.

A notification without a progress token and without a subscription id goes to the on_notification spawn option. A log message of the server5 is such a notification, also during a request. Thus on stdio, the on_log callback of a request gets no log messages.

fn onNotification(userdata: ?*anyopaque, method: []const u8, params: ?std.json.Value) void {
    _ = userdata;
    _ = params;
    std.log.info("notification of the server: {s}", .{method});
}

const child = try mcp.transport.stdio.Client.spawn(io, gpa, .{
    .argv = &.{"./zig-out/bin/stdio_server"},
    .on_notification = onNotification,
});

spawn starts the reader task, thus the client takes the function only from the spawn options. Version 0.3.0 had the fields on_notification and userdata on stdio.Client. A function that the caller set after spawn raced with the reader task, and the reader task could drop the log messages of the server.

The function runs on the reader task. It must not block: while it runs, the reader task routes no frame, and close does not return. The arguments method and params are valid only during the call. Copy the data that you keep.

With RequestOptions.inline_notifications, the reader task calls on_progress, on_log and on_notification of the request at once, before it routes the next frame. Thus a notification gets to its callback before the response of another request that the server wrote after it. Such a callback must only copy, serialize and write the data. It must not send a request through the same client. Without the option, the task of the request calls the callbacks later. See Notifications in the Client Guide.

Frames that the client cannot read

The reader task makes a request fail at once when it cannot read a frame of the request. The client then returns error.InvalidResponse and does not send the request again. Examples are a line over limits.stdio.max_line_bytes, a line that is not valid UTF-8 and a frame that is not a JSON-RPC message. The reader task finds the request by the top-level "id" member of the frame. The member can also come after the result or after the length limit.

An error response with a null id makes a request fail only when one request can get it. Such a request is a request that the client wrote and that got no frame from the server. Thus the error does not end a listen stream after its acknowledgment, or a request after a progress notification.

The server can still run a request that fails in this way. Thus the client sends notifications/cancelled for it. The client also does this when the sink of the request refuses a notification. The reader task logs a warning in the scope mcp_router for each frame that it drops, with the reason and the request id. The warning has no other part of the frame, except the error code of an error response.

Process management

With process_group, the child is in its own process group on POSIX and in a job object on Windows. Then close and kill also end the processes that the server started.

close() closes the standard input of the server and waits limits.shutdown_grace (2 s) for the end of its output. Then the client sends SIGTERM to the group and waits again. After that, it sends SIGKILL. The client then waits for the exit of the process in the same steps. Thus close also ends a server that closed its output but does not exit. On Windows, a job has no graceful signal, so both steps end the job.

kill() ends the process tree at once, also a process that a restart spawns at the same time. deinit() calls close(). After close or kill, the client writes no request to the input of the process, and a request fails with error.Closed.

The server can end its output when no restart is left. Then the reader task closes the input of the process and reaps it, as close does. It logs a warning in the scope mcp_stdio with the exit status. The requests in flight fail with error.Closed. Before version 0.4.0, the process stayed until close.

exitStatus() gives the exit status of the last server process that ended, as std.process.Child.Term. That is the exit code, or the signal that ended the process. The value is null before the client reaps the first process, and when the system gives no status. The client reaps a process after it ends its output, before a restart, and in close and kill. A reap and a signal take the same lock. Thus the client never signals a process ID that the system can give to a new process.

With max_restarts above zero, the client starts the server again when it exits on its own. Before the restart, the client closes the input of the old process and waits for its exit in the same steps as close. A request that waited during the exit fails with error.Closed. The client sends idempotent requests again with a new id. restartCount() gives the number of restarts, and the warning of each restart has the exit status. See the retry option in the Client Guide.

References

  1. MCP-STDIO-2026-07-28: stdio transport.
  2. MCP-CANCELLATION-2026-07-28: Cancellation of requests.
  3. MCP-SUBSCRIPTIONS-2026-07-28: Subscriptions and listen streams.
  4. MCP-PROGRESS-2026-07-28: Progress notifications and the progress token.
  5. MCP-LOGGING-2026-07-28: Log messages of the server.
⚠️ **GitHub.com Fallback** ⚠️