ChatGPT plugin over Secure MCP Tunnel
Intendant can be used from ChatGPT and Codex through an OpenAI Secure MCP Tunnel without making the daemon reachable from the public internet. The tracked integration kit keeps the reusable pieces in source control and leaves every credential and account-specific registration outside the repository.
ChatGPT / Codex
|
v
OpenAI-hosted tunnel endpoint
^ outbound HTTPS
|
tunnel-client -> 127.0.0.1:18766/mcp
|
v
intendant-mcp-relay.py
|
active port + per-boot
loopback admission token
|
v
Intendant daemon /mcp
OpenAI documents Secure MCP Tunnel as an outbound-only path for private MCP servers. It is suitable for private and developer-mode connections, but it is not itself a public-plugin distribution endpoint. A plugin submitted to the public directory needs a stable publicly reachable HTTPS MCP endpoint. See the Secure MCP Tunnel guide and plugin packaging guide.
What is tracked
| Part | Location | Why |
|---|---|---|
| Stable loopback relay | scripts/intendant-mcp-relay.py | Follows cli-path.meta.json across daemon port handovers and injects the current admission token without logging it. |
| Relay tests | scripts/test_intendant_mcp_relay.py | Covers handover, concurrent requests, header isolation, and opaque failures. |
| Plugin source template | examples/chatgpt-plugin/plugin-template/ | Canonical manifest and public branding, with no registered-app binding. |
| Per-install generator | examples/chatgpt-plugin/configure_plugin.py | Adds the non-secret plugin_asdk_app... technical ID to a generated package. |
| Supervisor examples | examples/chatgpt-plugin/services/ | Secret-free launchd and systemd user-service examples for the relay and tunnel client. |
The 128px composer icon is under 10 KiB. The larger square logo and wide banner are separate assets for richer plugin surfaces.
What must remain untracked
Do not commit any of these:
- a runtime API key or a tunnel-client profile that refers to one;
- files under the Intendant state root, especially
loopback-tokens/; - a live
cli-path.meta.json, tunnel-client health file, logs, or downloaded binary; - the installed plugin cache or personal marketplace state;
- a generated
.app.json, unless a project deliberately wants to publish a deployment binding rather than this reusable template; - a machine-rendered launchd/systemd file containing user-specific absolute paths.
Tunnel IDs and plugin_asdk_app... IDs are identifiers rather than bearer
secrets, but keeping the live values out of the public template avoids coupling
the repository to one account, workspace, or private tunnel.
Set up a private connection
Prerequisites are a running Intendant daemon with its HTTP /mcp surface, an
OpenAI tunnel for the intended Platform organization/ChatGPT workspace, the
public tunnel-client binary, and ChatGPT developer mode. Keep the tunnel
client’s runtime credential in an environment or secret manager outside the
checkout.
-
Start the stable relay:
python3 scripts/intendant-mcp-relay.pyIt listens only on
127.0.0.1:18766by default. Use--state-rootwhen the daemon uses a non-defaultINTENDANT_HOME. -
Initialize the private tunnel-client profile using OpenAI’s CLI flow. Point its HTTP MCP server URL at the relay:
tunnel-client init \ --profile intendant \ --tunnel-id tunnel_REPLACE_WITH_YOUR_PRIVATE_TUNNEL_ID \ --mcp-server-url http://127.0.0.1:18766/mcp tunnel-client doctor --profile intendant --explain tunnel-client run --profile intendantThe profile and runtime credential stay outside Git. Do not put the key on a command line, in a service template, or in this repository.
-
In ChatGPT developer mode, create an MCP app using Tunnel transport and select the intended tunnel. Copy the resulting technical ID from the app URL; it starts with
plugin_asdk_app_. -
Materialize the plugin package in an untracked personal location:
python3 examples/chatgpt-plugin/configure_plugin.py \ --app-id plugin_asdk_app_0123456789abcdef0123456789abcdef \ --output /absolute/path/to/personal/intendant-plugin -
Validate and install that generated directory with Plugin Creator, or add it to a personal marketplace. Enable it for the intended ChatGPT/Codex surface and test from a new chat.
For unattended use, copy the relevant service examples, replace every absolute path placeholder, validate the rendered service, and install it as a user service. Run the relay and tunnel client as the same OS account that owns the Intendant state root. Foreground commands work on every supported platform even when no native supervisor example is provided here.
Security properties
The relay is intentionally narrow:
- it binds only to a loopback IP and accepts only
/mcp; - it resolves the active daemon descriptor for each request, so a daemon update can change the web port without editing the tunnel profile;
- it reads the per-port admission token only at forwarding time, overwrites any caller-supplied Intendant token, strips ambient authorization/cookie headers, and never logs request headers;
- failures return only
relay unavailable, never a path, token, or exception; - it creates one upstream connection per request and uses a threaded loopback server, so multiple MCP consumers can make progress concurrently. Individual Intendant tools can still serialize internally when their resource or control plane requires it.
The tunnel does not weaken Intendant’s call-time MCP authorization. The relay authenticates as the local-process principal; configure Intendant IAM for that principal to the least role the private plugin needs.
Terminals across graceful daemon updates
The relay keeps an existing terminal on the daemon that owns its PTY while new stateless work follows the active daemon. There is no shell restart or state copying: cwd, exported variables, the running foreground command and the output cursor remain in the original process. Chained handovers and restarting the relay do not change that terminal’s owner.
After terminal_open (or terminal_list), retain the returned opaque
terminal_id verbatim. terminal_name is only the human-readable name.
The handle binds the owning daemon boot and one PTY incarnation. Passing it
back to terminal_open is attach-only, including an exited shell whose final
output is retained; it never creates a replacement. Plain names remain a
legacy local-daemon create/attach interface and do not promise continuity.
The raw terminal_* tools and canonical facade terminal commands use the
same routing. Direct clients that do not use this relay must keep targeting
the owning daemon themselves.
During drain, existing terminals and already-admitted opening reservations
hold the predecessor alive. New shell creation on that daemon is refused.
Call terminal_close when finished, including after reading a shell’s
exit status: retained final output also holds the drain. The dashboard’s
handover views name these terminal holdouts separately from agent sessions.
An update can therefore leave an old daemon running until its consumers close
their terminals; it is not a timed forced restart.
Routing uses only a recorded loopback boot under the configured state root. The relay verifies that boot’s admission-token fingerprint before forwarding, and the daemon independently checks the PTY generation plus normal visibility, IAM and filesystem-scope rules. A handle is not an authorization credential. A reused name or port cannot redirect the old handle to a new shell.
A hard kill/crash is not recoverable PTY migration. Missing owners and stale
handles return explicit terminal_owner_unavailable / terminal_lost errors.
After uncertain delivery the relay reports delivery: "unknown" and never
replays the write; verify the previous command’s effects before deliberately
opening a replacement. The current 256 KiB scrollback bound and gap reporting
still apply.
Rollout boundary: install both the updated daemon and this relay, then open terminals on that daemon. A daemon already running pre-fix code cannot retroactively acquire drain protection, and its old plain-name handles cannot identify a PTY incarnation. Finish those terminals before the initial upgrade. The continuity guarantee applies to subsequent graceful updates with this implementation on the predecessor.
Long-running MCP Tasks
When ChatGPT/Codex negotiates io.modelcontextprotocol/tasks, the daemon’s HTTP
/mcp endpoint returns an opaque Mcp-Session-Id. The relay wraps that
opaque header with its locally verified owner boot and unwraps it only when
forwarding to the same daemon, so remote_command start can return a Task and the
plugin can poll tasks/get or request tasks/cancel through the same private
tunnel. The session ID is not a bearer credential: Intendant re-authenticates
every request, binds the Tasks session to that authenticated identity, and
re-checks IAM for every task operation. Legacy clients that do not negotiate
Tasks remain sessionless and keep the original synchronous job-handle workflow.
An MCP client may explicitly DELETE /mcp with its session ID when it is done;
the relay forwards the DELETE and Intendant cancels/awaits any real remote work
owned by that Tasks session. The protocol session and task handles are in-memory
and do not survive a daemon restart. During graceful overlap, a negotiated
session continues on its original daemon, including across relay restarts.
A terminal handle and protocol session from different boots are refused,
never combined and never made to work by stripping the session header.
Initialize a fresh session on the successor (or use stateless terminal calls)
for new shells; the old session remains for the old shell. Unwrapped session
headers issued before this relay update require reinitialization rather than
an unsafe cross-daemon guess. This is affinity, not persistent Tasks migration.
Developer-only dogfooding (opt-in)
The default generated plugin does not contain dogfood instructions, and the
daemon does not advertise or accept report by default. End users are not
asked to produce developer feedback.
Developers may add --dev-dogfood when running configure_plugin.py to include
only the internal skill-dogfood-feedback skill in that developer package.
This does not enable the daemon feature or grant authority: independently start
the developer daemon with INTENDANT_DEV_DOGFOOD=1 and use a principal authorized
for feedback.write. Ordinary role:operator no longer grants that permission.
Do not distribute the opted-in package as the ordinary end-user plugin.
Reports remain local and Agenda-backed; no GitHub issues or central collection
are created. The Secure MCP relay authenticates as the local-process principal,
so client_context is self-described and only gate-stamped provenance is trusted.
The skill tells agents to continue silently when the tool is unavailable or
denied, never to enable it themselves or write Agenda feedback as a workaround.
For packages generated before this default-off boundary, regenerate into a new
directory and reinstall without --dev-dogfood. Stop/restart the updated daemon
without INTENDANT_DEV_DOGFOOD=1 to disable reporting regardless of cached client
instructions. Existing local reports are not deleted.
Verification and troubleshooting
What a ChatGPT/Codex session actually did survives the session: every
terminal command it typed, the shell’s output, and every tool call the
gateway served are in the daemon’s command log
(~/.intendant/command-log/, owner-only). Read it there to see what a
session did after its context is gone.
Run the secret-free local tests:
python3 -m unittest scripts/test_intendant_mcp_relay.py
temporary_plugin="$(mktemp -d)/intendant"
python3 examples/chatgpt-plugin/configure_plugin.py \
--app-id plugin_asdk_app_0123456789abcdef0123456789abcdef \
--output "$temporary_plugin"
python3 /path/to/plugin-creator/scripts/validate_plugin.py "$temporary_plugin"
For a live connection, check in this order:
- Intendant’s
cli-path.meta.jsonexists and names the currently running gateway port. - The relay process is listening on
127.0.0.1:18766. tunnel-client doctor --profile intendant --explainreports the local MCP endpoint ready.- The tunnel client is running and connected.
- The ChatGPT app uses Tunnel transport and the intended tunnel.
- A new chat can list the Intendant MCP tools and call a read-only tool.
An HTTP 502 from the plugin usually means the relay could not reach the active daemon, could not read the matching local admission token, or the tunnel client was running while Intendant was between handover states. The relay follows a completed handover automatically; persistent 502s should be diagnosed at the first failing layer above.