Bridge & transport
Topology
The server listens, the editor connects out (#276). Both are configurable via the same env var, GODOT_MCP_BRIDGE_URL (default ws://127.0.0.1:9080) — never hard-coded in library code.
Why the editor dials:
- The editor is the party that comes and goes; the server is a headless service (often spawned per session by the MCP client, or a long-lived Docker service).
- Launch order never matters: the server may bind the port before Godot exists.
- Reconnection with exponential backoff (start ~500 ms, capped) lives on the addon side — the only party with enough knowledge to survive restarts.
Framing & lifecycle
- Framing: one JSON object per WebSocket text message, UTF-8. No headers.
- One active peer: a new addon connection replaces the old one (a stale editor can't ghost the bridge).
- Auth is opt-in (#538): localhost-only by default — the zero-config path has no handshake. Set
GODOT_MCP_BRIDGE_TOKENon both sides for shared/remote-editor setups: the addon authenticates as its first message, a mismatch is refused at the handshake, tokens are never logged. Separately, a non-loopback HTTP bind requiresGODOT_MCP_AUTH_TOKEN(#226) — a fail-fast guard refuses to start otherwise. cmd_ping→{pong: true}is the health check, both directions.- Server→addon pushes: besides commands the addon answers, the server sends a few fire-and-forget control messages the addon consumes without a waiter —
cmd_server_hello(server version, #521) andcmd_toolsets_update(the enabled toolset set, #592, so the dock's Toolsets row mirrors the server). A replaced peer also receives aPEER_REPLACEDnotice before its link closes (#593).
Threat model
Loopback is reachable by more than the editor, so the listeners are hardened by default (#636):
- A browser page. WebSockets are exempt from the same-origin policy, and DNS rebinding lets an attacker's hostname resolve to
127.0.0.1. The bridge therefore accepts only Origin-less handshakes (origins=[None]) — the Godot addon'sWebSocketPeersends noOrigin, so a page is refused at the handshake; the HTTP transport's Host/Origin guard is likewise on by default (GODOT_MCP_HTTP_HOST_ORIGIN_PROTECTION=auto; foreignHost→421, foreignOrigin→403). - Another local process. Any local process can connect. The bridge is localhost-only; for shared/remote setups the opt-in token (#538) is the gate, and a bad/missing token is refused before it can displace the authenticated editor (#635). A non-loopback HTTP bind still requires
GODOT_MCP_AUTH_TOKEN(#226).
Timeouts, everywhere
A request that outlives its timeout resolves to a TIMEOUT envelope — it does not hang the agent. Concretely:
| Timeout | Where | Value |
|---|---|---|
| Bridge request | Bridge.send(command, timeout=…) | per-call; tool defaults vary (3–30 s) |
| Toolset enable's version fetch | _fetch_godot_version | 3 s, best-effort |
| HTTP transport | GODOT_MCP_HTTP_* via httpx client | tool-call bound (#422) |
| Readiness polls | poll_ready(…, timeout_ms) | per-tool (timeout_ms param) |
| Reconnect backoff | addon mcp_bridge.gd | ~500 ms start, capped |
A send with no connected peer resolves to BRIDGE_DISCONNECTED immediately — it does not wait for an editor that isn't coming.
Concurrency model
- Id correlation is concurrency-safe: many in-flight commands, each resolved to its own waiter keyed by
id. No shared mutable state across requests. - Single-writer semantics: one editor per bridge. A second connection replaces the first (documented; the server is single-user local by design).
- The server is sessionless by design (MCP
2026-07-28shape): no per-session state, works on every transport, andenable_toolsetwrites a server-global set shared by all clients (see gating). - No blocking sync I/O inside async handlers; no import-time sockets.
Configuration
| Env var | Default | Meaning |
|---|---|---|
GODOT_MCP_BRIDGE_URL | ws://127.0.0.1:9080 | bridge listen/connect URL (both sides) |
GODOT_MCP_TRANSPORT | stdio | stdio or http |
GODOT_MCP_HTTP_HOST / _PORT | 127.0.0.1:9090 | HTTP bind |
GODOT_MCP_AUTH_TOKEN | unset | Bearer token — required for non-loopback HTTP binds (#226) |
GODOT_MCP_HTTP_HOST_ORIGIN_PROTECTION | auto | HTTP Host/Origin (DNS-rebinding) guard (#636) — auto protects loopback, false opts out |
GODOT_MCP_BRIDGE_TOKEN | unset | opt-in shared secret for the bridge handshake (#538) — set on both sides; the addon authenticates as its first message; a mismatch is refused at the handshake; never logged |
GODOT_MCP_PROJECT_DIR | unset | explicit project dir (else the connected editor's) |
The full table lives in env-vars.
Testing the seam
The bridge is the seam most worth testing, so it gets two layers:
- Fake addon connection (
tests/fakes.py): an in-memory transport that validates real envelope shapes, correlatingids, and canned responses — the entire contract suite runs with no sockets and no editor. - Live e2e (
tests/integration/test_*_e2e.py): a real editor on a self-hosted runner; ephemeral per-run bridge ports (#444) so concurrent jobs can't contend; the addon's reconnect behavior is exercised by dedicated tests (#276).
The bridge inversion was itself landed with a red-first e2e: the old "addon-hosts" topology hung on every server restart; the "server-listens" topology survives any restart order.