Skip to content

Latest commit

 

History

History
537 lines (442 loc) · 21.4 KB

File metadata and controls

537 lines (442 loc) · 21.4 KB

Tunnel client wire protocol

This document is for authors of tunnel clients in any language. It describes the HTTP methods, headers, JSON shapes, and lifecycle used between a client and the Secure MCP Tunnel control plane.

The machine-readable contract is openapi.json. Use it to generate types or validate fixtures, and use this document for behavior that OpenAPI alone cannot express.

Scope

A tunnel client:

  1. authenticates to https://api.openai.com;
  2. optionally fetches tunnel metadata for startup diagnostics;
  3. optionally fetches managed Cloudflare runtime material to launch the bundled cloudflared companion;
  4. long-polls for commands addressed to one tunnel;
  5. forwards each command to the configured MCP server; and
  6. posts the MCP result back to the control plane.

The canonical client endpoints are:

Method Path Purpose
GET /v1/tunnels/{tunnel_id} Fetch minimal startup metadata.
GET /v1/tunnels/{tunnel_id}/cloudflare/runtime Fetch managed Cloudflare metadata plus the runtime token for an explicitly enabled bundled companion.
GET /v1/tunnels/{tunnel_id}/poll Long-poll for pending commands.
POST /v1/tunnels/{tunnel_id}/response Return the result for one command.

Use the plural /v1/tunnels/... paths. Singular /v1/tunnel/... paths are compatibility aliases and are not part of the contract for new clients.

Authentication and common headers

Send the tunnel API key on every request:

Authorization: Bearer <tunnel-api-key>

Clients should also send a stable implementation name and version. These headers are diagnostic metadata, not feature negotiation:

X-Tunnel-Client-Name: example-rust-client
X-Tunnel-Client-Version: 1.2.3

MCP server information

On control-plane requests, clients may send the optional X-Tunnel-MCP-Server-Info header alongside X-Tunnel-Client-Instance-Id. The official tunnel-client sends it on tunnel metadata, managed Cloudflare runtime, poll, and response requests. The declarations reflect the channels enabled when each request is sent, so Harpoon appears only after at least one target is registered.

The header value is compact JSON. Version 1 has this exact shape:

{
  "version": 1,
  "channels": [
    {"name": "main", "proc_affinity": true},
    {"name": "harpoon", "proc_affinity": true}
  ]
}

Version 1 permits only these keys:

Location Key Required Meaning
Top level version yes Integer protocol version; version 1 is the only v1 value.
Top level channels yes Array of MCP channel declarations.
Channel name yes Canonical channel name, such as main or harpoon.
Channel proc_affinity no When true, session work for that channel must stay on one tunnel-client process.

An omitted proc_affinity means false; clients serialize false by omitting the key rather than sending "proc_affinity": false. Channel names must be canonical and unique. A v1 header contains at most 32 channel declarations and at most 4096 UTF-8 bytes. Clients must reject duplicate, non-canonical, invalid, or over-limit declarations before sending them.

The v1 object is deliberately narrow. It must not contain URLs, commands, transport details, headers, request or response payloads, secrets, targets, or customer IDs.

Affinity declarations describe the effective channel binding:

  • A stdio-backed main channel uses "proc_affinity": true.
  • A remote Streamable HTTP main channel omits proc_affinity.
  • An enabled built-in in-memory harpoon channel uses "proc_affinity": true because its session state is process-local. Include it only while Harpoon has at least one registered target; never include target details.
  • Other configured channels use their canonical names and the same transport rule: process-local stdio or in-memory work is true; remote Streamable HTTP omits the key.

For a remote Streamable HTTP main channel without enabled Harpoon, the value is:

{
  "version": 1,
  "channels": [
    {"name": "main"}
  ]
}

For a remote Streamable HTTP main channel with enabled Harpoon, the value is:

{
  "version": 1,
  "channels": [
    {"name": "main"},
    {"name": "harpoon", "proc_affinity": true}
  ]
}

The header is additive metadata. Older tunnel-service versions ignore it, and its presence does not change current routing, queueing, Redis, shard-token, poll, or response behavior. Version 1 adds no transport field, session scope, session capability flag, affinity token, token echo, keyed FIFO lane, body field, command, or endpoint. This reader-first client protocol prerequisite does not provide service-side lazy-owner/FIFO behavior; that is a separate service implementation.

Treat tunnel IDs, request IDs, and shard tokens as opaque strings. Do not parse them or infer routing from their contents.

Managed Cloudflare runtime fetch

