PhoneBridgeDocs

Reference

Protocol

The contract between an MCP client, the relay and the phone. The relay understands just enough MCP to multiplex many clients onto one phone socket and forwards everything else byte for byte. The authoritative text is docs/PROTOCOL.md in the repository; this is the working summary.

Three parties#

client
Any MCP client. Speaks MCP Streamable HTTP to the relay at /mcp.
relay
Static Go binary. Authenticates both sides, forwards JSON-RPC, stores no content.
device
The Android app. Holds one outbound WebSocket to the relay and runs the real MCP server.

The version string throughout is phonebridge-v1. Everything the relay does to a message is listed under what the relay rewrites; there is nothing else.

Pairing#

A pairing code is 160 random bits, base32, shown as eight groups of four, valid ten minutes, single use, stored hashed with its owner's account id. The QR encodes:

{"v":1,"relay":"https://relay.example.com","code":"ABCD-EFGH-…","fp":"sha256:<hex of relay leaf cert SPKI>"}

The phone generates a P-256 key in the Keystore and posts its public key, the code and its name to /pair. The relay checks the code in constant time, stores the device, and returns the device id and relay host. 403 bad_code covers expired and used codes alike to avoid an oracle; 403 limit_reached is the plan cap; five failures per ten minutes from one address earns a 429. If fp is present the phone pins it.

Device connection#

  1. Challenge. POST /device/challenge with the device id returns a 32-byte nonce valid sixty seconds. Unknown ids get a nonce too; the signature check fails later.
  2. Upgrade. GET /device/ws with headers X-Device-Id, X-Device-Nonce, X-Device-Ts, X-Device-Sig and X-Device-App. The signature is SHA256withECDSA over "phonebridge-v1" || nonce || relay_host || ts, which is what ties a key to one relay hostname. A clock skew over sixty seconds, a reused nonce or a bad signature is rejected. A new socket supersedes the old one with close code 4001.
  3. Handshake. The relay acts as an MCP client towards the phone: it sends initialize and notifications/initialized once per socket and caches the result.

Framing: one JSON-RPC message per text frame, 256 KiB maximum, no batching. The phone sends pings at an adaptive interval of one to ten minutes; the relay considers it gone after twenty-five minutes of silence. Close codes: 4001 superseded, 4003 unauthorised, 4004 device removed, 1009 too big, 1011 relay error.

Client connection#

Endpoint /mcp. Auth is X-Api-Key, or Authorization: Bearer; when both are present the header wins. A missing or bad token is a 401 with WWW-Authenticate: Bearer realm="phonebridge", and in hosted mode a resource_metadata attribute pointing at the OAuth discovery document. A valid token on an account without a subscription is a 402 with subscription_required; nothing is revoked.

RequestBehaviour
POST /mcp with a requestForwarded to the phone; answered as application/json. SSE only when Accept excludes JSON.
POST /mcp with only notifications or responsesForwarded; 202 Accepted.
GET /mcpLong-lived SSE stream of device notifications for this session, with a comment line every 30 s. No Last-Event-ID resumption; events are best effort.
DELETE /mcpEnds the session, 204.

Protocol revisions 2026-07-28 (stateless, each request its own short session) and 2025-11-25, 2025-06-18, 2025-03-26 (the relay assigns Mcp-Session-Id on initialize; idle sessions expire after 24 h). Timeouts: thirty seconds per forwarded request, except wait_for_events, which gets its own timeout_s plus fifteen seconds, capped at 615.

What the relay rewrites#

  1. Answers initialize itself from the cached result and swallows notifications/initialized. If the phone is offline it still answers from the last cached result, so a client connects cleanly and gets a clear device_offline on its first real call.
  2. Rewrites JSON-RPC ids. Client ids may collide across sessions, so each outbound request gets a relay-unique id that is restored on the response.
  3. Fans out notifications to every open SSE stream. Sessions without one miss them, by design.
  4. Never forwards device-originated requests to clients; for older revisions it answers -32601 on the phone's behalf.

The relay parses exactly two fields of a request: the tool name and timeout_s, to size the timeout. It logs method names, sizes and latency.

Error codes#

CodeMeaning
-32001device_offline, with data.last_seen as RFC 3339 or null
-32002device_timeout
-32003payload_too_large
-32600 to -32603Standard JSON-RPC

Tool-level errors such as denied are not JSON-RPC errors; they are tool results with isError set, listed on the tools page.

Hosted mode#

With accounts enabled nothing changes on the wire; what changes is ownership. Every device, token, pairing code and session carries an account id, and a token is valid only while its device exists in the same account. Sign-in is a magic link whose GET renders a button and whose POST consumes the code, so mail scanners that prefetch links cannot burn it. State changes need the session cookie, a CSRF token and a same-origin request. Plans cap phones and keys, enforced when a code or key is created and again when the code is redeemed. A locked plan keeps pairings and tokens but answers 402 on /mcp until the account subscribes, so coming back needs no re-pair.

OAuth#

In hosted mode the relay is also an OAuth 2.1 authorization server, as the MCP authorization specification describes, so clients that cannot send a static header connect with the URL alone.

discovery
/.well-known/oauth-protected-resource/mcp (RFC 9728) and /.well-known/oauth-authorization-server (RFC 8414)
registration
POST /oauth/register (RFC 7591), anonymous, ten per ten minutes per address. Redirect URIs must be https, or http on loopback for native clients.
authorize
GET /account/authorize with PKCE S256 only. Sign in if needed, pick a phone, press Allow. The hop back is a same-origin page with a meta refresh, because the dashboard CSP forbids form actions elsewhere.
token
POST /oauth/token, authorization code (single use, five minutes, hashed) or refresh token (rotated on every use). Access tokens live one hour, refresh tokens thirty days.

The token minted is an ordinary device-bound token, so every rule above applies to it, and it shows on the dashboard as connected via OAuth. There is no revocation endpoint; revoke from the dashboard or delete the connector.

Store#

One file, store.json, mode 0600, written atomically under an exclusive lock and reloaded by the server when its modification time changes. That is how the CLI and the server share it without an admin socket. Secrets are stored hashed: token SHA-256, pairing codes, sign-in codes, session cookies, authorization codes, refresh tokens and client secrets. Expired and used records are dropped on every write. The current version is 3; a newer relay reads older files and rewrites them, and an older relay refuses a newer file.