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.
A tunnel client:
- authenticates to
https://api.openai.com; - optionally fetches tunnel metadata for startup diagnostics;
- optionally fetches managed Cloudflare runtime material to launch the bundled
cloudflaredcompanion; - long-polls for commands addressed to one tunnel;
- forwards each command to the configured MCP server; and
- 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.
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.3On 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
mainchannel uses"proc_affinity": true. - A remote Streamable HTTP
mainchannel omitsproc_affinity. - An enabled built-in in-memory
harpoonchannel uses"proc_affinity": truebecause 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.
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.
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.3When 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.1Omitting 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. |
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.
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.
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.
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.
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-tokenThe 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. |
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"
}- Keep polling until the process is stopped;
204is normal, not an error. - For polls, retry transient network failures,
429, and5xxwith bounded exponential backoff and jitter. - For response POSTs, retry transient network failures,
408,429,502,503, and504with bounded exponential backoff and jitter. Preserve the exact body, headers, correlation values, and caller deadline across attempts; do not retry other4xxresponses. - Retryable
429and503responses may include the standardRetry-Afterheader 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
401and403as authentication or authorization failures that need operator action instead of a tight retry loop. - A response POST can return
404when 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, andshard_tokenfrom 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.
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)
- 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
200and204poll 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 treat0sas 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_typevalues. - Preserve raw JSON-RPC payloads and multi-valued headers.
- Echo
request_id,channel, andshard_tokenin the correct locations. - Use bounded command concurrency and preserve correlation when responses complete out of order.
- Forward non-final
jsonrpc_notifypayloads 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.