When managed bundled-cloudflared mode is explicitly enabled and no static token is configured, fetch GET /v1/tunnels/{tunnel_id}/cloudflare/runtime before starting the child process. The response contains non-secret Cloudflare metadata and one runtime token. Treat the full response as secret-bearing: do not write it to disk, argv, logs, metrics, support exports, or generic raw HTTP traces. The service returns Cache-Control: no-store; clients must not cache the response.

The static cloudflared.token / CLOUDFLARED_TUNNEL_TOKEN path takes precedence and must bypass this fetch. A 401 or 403 is an authorization failure and must not fall back to another credential path.

Poll loop

Request:

GET /v1/tunnels/tunnel_123/poll?limit=25&timeout_ms=15000 HTTP/1.1
Authorization: Bearer <tunnel-api-key>
X-Tunnel-Client-Name: example-rust-client
X-Tunnel-Client-Version: 1.2.3

When an operator explicitly configures poll subscriptions, the client adds one repeated channel query parameter per sorted allowlist entry, for example:

GET /v1/tunnels/tunnel_123/poll?channel=harpoon&channel=main&limit=25&timeout_ms=15000 HTTP/1.1

Omitting the allowlist preserves the legacy request above with no channel parameters. These parameters describe drain subscriptions; they do not replace X-Tunnel-MCP-Server-Info, which remains capability metadata. End-to-end channel isolation also requires a tunnel-service reader/filtering implementation.

limit is optional and must be from 1 through 25. It is a request hint: if a successful response contains more commands than requested, process every command; do not drop the excess. timeout_ms is an optional requested long-poll wait in milliseconds. The service bounds the effective wait, so a client must not assume the requested duration is exact.

limit controls only the requested poll batch size. It is not an execution concurrency limit; each client chooses its own bounded concurrency.

A 204 No Content response means the poll completed without commands. Issue another poll. When a poll response arrives, record the local receipt time as soon as the response headers are available and before decoding the body. Use one receipt time for every command in the response. A 200 OK response contains a JSON envelope:

{
  "commands": [
    {
      "request_id": "req_123",
      "shard_token": "opaque-shard-token",
      "command_type": "jsonrpc",
      "channel": "main",
      "created_at": "2026-01-01T00:00:00Z",
      "response_timeout": "30s",
      "headers": {
        "Mcp-Session-Id": ["session_123"]
      },
      "jsonrpc": {
        "jsonrpc": "2.0",
        "id": "rpc_123",
        "method": "tools/list",
        "params": {}
      }
    }
  ]
}

Common command fields:

Field Meaning
request_id Opaque correlation ID. Echo it as request_id in the response body.
shard_token Opaque routing token. Echo it only in X-Tunnel-Shard-Token when posting the response.
command_type Discriminator for the command shape.
channel Logical MCP channel; defaults to main when absent. Echo it in the response body.
created_at RFC 3339 enqueue timestamp.
response_timeout Optional relative duration for the complete command lifecycle, anchored when the poll response is received.
headers Multi-valued headers to apply to the MCP request.

Response timeout

When present, response_timeout is a relative duration for the complete command lifecycle, anchored when the poll response is received. Its wire grammar is:

ResponseTimeout = 1*DIGIT TimeoutUnit
TimeoutUnit     = "ns" / "us" / "ms" / "s" / "m" / "h"

The value contains one non-negative integer and one lowercase unit. 30s, 4500ms, and 0s are valid. Fractions such as 4.5s, signed values such as -1s or +1s, compound values such as 1m30s, JSON strings such as " 1s" or "1s " that contain whitespace, exponents such as 1e3s, unknown units such as 30d, and overflowing values such as 999999999999999999999999h are invalid. A JSON number such as 30 is also invalid because the wire value must be a string.

An absent or JSON null value retains legacy no-deadline behavior. The official Go decoder also fails open for malformed values, wrong JSON types, unknown units, and values that overflow its duration range: the command remains decodable and retains legacy behavior. At the contract level, a valid zero such as 0s represents immediate expiry.

Compatibility is per command, including when tunnel-service instances produce different payload shapes during a mixed deployment:

Poll command Released official Go client without this field Contract-aware client
response_timeout omitted or null Decodes normally with legacy behavior. Decodes normally with legacy behavior.
Valid response_timeout present encoding/json ignores the unknown property; legacy behavior is retained. Anchors a local deadline at poll-response receipt and enforces it across MCP work and response delivery.
Malformed, wrong-type, unknown-unit, or overflowing value present encoding/json ignores the unknown property; legacy behavior is retained. Decoding succeeds and legacy behavior is retained.

Previously generated OpenAPI clients are outside this compatibility guarantee. Command schemas remain open with additionalProperties: true, and clients must accept unknown future command properties.

The receipt time must retain the platform's monotonic-clock component. For a valid timeout, derive the local deadline without adding another allowance:

