# Architecture

## Dual-Thread Design

Envoy uses a two-thread architecture to bridge the MCP protocol with TouchDesigner's single-threaded main loop:

- **Worker thread**: Runs the MCP server (FastMCP with Streamable HTTP transport via uvicorn) — no TouchDesigner imports allowed
- **Main thread**: Executes all TD operations via `EnvoyExt._onRefresh()` callback
- **Communication**: `threading.Event` + `Queue` for request/response between threads
- **Thread management**: Uses `op.TDResources.ThreadManager` (TDTask pattern)

```
┌──────────────┐  STDIO   ┌──────────────┐  HTTP   ┌──────────────┐  Event+Queue  ┌──────────────┐
│  Claude Code  │ ◄──────► │ STDIO Bridge  │ ──────► │ Worker Thread │ ◄───────────► │  Main Thread  │
│  (MCP client) │  JSON-RPC │              │  POST   │              │               │              │
│              │          │ envoy-bridge  │         │ FastMCP      │  MCP Request  │ _onRefresh() │
│              │          │ .py           │         │ (uvicorn)    │  ──────────►  │ Execute TD   │
│              │          │              │         │              │               │ operations   │
│              │          │ Meta-tools:  │         │ 127.0.0.1    │  ◄── Result   │              │
│              │          │ get_td_status │         │ :9870        │               │ Cook cycle   │
│              │          │ launch_td    │         │              │               │              │
│              │          │ restart_td   │         │              │               │              │
└──────────────┘          └──────────────┘         └──────────────┘               └──────────────┘
```

## STDIO Bridge

Claude Code communicates with MCP servers via STDIO (stdin/stdout JSON-RPC). Since Envoy runs as an HTTP server inside TouchDesigner, the bridge script (`.embody/envoy-bridge.py`) translates between these transports.

The bridge provides several features beyond simple proxying:

- **Local MCP handshake**: Handles `initialize`, `notifications/initialized`, and `tools/list` locally when TD is not running, so Claude Code can always complete the MCP setup and discover bridge meta-tools
- **Meta-tools**: `get_td_status`, `launch_td`, and `restart_td` run entirely on the bridge — no Envoy connection required
- **Automatic retry**: Transient connection failures are retried with exponential backoff before reporting an error
- **Crash detection**: Tracks the TD process PID and detects when it exits unexpectedly
- **Crash-loop protection**: Limits launches to 3 within a 5-minute window to prevent infinite restart loops
- **Orphan cleanup**: A watchdog thread terminates the bridge if its parent process (Claude Code) exits, preventing zombie processes
- **Stale bridge cleanup**: On startup, kills other bridge processes targeting the same port from previous sessions

The bridge is regenerated from Embody's templates on each Envoy start. It uses only the Python standard library (no third-party dependencies).

### `.embody/envoy.json` Configuration

The bridge reads project configuration from `.embody/envoy.json` at the git root. This file also serves as the **instance registry** when running multiple TouchDesigner instances.

```json
{
  "active": "MyProject",
  "td_executable": "/Applications/TouchDesigner.app",
  "instances": {
    "MyProject": {
      "toe_path": "dev/MyProject.toe",
      "port": 9870,
      "td_pid": 12345
    }
  }
}
```

| Field | Description |
|-------|-------------|
| `active` | Name of the instance the bridge currently targets |
| `td_executable` | Path to the TouchDesigner executable or `.app` bundle |
| `instances.<name>.toe_path` | Relative path to the `.toe` project file |
| `instances.<name>.port` | Envoy's HTTP port for this instance |
| `instances.<name>.td_pid` | OS process ID of the TouchDesigner process |

Each Envoy instance registers itself on startup and deregisters on graceful shutdown. The instance key is derived from `project.name` (the `.toe` filename). This file is auto-generated by Envoy and added to `.gitignore`.

### `.embody/project.json` Build Pin (committed)

`.embody/envoy.json` is gitignored — its `td_executable` path is local to whoever last ran the project. On a fresh clone, that path may not exist, so the bridge needs another way to find a TouchDesigner install. That's what `.embody/project.json` is for:

```json
{
  "td_build": "2025.32660"
}
```

Embody writes this file on `onProjectPostSave` and once at startup (frame 80), idempotent so unchanged builds skip the write. **It is committed to git** — the repo's `.gitignore` excludes everything in `.embody/` *except* `project.json` (`.embody/*` + `!.embody/project.json`). Existing projects auto-migrate the bare `.embody/` rule on next Embody startup.

When `launch_td` runs on a fresh clone, the bridge reads `td_build` from `project.json`, scans the platform's standard install locations, and picks the matching TouchDesigner:

- **macOS**: `/Applications/TouchDesigner*.app` (parses `CFBundleShortVersionString` from each `Info.plist`)
- **Windows**: `C:\Program Files\Derivative\TouchDesigner.*`
- **Linux**: `/opt/derivative/touchdesigner-*`

