# Secure MCP Tunnel: Enterprise Customer Onboarding 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**. ## What you are setting up 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. ```mermaid 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
POST /v1/mcp/{tunnel_id}"| tunnel client ==>|"Outbound HTTPS long-poll
GET /v1/tunnel/{tunnel_id}/poll"| tunnel client ==>|"Outbound HTTPS response
POST /v1/tunnel/{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`](architecture.md). ## Glossary - **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/tunnel/{tunnel_id}/poll` and `/v1/tunnel/{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. ## Key concept: two different URLs - **Current ChatGPT connector UI uses Tunnel mode**, where the operator either: - selects an available tunnel from the dropdown, or - pastes a `tunnel_id` manually. - **Under the hood**, ChatGPT still sends requests to the OpenAI-hosted MCP tunnel endpoint for that tunnel: ```text /v1/mcp/ ``` - **Tunnel client uses the Tunnel control-plane base URL** (host root) and derives: ```text /v1/tunnel//poll /v1/tunnel//response ``` ## What OpenAI provides to you 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.com` unless 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) --- ## Step 1 - Create (or obtain) a tunnel ID Depending on your rollout, either: 1. **Create a tunnel for you** and provide the resulting `tunnel_id`, or 2. 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` ### Prerequisite: permission to manage or use tunnels Before anyone creates keys or tunnels, assign the Platform roles described in [`permissions.md`](permissions.md): - Runtime daemon operators and the principal that creates `CONTROL_PLANE_API_KEY` need Tunnels **Read** + **Use**. - Tunnel CRUD operators need Tunnels **Read** + **Manage**. - Operators who create `OPENAI_ADMIN_KEY` need Platform admin-key permission in addition to the tunnel permissions needed for their workflow. The Platform UI maps those labels to these permission atoms: - `api.organization.tunnel.read` - `api.organization.tunnel.write` - `api.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. ### Tunnel Management API (admin endpoints) 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=...` *or* `workspace_id=...` *or* `tenant_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 ` can use the runtime key for read-only metadata lookup. ### Example: create a tunnel ```bash curl -X POST /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": [""] }' ``` The response includes the new tunnel's `id`. Use this as your **`tunnel_id`**. --- ### CLI helper (preferred for quick setup) 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=` - 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-id` and/or `--workspace-id` (duplicates are rejected). Examples: ```bash # 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 "" # 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 "" --json # Get by id bin/tunnel-client admin tunnels get "" # Update (PUT-like replacement for org/workspace lists when flags are present) bin/tunnel-client admin tunnels update "" \ --name "Renamed Tunnel" \ --organization-id "" # Delete (requires --confirm) bin/tunnel-client admin tunnels delete "" --confirm ``` Use `--json` on any subcommand for structured output. --- ## Step 2 - Configure the connector in ChatGPT When creating a connector in **ChatGPT**, use **Connection: Tunnel**. Then either: - select the tunnel from the **Available tunnels** dropdown, or - paste the `tunnel_id` into the tunnel field if it is not listed yet. Do **not** paste your private MCP server URL into ChatGPT. ### What to enter in ChatGPT - **Connection mode**: `Tunnel` - **Tunnel value**: your `tunnel_id` (for example `tunnel_0123456789abcdef0123456789abcdef`) ### Legacy/internal note Older or internal setup surfaces may still refer to an **MCP Server URL** and show the underlying OpenAI-hosted endpoint shape: ```text /v1/mcp/ ``` 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. ### What the connector sends (for reference) Connectors send a single JSON-RPC object per request: - **Method**: `POST` - **Path**: `/v1/mcp/{tunnel_id}` - **Body**: a JSON-RPC object (example format): ```json { "jsonrpc":"2.0", "id":1, "method":"tools/call", "params":{ "name":"...", "arguments":{} } } ``` Optional session header (used for MCP session continuity): - `Mcp-Session-Id: ` > You do not need to manage connector authentication headers; those are handled by the OpenAI connector runtime. --- ## Step 3 - Deploy the tunnel client in your environment 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: ```bash 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-stdio ``` For 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`. ### Network requirements 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 ### Required configuration (tunnel client) You must configure: - `CONTROL_PLANE_API_KEY`: tunnel client auth (provided by OpenAI) - `CONTROL_PLANE_TUNNEL_ID`: your `tunnel_id` from Step 1 - `MCP_SERVER_URL` or `MCP_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 to `https://api.openai.com`) - `CONTROL_PLANE_MAX_INFLIGHT_REQUESTS`: buffer size / backpressure (default `20`) - `MCP_MAX_CONCURRENT_REQUESTS`: MCP parallelism (default `10`) - `LOG_FORMAT=json` and `LOG_LEVEL=info` for production logs Optional control-plane mTLS configuration: - `CONTROL_PLANE_CLIENT_CERT`: path to the PEM client certificate for OpenAI control-plane HTTPS - `CONTROL_PLANE_CLIENT_KEY`: path to the PEM client private key paired with `CONTROL_PLANE_CLIENT_CERT` - When those are configured with the default `CONTROL_PLANE_BASE_URL=https://api.openai.com`, tunnel-client automatically calls `https://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 HTTPS - `MCP_CLIENT_KEY`: path to PEM client private key (must be paired with `MCP_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` (default `127.0.0.1:8080`; set to `:8080` only when a trusted remote probe needs access, or `127.0.0.1:0` only when you explicitly want an ephemeral loopback port) - `HEALTH_URL_FILE` (write resolved health URL; recommended with `HEALTH_LISTEN_ADDR=:0`) - `PID_FILE` (write pid on start, remove on stop) ### Configuration examples #### Host binary ```bash export CONTROL_PLANE_API_KEY="sk-..." export CONTROL_PLANE_TUNNEL_ID="" export MCP_SERVER_URL="https://mcp.internal.example.com/mcp" export CONTROL_PLANE_BASE_URL="" ./tunnel-client run --log.level=info --log.format=json ``` #### Docker ```bash docker run --rm \ -e CONTROL_PLANE_API_KEY="sk-..." \ -e CONTROL_PLANE_TUNNEL_ID="" \ -e 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 \ tunnel-client:latest ``` #### Kubernetes (sidecar pattern) ```yaml apiVersion: 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: tunnel-client:latest env: - name: CONTROL_PLANE_TUNNEL_ID value: - name: CONTROL_PLANE_BASE_URL value: - 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 } ``` ### Health and metrics The tunnel client exposes: - `GET /healthz` - `GET /readyz` - `GET /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. --- ## Step 4 - Validate end-to-end ### 1) Validate the tunnel client is running ```bash curl -fsS "http://127.0.0.1:8080/healthz" curl -fsS "http://127.0.0.1:8080/readyz" ``` ### 2) Validate traffic reaches your MCP server 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. --- ## How the tunnel works (deeper detail) ### Connector-facing endpoint: `/v1/mcp/{tunnel_id}` - 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/mcp` is not supported; connectors must use POST. ### Tunnel-client control-plane endpoints: `/v1/tunnel/{tunnel_id}/poll` and `/response` - The tunnel client long-polls `/poll` for queued work. - Response is `204 No Content` when no work is available. - Response is `200 OK` with a list of commands when work is available. - 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_notify` and are forwarded to the connector stream when SSE is enabled. ### Timeouts (high level) 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. --- ## Current limitations (important) - **SSE is opt-in**: connectors must send `Accept: text/event-stream` to receive streamed notifications. Otherwise they receive a single `application/json` response. - The OpenAI tunnel queueing and timeout behavior is optimized for deterministic request/response flows. --- ## Operations & best practices - **One tunnel client per tunnel ID**: run a single active `tunnel-client` instance per `tunnel_id` unless OpenAI explicitly advises otherwise. - **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.