Transport Unix Socket - ChristianPresley/zig-sdk GitHub Wiki

Transport Unix Socket

The Unix socket transport carries JSON-RPC messages over a stream socket at a file system path. It is a custom transport under the rules of the specification for custom transports1. It uses the framing of the stdio transport2: each line is one JSON-RPC message. The transport keeps the JSON-RPC format, the message patterns and the request metadata. It is in the module mcp.transport.unix.

Applies to: MCP 2026-07-28, Zig 0.16.0, zig-sdk 0.2.0. The notifications of the client, inline notifications and the frames that the client cannot read: zig-sdk 0.4.0. The poll before each read on Windows: zig-sdk 0.5.0.

The server

The server accepts many connections on one socket file. Each connection behaves as one stdio peer. See Transport stdio.

try mcp.transport.unix.createPrivateDirectory(io, "/run/user/1000/mcp");
var transport: mcp.transport.unix.Server = .init(io, gpa, &server, .{ .path = "/run/user/1000/mcp/mcp.sock" });
defer transport.deinit();
try transport.bind();
try transport.serve();

Server.init(io, gpa, &server, options) does not open the socket. bind() opens it, and serve() accepts connections until shutdown(). serve() calls bind() when the socket is not open. The options are:

Option Default Meaning
path none The path of the socket file. Its directory must be private. See Security.
mode 0o600 The file mode of the socket file on POSIX systems. On Windows a mode without group and other bits gives the socket file an access control list for the owner only.

The transport takes its limits from the options of the mcp.Server. Each connection obeys these rules:

  • Requests run at the same time up to limits.max_in_flight_requests (256) for each connection. When all permits are in use, the reader of that connection waits.
  • notifications/cancelled cancels a request of the same connection only. After the cancellation, the server writes nothing more for that request.
  • Listen streams work as on stdio.
  • The server drops a line longer than limits.stdio.max_line_bytes (16 MiB) and logs a warning. The connection stays open.
  • The socket carries MCP messages only. Logs go to standard error.
  • When a peer closes its connection, the server cancels the requests of that peer. The other connections do not change.

limits.unix_socket.max_connections (64) sets the maximum number of open connections. The server closes a new connection above the limit at once and logs a warning.

shutdown() stops the accept loop, ends the listen streams and cancels the requests in flight. Then serve() returns and removes the socket file. Another task can call shutdown(). shutdown() ends the listen streams of the whole mcp.Server, also on other transports of the same server. A listen stream that the shutdown ends gets a result and then a notifications/cancelled notification with the reason "server shutdown". deinit() also removes the socket file when serve() did not run.

The client

mcp.transport.unix.Client.connect(io, gpa, options) connects to a socket file and starts a reader task. transport() gives the transport for mcp.Client.

const conn = try mcp.transport.unix.Client.connect(io, gpa, .{ .path = "/run/user/1000/mcp/mcp.sock" });
defer conn.deinit();
var client: mcp.Client = .init(gpa, io, .{ .info = .{ .name = "cli", .version = "0.1.0" } });
defer client.deinit();
client.connect(conn.transport());

The options of connect are path, limits, poll_interval (50 ms), on_notification and userdata. The client does not start a server process, and it does not connect again after a lost connection. close() ends the output side and waits limits.shutdown_grace (2 s) for the server to close its side. deinit() calls close().

Notifications of the client

The reader task routes the frames of the connection as the stdio client does. See Routed notifications.

  • The reader task routes a notification only by its progress token or its subscription id, to the request in flight with that id. It drops a notification whose progress token or subscription id names no request in flight.
  • on_notification receives each notification without a progress token and without a subscription id. A log message of the server is such a notification, also during a request. Thus the on_log callback of a request gets no log messages on this transport.
  • 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.
  • The client obeys RequestOptions.inline_notifications. The reader task then calls the callbacks of the request at once, before it routes the next frame.

The client makes a request fail with error.InvalidResponse when it cannot read a frame of the request. 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 client then sends notifications/cancelled for the request, because the server can still run it. See Frames that the client cannot read.

Security

The server does not check the credentials of the peer (SO_PEERCRED or getpeereid). The directory of the socket and the protection of the socket file control the access.

The directory of the socket