local_response_deadline = local_poll_response_received_at + response_timeout

No wall-clock synchronization or server-time field is required, and clients must not derive this deadline from created_at. The deadline bounds the whole command lifecycle: MCP connect, write, read, and the response POST. Drop a command that is already expired without contacting MCP or posting a response. If the deadline passes during MCP work, cancel the operation and close its connection. If it passes after MCP completes, cancel the response POST without closing a shared connection that may already serve another command. Never synthesize a late error response. Progress notifications do not restart the deadline.

jsonrpc commands

For command_type: "jsonrpc", jsonrpc is the raw JSON-RPC request or notification to send to the MCP server. Preserve JSON-RPC IDs and do not reinterpret the payload as a tunnel-protocol object.

session_termination commands

For command_type: "session_termination", close the Streamable HTTP session identified by the Mcp-Session-Id header. The command has no jsonrpc field. After closing the session, post a response with resp_type: "session_termination_response", typically resp_code: 204, and no resp_json.

Future command types

Dispatch on command_type, not on field presence. If a client receives an unknown command type, it must not reinterpret it as JSON-RPC. Log the unsupported discriminator with the opaque request_id, continue serving known commands, and keep polling.

Posting a response

Every response POST must include the shard_token from the polled command:

POST /v1/tunnels/tunnel_123/response HTTP/1.1
Authorization: Bearer <tunnel-api-key>
Content-Type: application/json
X-Tunnel-Shard-Token: opaque-shard-token

The shard token belongs in the HTTP header only; never put it in the JSON body. X-Client-Request-Id is optional diagnostic correlation when the client has one.

JSON-RPC result example:

{
  "request_id": "req_123",
  "channel": "main",
  "resp_json": {
    "jsonrpc": "2.0",
    "id": "rpc_123",
    "result": {
      "tools": []
    }
  },
  "resp_headers": {
    "Content-Type": ["application/json"]
  },
  "resp_code": 200,
  "resp_type": "jsonrpc_response"
}

Response fields:

Field Required Meaning
request_id yes The polled command's opaque request ID.
channel no Logical channel; send the command's channel when present.
resp_json depends JSON-RPC payload; omit for acknowledgment-only responses.
resp_headers no Multi-valued upstream MCP response headers.
resp_code yes HTTP-style status code from the MCP interaction.
resp_type no Payload discriminator; defaults to jsonrpc_response.

Supported resp_type values:

Value Final? Use
jsonrpc_response yes Terminal JSON-RPC result or error with resp_json.
jsonrpc_notify no Intermediate JSON-RPC notification with resp_json.
notify_ack yes Terminal acknowledgment for a JSON-RPC notification that has no result.
session_termination_response yes Terminal acknowledgment after closing an MCP session.

Structured MCP errors and synthesized tunnel failures

When the target returns a valid JSON-RPC error, preserve its request id and exact error.code, error.message, and error.data values in resp_json. Preserve the actual target HTTP status in resp_code and the existing protocol-relevant response-header allowlist in resp_headers. This includes MCP capability error -32003 and version error -32004; do not replace or decorate either with -32603.

At the connector boundary, tunnel-service maps every informational 1xx status to 502 because an informational response cannot terminate the request. Among final-response statuses, it preserves values recognized by its HTTP runtime, maps an unrecognized 2xx to 200, and maps any other unrecognized status through 599 to 502. For synthesized target failures, upstream_status retains the original error status.

Only when no valid MCP error can be recovered may a client synthesize JSON-RPC -32603. A synthesized failure may carry bounded provenance at error.data.tunnel_failure:

{
  "request_id": "req_123",
  "channel": "main",
  "resp_json": {
    "jsonrpc": "2.0",
    "id": "rpc_123",
    "error": {
      "code": -32603,
      "message": "Bad Gateway",
      "data": {
        "tunnel_failure": {
          "version": 1,
          "source": "transport_closed",
          "upstream_response_received": false
        }
      }
    }
  },
  "resp_headers": {
    "Content-Type": ["application/json"]
  },
  "resp_code": 502,
  "resp_type": "jsonrpc_response"
}

The machine-readable schema is published as x-tunnel-failure-schema on the resp_json OpenAPI property. Its current known fields are:

Field Required Contract
version yes Positive integer. Version 1 is current; readers must tolerate future positive versions.
source yes Bounded string. Known values are listed below; readers must tolerate unknown values.
upstream_response_received yes Whether an actual target HTTP response was received.
upstream_status no Target HTTP error status from 400 through 599; valid only for source: target_http with upstream_response_received: true.

