Transport WebSocket - ChristianPresley/zig-sdk GitHub Wiki

Transport WebSocket

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 connection

  • The client opens the connection with an HTTP/1.1 GET upgrade request on the path of the endpoint, /mcp by default.
  • The request offers the subprotocol mcp in Sec-WebSocket-Protocol. The server selects mcp. It refuses a request without mcp with HTTP status 400.
  • The server negotiates no extension. Thus the reserved bits of each frame must be zero.
  • The server checks Origin and Host as 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: websocket gets status 426. A version other than 13 also gets status 426, with Sec-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 with error.InvalidResponse.

Messages

  • 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.

Request patterns

  • 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/listen is 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/cancelled only 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.

Metadata

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.

Authorization

  • The server checks the access token one time, at the upgrade, with the Authorization and DPoP headers. The method is GET.
  • A missing or invalid token answers the upgrade with status 401 or 403 and the WWW-Authenticate headers. The client runs its authorization provider and connects again.
  • The upgrade is an HTTP request. Thus the resource of the token and the htu claim of a DPoP proof use the http or https form of the URL4. For wss://host/mcp that form is https://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.

Close codes

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.

Limits

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.

Steps: serve WebSocket

var transport: mcp.transport.websocket.Server = .init(io, gpa, &server, .{ .port = 3001 });
defer transport.deinit();
try transport.bind();
try transport.serve();
  1. Make a mcp.transport.websocket.Server with Options.
  2. Call bind(), then serve(). serve() returns after shutdown().
  3. Add .tls = &tls_server for wss. The TLS server must offer ALPN http/1.1 or no ALPN.
  4. Add .auth = &resource_server for access tokens. Set resource to the http or https form of the URL.
  5. Set .allowed_origins and .allowed_hosts for a server that is not on loopback.

Steps: connect

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());
  1. Make a mcp.transport.websocket.Client with a ws or wss URL.
  2. Add .tls = .{ .trust = ... } for a private CA, or use the system trust store.
  3. Add .auth_provider or .auth for tokens. Add .origin to send an Origin header.
  4. Give transport() to mcp.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.

Notifications of the client

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_notification of Client.init 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 message, 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 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.

Tests

  • 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 wss with 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-interop runs the global WebSocket client of Node against the everything server with --ws-port.

References

  1. RFC6455: The WebSocket Protocol.
  2. MCP-TRANSPORTS-2026-07-28: Transports, and the rules for custom transports.
  3. MCP-CANCELLATION-2026-07-28: Cancellation and timeouts.
  4. RFC9449: OAuth 2.0 DPoP.
  5. RFC9728: OAuth 2.0 Protected Resource Metadata.
⚠️ **GitHub.com Fallback** ⚠️