An account that can add, delete or rename entries in the directory can put its own socket at the path. A client then connects to that socket and not to the server. Thus bind refuses such a directory with error.DirectoryNotPrivate, for each value of Options.mode.

  • On POSIX systems, the owner of the directory must be the user of the process or root. Other accounts must not have write permission, also not with the sticky bit. With the sticky bit, another account can put a socket at the path before the server starts. The group can have write permission only when Options.mode gives the group access to the socket.
  • On POSIX systems, bind also checks each parent directory up to /. The owner must be the user or root. A parent that other accounts can write to must have the sticky bit, so that they cannot rename a directory that they do not own.
  • On Windows, the owner and each entry of the access control list that applies to the directory must be a trusted account. The trusted accounts are the user of the process, the local system account, the Administrators group and TrustedInstaller. On Windows, bind does not check the parent directories.

mcp.transport.unix.createPrivateDirectory(io, path) makes a safe directory. On POSIX systems the directory has the mode 0700. On Windows it has a protected access control list for the user only, and its files inherit that list. The list is part of the creation, so changes to the list of the parent do not reach the directory. A directory that is already at path must be private, or the call gives error.DirectoryNotPrivate.

On Linux, a directory in $XDG_RUNTIME_DIR is a good choice. On Windows, a directory in %LOCALAPPDATA% is a good choice.

The protection of the socket file

  • On POSIX systems, the socket file has the mode 0600. Options.mode changes the mode. Linux requires write permission on the socket file for a connection, so only the owner and the superuser can connect. Some other systems do not obey the mode of a socket file. The directory rule protects the socket on these systems.
  • On Windows, see Platforms.
  • bind cannot give a mode or a list to the new socket file. Thus bind creates the socket in a new private directory next to the path, with a name such as .mcp-3fa2c1. It sets the mode or the list, and then it moves the socket to the path. No client can connect before the socket has its protection. After a crash during bind, the private directory can stay. You can remove it.

The path

  • The server refuses to start when a file that is not a socket is at the path (error.PathNotSocket). It does not change that file. A symbolic link at the path also gives error.PathNotSocket.
  • The server removes a socket file at the path only when a test connection to it fails. A live server makes bind fail with error.AddressInUse. When the test connection gets a permission error, bind fails with error.AccessDenied and the file stays.
  • The server removes its socket file at shutdown. After a crash, the file stays. The next start removes it.

Platforms

Linux, macOS and the BSD systems have Unix domain sockets. The path must fit into the sockaddr_un structure of the target. The limit is 108 bytes on Linux, and 104 bytes on macOS and the BSD systems. mcp.transport.unix.max_path_len has the limit of the build target. A longer path makes bind and connect fail with error.NameTooLong.

During bind the socket is in a private directory with an 11-byte name next to the path. Thus the path of the directory of the socket plus 14 bytes must also fit.

Windows has Unix domain sockets from Windows 10 version 1803. mcp.transport.unix.supported is a compile-time value. On Windows, it depends on the minimum Windows version of the build target. On Windows, the SDK resolves a relative path against the current directory.

On Windows, the driver of Unix sockets can lose the close of the peer. This occurs when a receive starts at about the same time as the close. Thus on Windows, each read of the server and the client first waits in a poll until data comes or the connection ends. Each poll stops after one second and starts again. On Linux and macOS, the transport reads without a poll.

When Options.mode has no group and other bits, the server gives the socket file a protected access control list. The list allows access only to the user of the process. Windows checks this list when a client connects. When Windows does not set the list, bind fails with error.AccessControlFailed. Another mode keeps the list that the file inherits from the directory.

On a target without Unix domain sockets, such as wasm32-wasi, supported is false. Then bind and connect return error.Unsupported.

Example

examples/unix_server.zig serves the tool add on a socket file. zig build run-unix_server creates the private directory mcp-run and listens on mcp-run/mcp.sock. client_cli unix mcp-run/mcp.sock -- list lists its tools. A path in /tmp does not work, because other accounts can write to /tmp. See Examples.

References

  1. MCP-TRANSPORTS-2026-07-28: Transports, and the rules for custom transports.
  2. MCP-STDIO-2026-07-28: stdio transport.
⚠️ **GitHub.com Fallback** ⚠️