**Match policy**, in order:

1. **Exact build match** — silent
2. **Same-year closest build** — warns (parameter defaults can drift between builds within a year)
3. **`envoy.json`'s `td_executable` if it still exists locally** — warns
4. **Newest installed TD** — warns
5. **Nothing found** — error response with the Derivative download link and the pinned build number

Backward compatible. Projects without `project.json` (or with `td_build` absent) fall back to `envoy.json`'s `td_executable` exactly as before.

### Multiple Instances

Envoy supports running multiple TouchDesigner instances simultaneously in the same git repo. Each instance gets its own port via automatic port allocation.

**Port allocation**: Each instance picks a port from a 10-port range starting at the configured Envoy Port (default: 9870). If the base port is occupied by another instance, Envoy scans ports `base+1` through `base+9` and claims the first available one. Up to 10 simultaneous instances are supported per base port.

**Bridge routing**: The STDIO bridge connects to **one active instance** at a time. The `switch_instance` meta-tool redirects the bridge to a different running instance by updating its target port in memory and writing the new `active` field to `.embody/envoy.json`. Switching is instant — no reconnection delay.

**Instance reachability**: The bridge verifies instances by checking both PID liveness and port responsiveness. An instance is only considered reachable when both checks pass. This filters out stale registry entries from crashed or closed instances.

**Same-project instances**: When you open the same `.toe` file in multiple TD instances, Envoy auto-suffixes the registry key to keep them distinct — the first registers as `MyProject`, the second as `MyProject-2`, etc. Stale entries (dead PIDs) are automatically reclaimed.

## Design Decisions

### Stateless HTTP Transport

Envoy uses `stateless_http=True` because TouchDesigner's single-threaded model means concurrent sessions would queue on the same main-thread execution path anyway. Stateless mode simplifies the implementation and avoids session management overhead.

### 30-Second Operation Timeout

`_execute_in_td()` times out at 30 seconds. This prevents indefinite hangs if the main thread is blocked (e.g., modal dialog), while allowing enough time for heavy operations like `.tox` saves. If a TD operation takes longer, the MCP tool returns a timeout error — the operation may need to be broken into smaller steps.

### Localhost-Only Binding

Envoy binds exclusively to `127.0.0.1` as a security requirement. Binding to `0.0.0.0` would expose the MCP server to the local network and enable DNS rebinding attacks from malicious websites.

### Standalone Thread

The MCP server runs as a `standalone=True` TDTask because it is long-lived (runs for the entire session), unlike pool tasks which are meant for short-lived work units.

### Queue-Based Communication

Uses `threading.Event` + `Queue` rather than locks because TD's cook cycle is frame-based — the main thread can only process requests once per frame via the RefreshHook.

## Graceful Shutdown

The shutdown sequence ensures clean port release:

1. `EnvoyExt.Stop()` is called (from UI toggle, `onExit`, or project close)
2. `shutdown_event.set()` signals the worker thread's uvicorn server to stop
3. Uvicorn completes its shutdown (stops accepting connections, drains existing)
4. Worker thread's target function returns
5. `SuccessHook` or `ExceptHook` fires on the main thread for cleanup
6. Port is released and available for rebinding

### Closing Instances via MCP

The preferred way to close a TD instance programmatically is via Envoy itself:

```python
execute_python(code="project.quit()")
```

This triggers TD's normal quit flow — the user is prompted to save unsaved changes, then `onDestroyTD` fires, the instance deregisters from `.embody/envoy.json`, and the port is released cleanly. This is more reliable than OS-level approaches (`osascript`, `taskkill`) which may not trigger TD's shutdown callbacks.

!!! warning
    Never use `project.quit(force=True)` unless the user has explicitly asked for it — `force=True` skips the save dialog and risks losing unsaved work.

To close a specific instance in a multi-instance setup, first `switch_instance` to target it, then send the quit command.

## Thread Safety Rules

!!! danger "Critical"
    The worker thread (`EnvoyMCPServer`) must **never** import or call TouchDesigner modules. All TD access goes through `_execute_in_td()` which routes operations to the main thread.

- All TD operations execute on the main thread via the `_onRefresh()` callback
- The worker thread only handles HTTP/MCP protocol logic
- Data crosses the thread boundary only through the `Queue` objects
- `op.TDResources.ThreadManager` must not be accessed from within a worker thread (triggers THREAD CONFLICT)

## Error Handling

Envoy handles two error categories:

1. **Protocol errors** (JSON-RPC level) — unknown tools, invalid arguments, or server errors. FastMCP handles these automatically.
2. **Tool execution errors** — returned in tool results via `{'error': str(e)}` dicts. These indicate the tool ran but encountered a problem (missing operator, invalid path, etc.).

All tool handlers validate inputs before passing to TD operations and return structured error information rather than raising exceptions.
