WebSocket protocol
Every frame between your app and Relay, the rules for hello and calls, keep-alive, close codes and reconnecting.
The app holds one WebSocket to Relay per link. Frames are JSON text, one object per frame, each with a type. The protocol version is the integer protocol in hello; this page describes version 1.
⫻
socket URL
@meslzy/relay speaks this protocol for you, as a plain helper or as a transport for the official MCP SDK: see the Quickstart.Lifecycle#
- Open the socket and send
hellowithin 10 seconds, or the socket is closed. - Relay checks the link token and the tools, stores the tool list on the link and answers
ready. - Each tool call arrives as
call. Answer each withresultorerror. Calls can overlap, and you can answer them in any order. - Keep the socket open. Relay pings it, and your WebSocket library answers on its own.
- When the socket closes, reconnect with backoff unless the close code says not to.
Frames the app sends#
hello#
⫻
hello
- Rules
hello
4400.- Rules
1
4401.- Rules
lt_…
- Rules
- 1–64 characters
- Rules
- 1–32 characters
initialize.- Rules
- At most 4,000 characters
- Rules
- 1–64 tools with unique names
A hello that breaks any rule gets an `error` frame saying what is wrong, then the socket closes with 4400. To change the tools, close the socket and connect again with a new hello.
Tools#
Each tool is an MCP tool definition. Relay passes every field to hosts unchanged and never renames or prefixes a tool: the connector's name in the host already tells apps apart.
hello. Verbs in snake case, such as search_notes, read best to models.- Rules
- 1–64 letters, digits,
_or-
name.- Rules
- At most 2,000 characters
$schema is treated as 2020-12, and any other dialect is rejected at hello.- Rules
- A JSON Schema with
"type": "object"
structuredContent in your results, for hosts that use it.- Rules
- A JSON Schema with
"type": "object"
readOnlyHint, destructiveHint and idempotentHint. ChatGPT asks the person to confirm every call to a tool without readOnlyHint: true.- Rules
- MCP tool annotations
app, instructions and tools as JSON) must be at most 64 KiB. Hosts cache tool lists, so after you remove a tool a host may call it until it lists tools again; those calls answer -32602 and never reach the app.result#
⫻
result
- Rules
result
id of the call it answers.- Rules
- MCP content items:
{ "type": "text", "text" }or{ "type": "image", "data", "mimeType" }with base64data
outputSchema.- Default
false
content and structuredContent together must be at most 128 KiB as JSON.
error#
⫻
error
A short way to report a failed call. Relay returns it to the host as a result with isError: true and the message as its text, so write the message for the model to repeat to the person. The call counts like any other answer.
ping#
⫻
ping and pong
Optional. Relay answers pong. Browser code cannot see WebSocket ping frames, so a web app that wants to notice a dead socket sends ping now and then and expects pong.
Link commands#
The person controls their own link from your app, with no account of their own. Send a command with an id of your choice; Relay answers with the same id.
| Command | Effect |
|---|---|
| link.rotate | Issues a new MCP URL and answers link.rotated with it. The old URL answers 404 at once. Replace the stored URL and show the new one so the person can update the host. The link token stays the same. |
| link.pause | Answers link.state with paused: true. Calls then answer with a tool error saying the link is paused, and never count. |
| link.resume | Answers link.state with paused: false. |
| link.revoke | Answers link.revoked, then closes the socket with 4410. The link token and the MCP URL stop working for good. |
The dashboard offers the same actions for every link of the project. A rotate there sends link.rotated with the new URL to a connected app, and a pause or resume sends link.state, both with id: null, so the app never holds a dead URL or a stale paused flag. Handle these frames whenever they arrive, not only after a command.
Frames Relay sends#
ready#
The link is live: hosts can reach the app from now on. ready does not carry the MCP URL; the app has it from link creation.
⫻
ready
| Field | Meaning |
|---|---|
| linkId | The link's id. |
| paused | Whether the link is paused. Show it next to a resume switch. |
| limits.callTimeoutSeconds | How long Relay waits for an answer to each call. |
| limits.resultMaxBytes | The largest content plus structuredContent a result may carry, in bytes. |
call#
⫻
call
argumentsalready match the tool'sinputSchema.deadlineis when Relay stops waiting, 180 seconds after it sent the call. An answer after the deadline is too late: the host has been told the app did not answer.- Calls can overlap. At most 8 calls per link are in flight at once; Relay refuses more before they reach the app.
- Every call reaches the app before anything runs, so the app can show what is about to happen and ask the person first before a destructive tool.
cancel#
⫻
cancel
The host cancelled the call, closed its connection, or the deadline passed. Stop the work if you can. No answer is needed, and an answer sent after cancel is not delivered.
error#
⫻
error
Relay answers a frame it cannot use with an error frame. id is the id of the frame it answers, or null.
| Code | When |
|---|---|
| invalid_message | The frame is not JSON, or matches no frame type. |
| invalid_result | A result or error breaks the content rules or the size limit. The call ends for the host with a tool error saying the app sent an answer Relay could not accept. |
| unknown_call | A result or error names a call Relay is not waiting for: it already ended, was cancelled or passed its deadline. |
| rate_limited | A ping or link.* command arrived after 30 in the last minute on this link. Relay does not execute it, and the socket stays open. |
Keep-alive#
- Relay sends a WebSocket ping every 30 seconds. Every WebSocket library answers it with a pong on its own.
- A socket that stays silent for 90 seconds is closed. Reconnect as below.
- Pings, pongs,
pingframes and an open socket with no calls are free.
The newest socket wins#
A link has one live socket. When a new socket sends hello with the same link token, Relay closes the older one with 4001 and sends calls to the new one. Calls still in flight on the old socket end with an error for the host and are taken off the month's count. An app that restarts can therefore reconnect at once, before Relay notices the old socket is dead.
Close codes#
| Code | Meaning | Reconnect |
|---|---|---|
| 4400 | The hello was invalid or late, its protocol is not supported, or the socket sent more than 10 invalid frames. | With backoff, after fixing the hello. The same hello fails again. |
| 4401 | The link token is unknown or revoked. | No. Create a new link. |
| 4403 | The socket's Origin is not on the project's allowed origins. Relay checks it after hello. | No. |
| 4001 | A newer socket connected with the same link token. | With backoff. If it keeps happening, two copies of the app share one link. |
| 4410 | The link was revoked from the dashboard or with link.revoke. | No. Create a new link if the person wants to connect again. |
| 4423 | The project is suspended. | With backoff. The socket is accepted again once the project is reinstated. |
| 4429 | The project has too many open sockets. | With backoff. |
| 1006 | The connection dropped with no close frame. Relay drops it when a frame is larger than 256 KiB; a network failure looks the same. | With backoff, after making any oversized frame smaller. |
| other | The network dropped, or Relay restarted, for example during a deploy. | With backoff. |
Reconnecting#
Reconnect on every close except 4401, 4403 and 4410, waiting 1 second at first and doubling up to 30 seconds, with random jitter. Reset the wait once ready arrives. A deploy of Relay closes every socket at once, and the jitter keeps apps from all reconnecting in the same second.
⫻
JavaScript