# Architecture `tunnel-client` connects OpenAI-hosted products to a private MCP server without requiring the customer to expose that MCP server to the public internet. The customer runs a small agent inside their network. The agent keeps an outbound HTTPS connection to the OpenAI tunnel service, receives MCP work, forwards it to the configured MCP server, and returns the response through the same tunnel. ## Customer-shareable summary - **No inbound firewall rule is required for the MCP server.** The tunnel client initiates all tunnel traffic as outbound HTTPS to OpenAI. - **The MCP server remains private.** OpenAI products call an OpenAI-hosted MCP tunnel URL; the customer's internal MCP URL is only used by the tunnel client. - **Traffic is request/response with backpressure.** The client long-polls for queued work, forwards only the work it can process, and posts the result back to OpenAI. - **Operations stay local.** Health, readiness, metrics, logs, and the optional admin UI are exposed by the tunnel client for the customer's operators. ## Solution overview ```mermaid flowchart LR subgraph openai["OpenAI"] product["ChatGPT, Codex, Responses API, or AgentKit"] tunnel["OpenAI tunnel service"] end subgraph customer["Customer network"] client["tunnel-client"] mcp["Private MCP server"] ops["Local health, metrics, and admin UI"] end product -->|"MCP JSON-RPC request"| tunnel client ==>|"Outbound HTTPS long-poll
GET /v1/tunnels/{tunnel_id}/poll"| tunnel client ==>|"Outbound HTTPS response
POST /v1/tunnels/{tunnel_id}/response"| tunnel client -->|"Streamable HTTP, stdio, or in-memory MCP"| mcp client -.->|"Loopback or operator network"| ops classDef openaiNode fill:#eef5ff,stroke:#4a6fa5,color:#172033 classDef customerNode fill:#eefaf4,stroke:#3f7f5f,color:#172033 classDef opsNode fill:#fff7e6,stroke:#9a6b00,color:#172033 class product,tunnel openaiNode class client,mcp customerNode class ops opsNode ``` In the current ChatGPT connector UI, operators attach a tunnel by selecting an available tunnel or pasting a `tunnel_id`. Under the hood, the product still targets the OpenAI tunnel service endpoint `/v1/mcp/`. The tunnel client is configured separately with the same `tunnel_id`, an API key, and the private MCP server address that is reachable from inside the customer network. See [`connectors.md`](connectors.md) for connector-specific setup, channel routing, and troubleshooting notes. ## Request lifecycle ```mermaid sequenceDiagram autonumber participant Product as OpenAI product participant Tunnel as OpenAI tunnel service participant Client as tunnel-client participant MCP as Customer MCP server Client->>Tunnel: Long-poll for work Product->>Tunnel: POST /v1/mcp/{tunnel_id} Tunnel-->>Client: Return queued JSON-RPC command Client->>MCP: Forward MCP JSON-RPC request MCP-->>Client: Return MCP response or notifications Client->>Tunnel: POST /v1/tunnels/{tunnel_id}/response Tunnel-->>Product: Return final response or SSE stream ``` For streaming requests, JSON-RPC notifications are posted back with `resp_type=jsonrpc_notify` and forwarded to the connector stream when the connector requested `text/event-stream`. A final JSON-RPC response closes the stream. ## Trust boundaries and network paths ```mermaid flowchart TB subgraph internet["OpenAI-managed public edge"] edge["OpenAI MCP tunnel URL
/v1/mcp/{tunnel_id}"] control["Tunnel control plane
/v1/tunnels/{tunnel_id}/*"] end subgraph private["Customer-controlled environment"] client["tunnel-client process"] mcp["Private MCP server"] proxy["Optional outbound proxy"] ca["Optional custom CA bundle"] mtls["Optional MCP mTLS client cert"] end edge --> control client -->|"Outbound HTTPS, API-key authenticated"| control client -->|"Private network request"| mcp client -.->|"If configured"| proxy ca -.-> client mtls -.-> client classDef public fill:#eef5ff,stroke:#4a6fa5,color:#172033 classDef privateNode fill:#eefaf4,stroke:#3f7f5f,color:#172033 classDef option fill:#f8f8f8,stroke:#7a7a7a,color:#172033 class edge,control public class client,mcp privateNode class proxy,ca,mtls option ``` Choosing **Connection: Tunnel** changes how an OpenAI product reaches the MCP server; it does not make MCP authentication or MCP data flow fully local. A custom app that uses Tunnel keeps the MCP listener private, but its requests, responses, and applicable auth artifacts still follow the paths below. ### Auth and data flow matrix | Flow or artifact | Current path | Customer-local part | | --- | --- | --- | | MCP JSON-RPC requests, tool arguments, responses, and stream events | Cross the OpenAI product runtime, tunnel-service queue, and the tunnel client's control-plane connection. | Only the final tunnel-client-to-MCP hop is local. | | Connector-forwarded `Authorization` | Crosses OpenAI with the queued request headers. For HTTP MCP, tunnel-client applies it only to the configured MCP server origin; stdio has no HTTP-header hop. OpenAI-internal, IP-forwarding, fixed proxy/hop-by-hop, and `Connection`-nominated headers are blocked. | A forwarded bearer token is not local-only. | | Tunnel runtime API key (`CONTROL_PLANE_API_KEY`) | Sent from tunnel-client to OpenAI as bearer auth for poll, response, and metadata control-plane calls; it is not forwarded to the MCP server. | The key can be sourced and stored locally, but it does not remain local. | | OAuth discovery; DCR, token, and revocation | Connector-facing protected-resource metadata uses the tunnel/Harpoon path. Authorization-server metadata does so only when its issuer was rewritten to a Harpoon-backed route; registered `harpoon://` DCR, token, and revocation endpoints do as well. Their requests and responses cross OpenAI. Public `http(s)` OAuth endpoints remain unchanged and are called by the product OAuth caller rather than through Tunnel. | For registered targets, the final Harpoon call originates inside the customer network. | | Browser authorization | The OAuth shim does not rewrite `authorization_endpoint`; the supported auto-registered path leaves the browser to contact the upstream authorization server directly. | The browser-to-authorization-server hop is direct. | | OAuth callback and authorization-code exchange | The callback target is selected by the product/app OAuth flow. In the OpenAI connector flow, OpenAI receives and processes the callback/code and performs the token exchange; a shimmed token endpoint changes only the final hop. Client credentials, refresh tokens, and token responses remain in that product OAuth path when present. | For a shimmed token endpoint, the final tunnel-client/Harpoon-to-authorization-server hop is local. | | Env- or file-backed `MCP_EXTRA_HEADERS` | For HTTP MCP, values are resolved by tunnel-client and injected only for the configured MCP server origin: the exact MCP path for runtime requests, and the same origin for discovery/probe requests. This mechanism does not send them to the OpenAI control plane or unrelated auth-server hosts; connector-forwarded headers apply last and can override them. | A static HTTP backend credential can stay on the tunnel-client-to-MCP hop. Stdio has no HTTP-header injection. | | MCP-side mTLS | Applies only to `http-streamable` MCP. The private key stays local and the client certificate is presented only to the configured MCP origin; it is not control-plane auth. | The TLS handshake is on the HTTP tunnel-client-to-MCP hop. Stdio has no TLS hop, and non-HTTP binding mTLS is rejected. | ### Choosing the right path - **Strict-local-auth is not supported by Tunnel.** If every bearer token or auth artifact must stay outside OpenAI, do not use Secure MCP Tunnel for that requirement. - **Local credential injection and MCP-side mTLS are narrower supported cases.** For HTTP MCP, use env- or file-backed `MCP_EXTRA_HEADERS` when a static backend credential must be added only on the final MCP hop, or MCP-side mTLS when the private key must stay customer-side. MCP payloads and results still traverse OpenAI. - `MCP_EXTRA_HEADERS` is static configuration; it is not dynamic, short-lived, per-request token generation. - **For Codex, use a direct local MCP configuration instead of Tunnel** when strict-local-auth is required: stdio, loopback HTTP, or private HTTP that is reachable from the Codex host. Do not configure raw `/v1/mcp/{tunnel_id}` as a Codex MCP URL. Tool content still enters the normal Codex/model data path. See the [Codex MCP documentation](https://developers.openai.com/codex/extend/mcp). ### Security-review triage Before deciding whether Tunnel fits a customer's requirement, ask: - Which target surface is involved: ChatGPT, a custom app, API, AgentKit, or Codex? - Which auth mode is in use: no auth, forwarded bearer, OAuth, static header, or mTLS? - Which specific artifacts are prohibited from crossing OpenAI: tool payloads, bearer tokens, authorization codes, refresh tokens, client secrets, private keys, or all of them? - Is a local loopback proxy allowed? Security-relevant defaults: - Tunnel-client control-plane calls require the tunnel client's runtime API key; this key is separate from MCP-server authentication. - The MCP server does not need a public listener. - The admin UI and log endpoints are loopback-only by default unless `--allow-remote-ui` is enabled. - A custom CA bundle can extend trust for outbound TLS connections. - MCP mTLS can be configured when the private MCP server requires client certificate authentication. - Raw HTTP logging is disabled by default and should only be enabled for tightly controlled debugging sessions. ## Deployment patterns ```mermaid flowchart LR subgraph sidecar["Kubernetes sidecar: one Pod"] podclient["tunnel-client"] podmcp["MCP container"] podclient -->|"localhost"| podmcp end subgraph dedicated["Kubernetes dedicated Deployment"] deployclient["tunnel-client Deployment"] svcmcp["MCP Service"] deployclient -->|"Cluster DNS"| svcmcp end subgraph vm["VM or host service"] systemd["systemd service"] hostmcp["MCP endpoint"] systemd -->|"Host or private network"| hostmcp end classDef pattern fill:#f8f8f8,stroke:#7a7a7a,color:#172033 class podclient,podmcp,deployclient,svcmcp,systemd,hostmcp pattern ``` Choose the pattern that matches the MCP server's deployment model: - Use a **sidecar** when the MCP server is in the same Pod and can be reached on `localhost`. - Use a **dedicated Deployment** when the MCP server is already exposed through a Kubernetes Service and the tunnel client should be upgraded independently. - Use **VM / systemd** when the MCP server runs on a host or is reachable through private networking outside Kubernetes. ## Runtime components - **CLI / process entry**: `cmd/client` loads config, wires dependencies, and starts the app. - **Configuration**: `pkg/config` handles flags, environment variables, validation, and defaults. - **Control plane**: `pkg/controlplane` builds the HTTP client and runs the poll/response loop. - **Dispatcher**: `pkg/dispatcher` uses a bounded in-memory prefetch queue sized by `control-plane.max-inflight`. Requests actively executing against the MCP server are limited separately by `mcp.max-concurrent-requests`. - **MCP client**: `pkg/mcpclient` handles Streamable HTTP MCP, stdio MCP, header forwarding, and startup probing. - **Channel state and admin UI**: `pkg/adminui` exposes channel status, OAuth state, Harpoon state, log export, and the embedded web UI. - **Operations surface**: `pkg/health`, `pkg/metrics`, `pkg/log`, and `pkg/process` provide health checks, readiness, Prometheus metrics, structured logging, and optional PID-file lifecycle. ## Important behaviors - **Outbound-only tunnel**: tunnel traffic is initiated by the client. The only inbound listener in the client process is the optional local admin/health server. - **Queueing and backpressure**: the poller requests only the number of commands that can fit in the bounded queue, up to `25` per poll. A full queue pauses polling. When all MCP workers are busy, the dispatcher removes one command from the queue and waits for a worker slot. It does not drain another command until a slot is free. Local resident work is therefore bounded by the active worker limit plus the queue capacity and one dispatcher-held command. - **Channel routing**: `main` routes to the configured MCP transport. `harpoon` routes to the embedded Harpoon server and is enabled only when at least one Harpoon target is registered. Additional channels can be configured with channel-qualified MCP bindings. - **Streaming semantics**: requests can stream intermediate JSON-RPC notifications over SSE when the connector asks for `text/event-stream`; a final JSON-RPC response closes the stream. - **Connector GET not supported**: `/v1/mcp` accepts POST requests for MCP JSON-RPC traffic. GET requests do not provide an SSE stream. ## OAuth-protected MCP For OAuth-protected MCP servers, the tunnel client and tunnel service preserve the standard MCP OAuth flow while keeping the MCP server private: - Inbound `Authorization` headers are forwarded to the MCP server through the tunnel client. - Connector-facing protected-resource discovery GETs are queued as tunnel commands and executed from the customer's network by the tunnel client. - `WWW-Authenticate` `resource_metadata` values and discovery payload `resource` URLs are rewritten to OpenAI tunnel-service endpoints for the same `tunnel_id`. - `authorization_servers[0]` from Protected Resource Metadata is treated as the source of truth for auth-server metadata enrichment and Harpoon OAuth target registration. - Metadata is accepted when the returned `issuer` differs from `authorization_servers[0]`, which supports external enterprise identity provider issuer URLs while preserving mismatch diagnostics in logs and state. - Registered `harpoon://` `registration_endpoint`, `token_endpoint`, and `revocation_endpoint` values are rewritten to Tunnel OAuth-shim routes. Their POST requests and responses traverse Tunnel and Harpoon; public `http(s)` endpoint URLs remain unchanged and are called by the product OAuth caller rather than through Tunnel. - A caller that needs to sign `private_key_jwt` for a shimmed token endpoint can explicitly GET the shim token URL first. Tunnel service asks a supporting tunnel client for the exact upstream token endpoint audience and returns only that value; older clients keep the existing POST proxy behavior and fail this optional lookup closed. - The OAuth shim does not rewrite `authorization_endpoint`; the supported auto-registered path leaves browser authorization direct to the upstream authorization server. Tunnel does not expose arbitrary authorization-server routes.