Known version-1 source values are target_http, dns, tls, connect, transport_closed, timeout, protocol, and client_internal. target_http means an actual target HTTP error response was received and requires upstream_status. transport_closed means the target connection or pipe was already closed or became unusable before a target response, so upstream_response_received must be false and upstream_status must be omitted. A synthesized outer resp_code such as 502 is the tunnel response status; it is not evidence that the target returned that status.

The provenance object is optional and additive. Existing clients may omit it, existing services continue accepting the nested value inside opaque resp_json, and contract-aware readers use only recognized safe fields. Unknown versions, sources, and fields must fall back to generic tunnel-failure behavior and must not be copied into logs or metrics. Never put raw target URLs, response bodies, arbitrary exception text, credentials, or tokens in provenance. This contract does not use capability negotiation or an additional MCP handshake.

For a JSON-RPC request with an ID, a client may post zero or more jsonrpc_notify payloads while processing the command, followed by one terminal jsonrpc_response. Every POST for the command must reuse its request_id in the body and its shard_token in the X-Tunnel-Shard-Token header, and should echo its channel when present. A jsonrpc_notify does not complete the command. notify_ack is the terminal acknowledgment for a JSON-RPC notification without an ID; it is not a progress event.

A successful POST returns:

{
  "status": "ok"
}

Errors, retries, and concurrency

  • Keep polling until the process is stopped; 204 is normal, not an error.
  • For polls, retry transient network failures, 429, and 5xx with bounded exponential backoff and jitter.
  • For response POSTs, retry transient network failures, 408, 429, 502, 503, and 504 with bounded exponential backoff and jitter. Preserve the exact body, headers, correlation values, and caller deadline across attempts; do not retry other 4xx responses.
  • Retryable 429 and 503 responses may include the standard Retry-After header in either delta-seconds form (for example, 5) or HTTP-date form. Treat a valid value as a minimum delay in addition to local backoff and jitter. Ignore malformed, negative, and expired values, cap excessive values, and never wait past cancellation or the command deadline.
  • Treat 401 and 403 as authentication or authorization failures that need operator action instead of a tight retry loop.
  • A response POST can return 404 when the request has already been fulfilled or is no longer pending. Treat that command as terminal and do not replay the MCP operation.
  • A client chooses its own bounded execution concurrency. Concurrent command processing does not require overlapping poll requests; one poll loop can submit returned commands to workers.
  • Commands may complete in any order. Correlation is always per command: pair each request_id, channel, and shard_token from one poll item with every response for that item.
  • Preserve multi-valued headers. Do not collapse repeated values into a comma-separated string unless the MCP transport itself requires it.

Language-neutral implementation sketch

workers = bounded_worker_pool(client_defined_concurrency)

process(command, deadline):
  if deadline is not none:
    if deadline has passed:
      return
    apply deadline to MCP work and response delivery

  # Dispatch yields zero or more notifications, then a terminal response.
  for response in dispatch_by_command_type(command):
    POST /v1/tunnels/{tunnel_id}/response
      header X-Tunnel-Shard-Token = command.shard_token
      body.request_id = command.request_id
      body.channel = command.channel
      body.resp_* = response

loop:
  poll = GET /v1/tunnels/{tunnel_id}/poll?limit=25&timeout_ms=15000
  poll_received_at = monotonic_now()  # immediately when response headers arrive

  if poll.status == 204:
    continue
  if poll.status != 200:
    handle_control_plane_error(poll)
    continue

  for command in poll.body.commands:
    deadline = none
    timeout = parse_optional_response_timeout(command.response_timeout)
    if timeout is valid:
      deadline = poll_received_at + timeout

    workers.submit(process, command, deadline)

Implementation checklist

  • Generate or hand-write models from openapi.json.
  • Send bearer auth and stable client name/version headers.
  • Send the optional MCP server information declaration alongside the stable client instance ID on control-plane requests.
  • Use only the canonical plural endpoints.
  • Handle 200 and 204 poll responses.
  • Record one monotonic receipt time before decoding a successful poll response.
  • Parse the optional relative response_timeout, fail open when it is missing or invalid, and treat 0s as immediately expired.
  • Bound MCP work and response delivery by receipt time plus the timeout without adding another skew, and drop expired commands without a response.
  • Support both documented command_type values.
  • Preserve raw JSON-RPC payloads and multi-valued headers.
  • Echo request_id, channel, and shard_token in the correct locations.
  • Use bounded command concurrency and preserve correlation when responses complete out of order.
  • Forward non-final jsonrpc_notify payloads before the terminal response.
  • Cover each response discriminator with fixtures.
  • Ignore unknown JSON fields for forward compatibility.
  • Validate fixtures against the OpenAPI document in CI.