Transport WebSocket - ChristianPresley/zig-sdk GitHub Wiki
The WebSocket binding carries MCP on connections of RFC 64551. It is a custom transport under the custom transport rules of the specification2. It keeps the JSON-RPC message format, the message patterns and the metadata model of each request. This page is the normative description of the binding. The binding is in the module mcp.transport.websocket and has no dependency.
Applies to: MCP 2026-07-28, Zig 0.16.0, zig-sdk 0.2.0. The notifications of the client, inline notifications, the frames that the client cannot read and the status of a refused upgrade: zig-sdk 0.4.0.
- The client opens the connection with an HTTP/1.1
GETupgrade request on the path of the endpoint,/mcpby default. - The request offers the subprotocol
mcpinSec-WebSocket-Protocol. The server selectsmcp. It refuses a request withoutmcpwith HTTP status 400. - The server negotiates no extension. Thus the reserved bits of each frame must be zero.
- The server checks
OriginandHostas the HTTP server does. By default it accepts loopback origins and loopback hosts only. A foreign origin or host gets status 403. - A request without
Upgrade: websocketgets status 426. A version other than 13 also gets status 426, withSec-WebSocket-Version: 13. - A response other than 101 ends the connection. The SDK client gives the status of a refused upgrade in
Client.Diagnostics.http_status, and the request fails witherror.InvalidResponse.
- Each text message carries exactly one JSON-RPC message in UTF-8.
- A binary message closes the connection with the code 1003.
- An endpoint sends each message in one frame. It accepts a message in fragments, and control frames between the fragments.
- Each frame of the client has a new mask from a ChaCha20 generator with a seed from the system. The frames of the server have no mask.
- One connection carries many requests at the same time. A response has the id of its request.
- The notifications of a request, such as progress, go on the same connection with the progress token of the request.
-
subscriptions/listenis one long-lived request on the connection. Its events carry the subscription id. - The client cancels a request with
notifications/cancelled, as on stdio3. After the cancellation, the server sends nothing more for that request. - The server sends
notifications/cancelledonly when it ends a listen stream, for example at shutdown. - The server sends no requests. A multi round-trip request is a new request with the answers, as on the other transports.
- When a connection ends, the server cancels the requests of that connection.
Each request carries the protocol version and the client capabilities in _meta, as on every transport. The server reads all metadata from the message. The binding has no Mcp-* headers, because one upgrade request carries many messages. The specification makes the message the source of truth. The mirror of body fields into envelope metadata is optional, so the binding keeps the metadata model.
- The server checks the access token one time, at the upgrade, with the
AuthorizationandDPoPheaders. The method isGET. - A missing or invalid token answers the upgrade with status 401 or 403 and the
WWW-Authenticateheaders. The client runs its authorization provider and connects again. - The upgrade is an HTTP request. Thus the resource of the token and the
htuclaim of a DPoP proof use thehttporhttpsform of the URL4. Forwss://host/mcpthat form ishttps://host/mcp. - With
Options.auth, the server serves the protected resource metadata5 on its listener at/.well-known/oauth-protected-resource/mcp. - When the token expires, the server closes the connection with the code 1008. It also refuses an upgrade with a token that expired.
- A handler reads the principal with
ctx.principal(), as on HTTP and gRPC.
| Code | When |
|---|---|
| 1000 | The client closes the connection. |
| 1001 | The server stops, or no frame came for the idle timeout. |
| 1002 | The peer broke a rule of RFC 6455. |
| 1003 | The peer sent a binary message. |
| 1007 | A text message or a close reason is not valid UTF-8. |
| 1008 | The access token of the connection expired. |
| 1009 | A frame or a message is larger than its limit. |
| 1011 | The endpoint has no memory for the message. |
The server answers a close frame with the same code. Then it closes the TCP connection. At shutdown, the server first ends the listen streams with their results. Then it sends the close frame with the code 1001.
| Limit | Default | On overflow |
|---|---|---|
limits.websocket.max_message_bytes |
4 MiB | Close with 1009. |
limits.websocket.max_frame_bytes |
4 MiB | Close with 1009. |
limits.websocket.max_connections |
256 | The server closes the new connection at once. |
limits.websocket.max_in_flight_requests |
256 for each connection | The error -32603. The connection stays open. |
limits.websocket.ping_interval |
30 s | A ping when no frame came for this time. |
limits.websocket.idle_timeout |
90 s | Close with 1001. |
limits.websocket.handshake_timeout |
10 s | The server closes the connection without a response. |
limits.http.max_head_bytes |
16 KiB | HTTP status 431. |
Zero disables the ping interval and the idle timeout. The client obeys the size and time limits too.
The rate limits of the server apply as on the other transports. A request without a principal has the IP address of the client as its caller. Thus all connections from one address share one bucket. See Limits and Security.
var transport: mcp.transport.websocket.Server = .init(io, gpa, &server, .{ .port = 3001 });
defer transport.deinit();
try transport.bind();
try transport.serve();- Make a
mcp.transport.websocket.ServerwithOptions. - Call
bind(), thenserve().serve()returns aftershutdown(). - Add
.tls = &tls_serverforwss. The TLS server must offer ALPNhttp/1.1or no ALPN. - Add
.auth = &resource_serverfor access tokens. Setresourceto thehttporhttpsform of the URL. - Set
.allowed_originsand.allowed_hostsfor a server that is not on loopback.
const ws = try mcp.transport.websocket.Client.init(io, gpa, .{ .url = "ws://127.0.0.1:3001/mcp" });
defer ws.deinit();
client.connect(ws.transport());- Make a
mcp.transport.websocket.Clientwith awsorwssURL. - Add
.tls = .{ .trust = ... }for a private CA, or use the system trust store. - Add
.auth_provideror.authfor tokens. Add.originto send anOriginheader. - Give
transport()tomcp.Client. The first request opens the connection.
The client connects again when the connection is gone. close() sends the code 1000 and waits for the server to close the connection. client_cli ws <url> -- list lists the tools of a server. The example websocket_server listens on ws://127.0.0.1:3001/mcp. Run it with zig build run-websocket_server.
The reader task of the client routes the messages 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.
- The option
on_notificationofClient.initreceives 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 theon_logcallback 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 message, and
closedoes not return. The argumentsmethodandparamsare 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 message.
A message that is not a JSON-RPC message makes the request with its top-level "id" fail with error.InvalidResponse. The client then sends notifications/cancelled for the request, because the server can still run it. A message over limits.websocket.max_message_bytes or a message that is not valid UTF-8 does not get to the router of the client. The client closes the connection with the code 1009 or 1007, and the requests fail with error.Closed.
- The tests check the accept key of RFC 6455 section 1.3 and each framing example of section 5.7.
- Raw peers check each protocol violation and its close code, the ping and the pong, fragments and the close echo.
- The MCP client runs over the WebSocket client against the SDK server. The tests cover tools with progress, resources, prompts, multi round-trip requests, listen streams, cancellation and concurrent requests.
- Other tests cover
wsswith the test certificates, bearer and DPoP tokens at the upgrade, token expiry, the Origin check and each limit. - A test checks that the client calls the progress callback of an inline request on the reader task.
- The CI job
websocket-interopruns the global WebSocket client of Node against the everything server with--ws-port.
- RFC6455: The WebSocket Protocol.
- MCP-TRANSPORTS-2026-07-28: Transports, and the rules for custom transports.
- MCP-CANCELLATION-2026-07-28: Cancellation and timeouts.
- RFC9449: OAuth 2.0 DPoP.
- RFC9728: OAuth 2.0 Protected Resource Metadata.