This document is designed to be shared with an enterprise customer. It explains how to:
- Create and manage a tunnel in the OpenAI tunnel control plane.
- Deploy the tunnel client inside your network to reach your internal MCP server.
- Configure a connector in ChatGPT to use the OpenAI-hosted MCP tunnel URL.
You will not expose your MCP server publicly. Instead, an outbound-only tunnel client inside your network pulls work from OpenAI and forwards it to your MCP server.
flowchart LR
subgraph openai["OpenAI"]
ui["ChatGPT connector setup"]
runtime["OpenAI connector runtime"]
tunnel["OpenAI-hosted MCP tunnel endpoint"]
end
subgraph customer["Customer network"]
client["tunnel-client"]
mcp["Private MCP server"]
end
ui --> runtime
runtime -->|"MCP JSON-RPC<br/>POST /v1/mcp/{tunnel_id}"| tunnel
client ==>|"Outbound HTTPS long-poll<br/>GET /v1/tunnels/{tunnel_id}/poll"| tunnel
client ==>|"Outbound HTTPS response<br/>POST /v1/tunnels/{tunnel_id}/response"| tunnel
client -->|"Private MCP request"| mcp
classDef openaiNode fill:#eef5ff,stroke:#4a6fa5,color:#172033
classDef customerNode fill:#eefaf4,stroke:#3f7f5f,color:#172033
class ui,runtime,tunnel openaiNode
class client,mcp customerNode
For a deeper explanation and more diagrams, see
architecture.md.
- Tunnel: A logical identifier that binds together:
- the connector's ChatGPT tunnel selection (or pasted tunnel ID), and
- the tunnel-client instance configured with that same identifier.
- Tunnel ID (
tunnel_id): The identifier used in:- ChatGPT connector setup: selected from the tunnel dropdown or pasted into the tunnel ID field
- tunnel-client control plane:
/v1/tunnels/{tunnel_id}/polland/v1/tunnels/{tunnel_id}/response - Format:
tunnel_followed by 32 lowercase hexadecimal characters.
- OpenAI-hosted MCP tunnel endpoint: The OpenAI-managed virtual MCP server
endpoint that ChatGPT targets for the selected
tunnel_id. - Tunnel Client: A customer-run process that:
- long-polls OpenAI for MCP requests for its
tunnel_id, and - forwards them to your MCP server.
- long-polls OpenAI for MCP requests for its
-
Current ChatGPT connector UI uses Tunnel mode, where the operator either:
- selects an available tunnel from the dropdown, or
- pastes a
tunnel_idmanually.
-
Under the hood, ChatGPT still sends requests to the OpenAI-hosted MCP tunnel endpoint for that tunnel:
<OPENAI_MCP_TUNNEL_BASE_URL>/v1/mcp/<tunnel_id>
- Tunnel client uses the Tunnel control-plane base URL (host root) and derives:
<CONTROL_PLANE_BASE_URL>/v1/tunnels/<tunnel_id>/poll
<CONTROL_PLANE_BASE_URL>/v1/tunnels/<tunnel_id>/response
OpenAI will provide, or your rollout will let you create:
- OpenAI MCP tunnel base URL (the base host to use in the connector UI)
- Tunnel control-plane base URL for the tunnel client (defaults to
https://api.openai.comunless OpenAI provides a different rollout host) - Tunnel client API key (for tunnel client authentication)
- Tunnel management (admin) API access so you can create/manage tunnel IDs (if applicable for your rollout)
You will provide:
- Your MCP server URL (reachable from wherever you run the tunnel client)
Depending on your rollout, either:
- Create a tunnel for you and provide the resulting
tunnel_id, or - Let you create one yourself from Platform tunnel settings or the Tunnel Management API.
Use these exact setup pages when you need to create or inspect the handoff values:
- Tunnels management and supported tunnel-client download:
https://platform.openai.com/settings/organization/tunnels - Organization roles:
https://platform.openai.com/settings/organization/people/roles - Organization groups:
https://platform.openai.com/settings/organization/people/groups - Runtime API keys:
https://platform.openai.com/settings/organization/api-keys - Admin API keys:
https://platform.openai.com/settings/organization/admin-keys - ChatGPT connector settings:
https://chatgpt.com/#settings/Connectors
Before anyone creates keys or tunnels, assign the Platform roles described in
permissions.md:
- Runtime daemon operators and the principal that creates
CONTROL_PLANE_API_KEYneed Tunnels Read + Use. - Tunnel CRUD operators need Tunnels Read + Manage.
- Operators who create
OPENAI_ADMIN_KEYneed Platform admin-key permission in addition to the tunnel permissions needed for their workflow.
When creating CONTROL_PLANE_API_KEY in Platform Runtime API keys, choose
Restricted and select Tunnels Read + Use. Do not use All or an
admin key for the long-lived daemon.
The Platform UI maps those labels to these permission atoms:
api.organization.tunnel.readapi.organization.tunnel.writeapi.organization.tunnel.use
If you do not have the right permission, tunnel CRUD can fail with 403. If a
tunnel is created without the ChatGPT workspace ID, it may be visible in
Platform but absent from the ChatGPT connector tunnel picker.
These endpoints manage tunnel metadata. They do not deploy the tunnel client for you.
- Create:
POST /v1/tunnels - Get:
GET /v1/tunnels/{tunnel_id} - List:
GET /v1/tunnels?organization_id=...orworkspace_id=...ortenant_id=... - Update:
POST /v1/tunnels/{tunnel_id} - Delete:
DELETE /v1/tunnels/{tunnel_id}
AuthZ note: list, create, update, and delete require an admin API
key plus Tunnels Manage (api.organization.tunnel.write) in the caller's
active organization/workspace context. get <tunnel_id> can use the runtime key
for read-only metadata lookup.
curl -X POST <TUNNEL_MGMT_API_BASE_URL>/v1/tunnels \
-H "Authorization: Bearer $OPENAI_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "BigCo Prod MCP Tunnel",
"description": "Routes BigCo connector traffic to the on-prem MCP server",
"workspace_ids": ["<WORKSPACE_ID>"]
}'The response includes the new tunnel's id. Use this as your tunnel_id.
You can manage tunnels with the bundled tunnel-client admin tunnels commands
instead of crafting raw curl requests.
Prereqs:
- Set an admin key:
export OPENAI_ADMIN_KEY=<admin key> - Optional: override the control plane host (defaults to prod):
export CONTROL_PLANE_BASE_URL=https://api.openai.com - Provide at least one scope flag:
--organization-idand/or--workspace-id(duplicates are rejected).
Examples:
# Create (requires at least one org/workspace id)
bin/tunnel-client admin tunnels create \
--name "BigCo Prod MCP Tunnel" \
--description "Routes BigCo connector traffic to the on-prem MCP server" \
--workspace-id "<WORKSPACE_ID>"
# After create succeeds, wait 25-30 seconds before expecting the tunnel to be active and ready.
# List by workspace (exactly one filter required: org OR workspace OR tenant)
bin/tunnel-client admin tunnels list --workspace-id "<WORKSPACE_ID>" --json
# Get by id
bin/tunnel-client admin tunnels get "<tunnel_id>"
# Update (PUT-like replacement for org/workspace lists when flags are present)
bin/tunnel-client admin tunnels update "<tunnel_id>" \
--name "Renamed Tunnel" \
--organization-id "<ORG_ID>"
# Delete (requires --confirm)
bin/tunnel-client admin tunnels delete "<tunnel_id>" --confirmUse --json on any subcommand for structured output.
When creating a connector in ChatGPT, use Connection: Tunnel.
Then either:
- select the tunnel from the Available tunnels dropdown, or
- paste the
tunnel_idinto the tunnel field if it is not listed yet.
Do not paste your private MCP server URL into ChatGPT.
- Connection mode:
Tunnel - Tunnel value: your
tunnel_id(for exampletunnel_0123456789abcdef0123456789abcdef)
Older or internal setup surfaces may still refer to an MCP Server URL and show the underlying OpenAI-hosted endpoint shape:
<OPENAI_MCP_TUNNEL_BASE_URL>/v1/mcp/<tunnel_id>
That URL shape remains the transport path used by OpenAI, but in the current
ChatGPT UI operators should choose Tunnel mode and provide the tunnel_id,
not the full URL.
Connectors send a single JSON-RPC object per request:
- Method:
POST - Path:
/v1/mcp/{tunnel_id} - Body: a JSON-RPC object (example format):
{ "jsonrpc":"2.0", "id":1, "method":"tools/call", "params":{ "name":"...", "arguments":{} } }Optional session header (used for MCP session continuity):
Mcp-Session-Id: <session-id>
You do not need to manage connector authentication headers; those are handled by the OpenAI connector runtime.
You can run the tunnel client as a:
- host binary (VM / server / systemd)
- Docker container
- Kubernetes sidecar (same Pod as MCP server) or dedicated Deployment
Open Platform tunnel settings, then use the download link there or the latest public tunnel-client release from:
https://github.com/openai/tunnel-client/releases/latest
If you already have a binary, start with the supported CLI path before hand-editing configuration:
tunnel-client help quickstart
export CONTROL_PLANE_API_KEY="sk-..."
tunnel-client init \
--sample sample_mcp_stdio_local \
--profile local-stdio \
--tunnel-id tunnel_0123456789abcdef0123456789abcdef \
--mcp-command "python /path/to/server.py"
tunnel-client doctor --profile local-stdio --explain
tunnel-client run --profile local-stdioFor an HTTP MCP server, switch to
--sample sample_mcp_remote_no_auth and use
--mcp-server-url https://mcp.internal.example.com/mcp instead of
--mcp-command.
From the tunnel client host/network:
- Outbound HTTPS to the tunnel control plane base URL (port 443)
- Outbound HTTP(S) to your MCP server URL (
MCP_SERVER_URL) - No inbound ports are required for the tunnel itself
You must configure:
CONTROL_PLANE_API_KEY: tunnel client auth (provided by OpenAI)CONTROL_PLANE_TUNNEL_ID: yourtunnel_idfrom Step 1MCP_SERVER_URLorMCP_COMMAND: your internal MCP endpoint or local stdio command, reachable from the tunnel client
Recommended configuration:
CONTROL_PLANE_BASE_URL: the control-plane host root (provided by OpenAI; defaults tohttps://api.openai.com)CONTROL_PLANE_MAX_INFLIGHT_REQUESTS: local polled-command buffer capacity (default20); a full buffer pauses pollingMCP_MAX_CONCURRENT_REQUESTS: requests actively dispatched to the MCP server (default10); this is separate from the local buffer capacityLOG_FORMAT=jsonandLOG_LEVEL=infofor production logs
Optional control-plane mTLS configuration:
CONTROL_PLANE_CLIENT_CERT: path to the PEM client certificate for OpenAI control-plane HTTPSCONTROL_PLANE_CLIENT_KEY: path to the PEM client private key paired withCONTROL_PLANE_CLIENT_CERT- When those are configured with the default
CONTROL_PLANE_BASE_URL=https://api.openai.com, tunnel-client automatically callshttps://mtls.api.openai.com. - Control-plane mTLS is additive to
CONTROL_PLANE_API_KEY; it does not replace API-key auth. - If the runtime API key's org/project requires API mTLS and no valid control-plane certificate is presented, the control-plane request fails with code
certificate_required.
Optional mTLS configuration for MCP server authentication:
MCP_CLIENT_CERT: path to PEM client certificate used for outbound MCP HTTPSMCP_CLIENT_KEY: path to PEM client private key (must be paired withMCP_CLIENT_CERT)
Do not reuse MCP_CLIENT_CERT / MCP_CLIENT_KEY for the OpenAI control-plane hop;
those settings authenticate tunnel-client to the customer MCP server, while
CONTROL_PLANE_CLIENT_CERT / CONTROL_PLANE_CLIENT_KEY authenticate tunnel-client
to OpenAI's mTLS API endpoint.
Operational helpers (optional):
HEALTH_LISTEN_ADDR(default127.0.0.1:8080; set to:8080only when a trusted remote probe needs access, or127.0.0.1:0only when you explicitly want an ephemeral loopback port)HEALTH_URL_FILE(write resolved health URL; recommended withHEALTH_LISTEN_ADDR=:0)PID_FILE(write pid on start, remove on stop)
export CONTROL_PLANE_API_KEY="sk-..."
export CONTROL_PLANE_TUNNEL_ID="<tunnel_id>"
export MCP_SERVER_URL="https://mcp.internal.example.com/mcp"
export CONTROL_PLANE_BASE_URL="<CONTROL_PLANE_BASE_URL>"
./tunnel-client run --log.level=info --log.format=jsonThe container examples use an illustrative v1.2.3 tag. Replace it with a
released version or digest.
docker run --rm \
-e CONTROL_PLANE_API_KEY="sk-..." \
-e CONTROL_PLANE_TUNNEL_ID="<tunnel_id>" \
-e CONTROL_PLANE_BASE_URL="<CONTROL_PLANE_BASE_URL>" \
-e MCP_SERVER_URL="https://mcp.internal.example.com/mcp" \
-e LOG_LEVEL="info" \
-e LOG_FORMAT="json" \
-e HEALTH_LISTEN_ADDR=":8080" \
-p 8080:8080 \
ghcr.io/openai/tunnel-client:v1.2.3apiVersion: v1
kind: Pod
metadata:
name: mcp-with-tunnel
spec:
containers:
- name: mcp-server
image: your-mcp-image:latest
ports:
- containerPort: 3000
- name: tunnel-client
image: ghcr.io/openai/tunnel-client:v1.2.3
env:
- name: CONTROL_PLANE_TUNNEL_ID
value: <tunnel_id>
- name: CONTROL_PLANE_BASE_URL
value: <CONTROL_PLANE_BASE_URL>
- name: MCP_SERVER_URL
value: http://127.0.0.1:3000/mcp
- name: CONTROL_PLANE_API_KEY
valueFrom:
secretKeyRef:
name: openai-api-key
key: api_key
ports:
- name: health
containerPort: 8080
livenessProbe:
httpGet: { path: /healthz, port: health }
readinessProbe:
httpGet: { path: /readyz, port: health }Local GET /health?details=true gives bounded component diagnostics;
GET /health/mcp shows observed main stdio discovery. Plain /health is compact
unless --health.show-details=true (HEALTH_SHOW_DETAILS, YAML
health.show_details) is enabled; the default is false. These routes require
loopback TCP or the configured Unix socket and do not start probes. Keep the
orchestrator's /healthz and /readyz probes unchanged. See the
health reference for copy/paste examples and interpretation.
The tunnel client exposes:
GET /healthzGET /readyzGET /metrics(Prometheus)
By default these endpoints bind to loopback. If you set HEALTH_LISTEN_ADDR=:8080
for Docker or Kubernetes probes, keep that port on a trusted container, Pod, or
operator network.
curl -fsS "http://127.0.0.1:8080/healthz"
curl -fsS "http://127.0.0.1:8080/readyz"In the ChatGPT connector UI:
- Save the connector configuration after selecting a tunnel or pasting the
tunnel_id. - Run a "test connection" / "test tool call" flow (if available).
On your MCP server, confirm you observe:
- a JSON-RPC request arriving, and
- the corresponding response being generated.
- The OpenAI-hosted MCP tunnel endpoint receives one JSON-RPC request.
- It enqueues the request under your
tunnel_id. - It waits, with the HTTP request held open, for the tunnel client to return the final response.
- If the connector includes
Accept: text/event-stream, the response is streamed as SSE. JSON-RPC notifications are forwarded as they arrive, and a final JSON-RPC response closes the stream. GET /v1/mcpis not supported; connectors must use POST.
- The tunnel client long-polls
/pollfor queued work.- Response is
204 No Contentwhen no work is available. - Response is
200 OKwith a list of commands when work is available.
- Response is
- The tunnel client posts results to
/response(including:request_id,- final JSON-RPC response payload (or JSON-RPC notifications), and
- selected response headers and status code from the MCP server).
- JSON-RPC notifications are sent with
resp_type=jsonrpc_notifyand are forwarded to the connector stream when SSE is enabled.
Timeouts are configurable by OpenAI. Typical behaviors:
- Connector requests may time out if the MCP server does not respond in time.
- Long-poll requests complete periodically so the tunnel client can reconnect quickly.
If you expect long-running MCP calls, coordinate timeout values with OpenAI.
- SSE is opt-in: connectors must send
Accept: text/event-streamto receive streamed notifications. Otherwise they receive a singleapplication/jsonresponse. - The OpenAI tunnel queueing and timeout behavior is optimized for deterministic request/response flows.
- Redundant tunnel clients: multiple active
tunnel-clientinstances may poll the sametunnel_idwhen they target equivalent MCP backends that are stateless or provide session affinity/shared session state, such as a single HTTP MCP host or a load balancer that keeps MCP sessions sticky to the right backend. Tunnel service keeps one shared queue per tunnel and does not assign messages to a specifictunnel-clientreplica. Each queued message is delivered to whichever replica polls it first. For statefullocalhostor non-sticky per-replica HTTP MCP servers, keep one activetunnel-clientper tunnel or use distinct tunnel IDs per replica. - Stdio deployments: multiple active
tunnel-clientinstances sharing a tunnel ID with stdio bindings are not supported. Each instance starts a separate MCP child, so initialization and later tool calls can reach different children. Run one active instance per tunnel ID and stop it before starting its replacement, including during upgrades. On Kubernetes, usereplicas: 1withstrategy.type: Recreateto avoid update overlap. See stdio deployment limits. - Secrets hygiene: treat all API keys/tokens as secrets; store them in a secrets manager and rotate them on your standard cadence.
- Logging safety: do not enable raw HTTP logging except in tightly controlled debugging sessions, as it can expose sensitive headers/bodies.