Skip to main content
Glama
eva-akselrad

MCP ETC Nomad

by eva-akselrad
README.md
# MCP ETC Nomad

[![CI](https://github.com/eva-akselrad/mcp-etc-nomad/actions/workflows/ci.yml/badge.svg)](https://github.com/eva-akselrad/mcp-etc-nomad/actions/workflows/ci.yml)

**GitHub:** https://github.com/eva-akselrad/mcp-etc-nomad

TypeScript [Model Context Protocol](https://modelcontextprotocol.io) server for **ETC Eos Family** lighting consoles — including **ETCnomad** on PC/Mac.

Control ETCnomad and Eos desks over **OSC** so an LLM can operate cues, levels, subs, macros, and the full Eos command line.

> See **[PLAN.md](./PLAN.md)** for the full roadmap to operator parity.  
> Lighting-ops pack API: **[mcp-etc-nomad-specs/LIGHTING_OPS_SPEC.md](./mcp-etc-nomad-specs/LIGHTING_OPS_SPEC.md)** (LOCKED).

## Phase status

| Phase | Status |
|-------|--------|
| 0 Foundation | Implemented |
| 1 Playback parity | Implemented |
| 2 Programming parity | Implemented |
| 2.5 Lighting Expert pack | **Implemented** |
| **3 Show & system admin** | **Implemented** |
| **4 Hardening & distribution** | **Implemented** |

## Phase 1 (playback)

Live-write tools require `confirm=true` when `EOS_REQUIRE_CONFIRM=true` (default) and `allow_live=true` when the console is **LIVE** (or state is unknown) and `EOS_ALLOW_LIVE=false` (default). Read `get_console_state` / `eos://playback/state` first.

**Intensity scales:** channel/group levels and `color_set_rgb` / `channel_set_param` are **0–100** (percent, converted at TX). **`fader_set_level` stays 0.0–1.0** (OSC native). Grand master and **submaster** tool APIs are **0–100** (mapped to OSC 0.0–1.0). Cue-fire rate limit (default 12/min) is bypassed with `override_rate_limit=true` — `confirm` does **not** bypass rate limits.

| Group | Tools |
|-------|--------|
| Playback | `go_to_cue` (CLI GTC preferred), `cue_fire` (alias: Assert+GTC, not `/eos/cue/.../fire`), `cue_select`, `cue_go`, `cue_hold`, `cue_back`, `cue_resume`, `cue_stop` (deprecated→hold), `cue_list_go`, `get_active_cue`, `get_pending_cues` |
| GM / BO | `grandmaster_set_level` (0–100 → fader 0/1), `blackout` (BO key — `state` default `on`) |
| Channel check | `highlight` / `rem_dim` (`state` + channels/ranges), `timing_disable` (`state`), `sneak` (optional selection — omit = current; optional `time`), `home` (selection required — no whole-rig) |
| Park | `park`, `unpark` (`park_channel` / `unpark_channel` deprecated aliases), `get_parked` |
| Cue list banks | `cue_list_bank_config`, `cue_list_bank_page`, `cue_list_bank_select`, `cue_list_bank_reset` |
| Faders / subs | `fader_set_level` (0.0–1.0), `fader_bank_config`, `fader_load` / `_unload` / `_stop` / `_fire`, `fader_bank_page`, `fader_bank_reset`, `submaster_set_level` (0–100), `submaster_bump` (`submaster_fire` alias), `submaster_select` |
| Palettes / presets | `palette_select`, `palette_recall` (`palette_fire` alias), `preset_select`, `preset_recall` (`preset_fire` alias) |
| Keys / macros | `key_press`, `softkey_press`, `macro_select`, `macro_fire`, `staging_mode_toggle`, `list_osc_keys` |
| Direct selects | `direct_select_bank_create`, `direct_select_bank_page`, `direct_select_press` |
| Command line | `eos_command`, `eos_new_command`, `eos_event` |
| Levels | `channel_select` (Thru/+), `channel_set_level` (0–100), `channel_set_dmx`, `group_select`, `group_set_level`, `at_set_level` |
| Color / params | `color_set_hs`, `color_set_rgb` (r/g/b 0–100), `channel_set_param` (`value` 0–100) |
| Queries | `get_console_state`, `get_command_line`, `get_fader_labels_levels`, `get_direct_selects`, `wait_for_osc`, `osc_reset`, `magic_sheet_open` |

**Resources:** `eos://playback/active`, `eos://playback/pending`, `eos://playback/state`, `eos://playback/faders`, `eos://console/keys`

**Prompts:** `eos-operator`, `eos-live`

OSC addresses follow the [ETC OSC Dictionary](https://www.etcconnect.com/WebDocs/Controls/EosFamilyOnlineHelp/en/Content/23_Show_Control/08_OSC/OSC_Dictionary.htm). Go is `go_0`; Stop/Back is `stop`. Create fader / cue-list / direct-select banks before paging or reading labels.

## Phase 2 (programming)

Programming writes use the same `confirm` / `allow_live` gates as playback. Destructive deletes also require `confirm_delete=true` when `EOS_REQUIRE_CONFIRM=true`.

| Group | Tools |
|-------|--------|
| Record / update | `record_cue`, `update_cue`, `make_manual`, `set_cue_timing`, `record_group`, `record_preset`, `record_palette` |
| Copy / move / delete | `copy_target`, `move_target`, `delete_target` (+ `confirm_delete`) |
| OSC set | `label_target`, `group_set_channels` (`/eos/set/...`; Thru as `>`) |
| Patch | `patch_channel`, `patch_copy_to`, `patch_move`, `unpatch_channel` |
| Sync / get | `sync_show_targets` (optional `patch=true`), `get_groups`, `get_cuelists`, `get_cues`, `get_presets`, `get_palettes`, `get_patch` |
| Command line | `eos_command`, `eos_new_command` (typed tools use **newcmd**); no `/eos/record` verb |

**Resources:** `eos://show/groups`, `eos://show/cuelists`, `eos://show/cues/{list}`, `eos://show/patch`, `eos://show/presets`, `eos://show/palettes/{type}`

**Prompts:** `eos-programmer`, `eos-patch`

Sync uses OSC `/eos/get/*` request/response (node-eos-console / EosSyncLib pattern): count → index → cache in listener state. Subscribe with `/eos/subscribe` + int arg `1` on sync (default).

## Phase 3 (show & system admin)

Eos OSC domain rules: **no OSC Save/Load verbs** — Browser + `key_press` + CLI only. Never invent `usb1:/` or `.esf` paths.

| Group | Tools |
|-------|--------|
| Show files | **`show_save`** (priority: `confirm_save` + path echo), `show_load`, `show_merge`, `show_export`; `get_show_path` |
| Patch extras | `attach_patch_device`, `detach_patch_device` |
| Troubleshoot | `identify_fixture`, `channel_check`, `highlight_channels` |
| Network | `get_session_info`, `osc_set_user`, `network_session_join`, `network_session_leave` |

**Gates:** `user_intent` for load/merge/join; `confirm_save` / `confirm_path` when `EOS_REQUIRE_CONFIRM=true`. Prefer Blind for load/merge. After load/merge, `sync_show_targets` + reconfigure banks.

**TCP transport (real TCP OSC, not UDP retarget):** `EOS_PROTOCOL=tcp`. `3032` = OSC TCP 1.0 length headers (bidirectional); `3037` = Third Party OSC 1.1 SLIP (~realtime `/eos/out`). Custom ports OK (4703–4727+). Enable OSC RX+TX in Setup. UDP remains default; ETC prefers TCP.

**Resources:** `eos://console/info`, `eos://console/session`, `eos://console/version`, `eos://show/path`

**Prompts:** `nomad-setup`, `eos-showfile`

## Phase 4 (hardening & distribution)

| Item | Status |
|------|--------|
| OSC address + CLI test suite | `test/addresses-full.test.ts`, `test/cli-tools.test.ts`, `test/golden-replay.test.ts` |
| Golden trace replay | `test/fixtures/golden-traces.json` → listener state parser |
| CI (no hardware) | GitHub Actions — `npm run typecheck`, `build`, `test` with mock OSC peer |
| npm package | `mcp-etc-nomad@1.0.0` — `bin`, `files`, `prepublishOnly` |
| Cloudflare Worker relay | **Not implemented** — no prior sketch; documented as future remote-desk option |

## Install

### From npm (recommended)

```bash
npm install -g mcp-etc-nomad
# or as a project dependency:
npm install mcp-etc-nomad
```

Run the MCP server (stdio):

```bash
mcp-etc-nomad
# equivalent: npx mcp-etc-nomad
```

### From source

```bash
npm install
npm run build
npm start
```

Development:

```bash
npm run dev
```

## ETCnomad OSC setup

Setup → System Settings → Show Control → OSC:

| Setting | Value |
|---------|-------|
| OSC RX | On |
| OSC TX | On |
| String RX | On |
| UDP RX Port | `8000` (match `EOS_PORT_TX`) |
| UDP TX Port | `9001` (match `EOS_PORT_RX`) |

Use the console IP from Nomad Shell (not always `127.0.0.1` when MCP runs on another machine).

Copy `.env.example` to `.env` and adjust.

## Testing

**Mock OSC (no Nomad hardware):** `test/harness.ts` + `test/mock-osc-peer.ts` listen on a local UDP port, capture MCP tool TX packets, and send canned `/eos/out/*` replies (see PLAN.md §11.1).

```bash
npm test
```

| File | Coverage |
|------|----------|
| `test/osc-harness.test.ts` | Address builders, `assertLiveAllowed` gates, fader/cue bank TX sequencing |
| `test/addresses-full.test.ts` | Full `addresses.ts` Dictionary path coverage + user prefix |
| `test/command.test.ts` | CLI Enter/`#`/none terminators |
| `test/keys.test.ts` | OSC hardkey aliases (`go` → `go_0`, etc.) |
| `test/cli-tools.test.ts` | `eos_command`, keys, palettes, macros, user prefix, mock CLI echo |
| `test/lighting-expert.test.ts` | Lighting-ops blockers: `go_to_cue`/`cue_fire`/`cueZero`, BO≠GM, `park`/`unpark`, highlight/home selection, timing, subs/GM/RGB 0–100 |
| `test/golden-replay.test.ts` | Anonymized `/eos/out/*` trace replay (PLAN §11.3) |
| `test/programming.test.ts` | CLI programming builders (Copy/Delete Thru), tool TX, `sync_show_targets` mock-peer integration |
| `test/show-admin.test.ts` | Show save/load/**merge**/export gates (`user_intent`, LIVE refuse, `confirm_path`, `needsManual`); **network_session_leave** `user_intent`; TCP framing |
| `test/mock-osc-peer.ts` | Canned `/eos/get/*`, `/eos/out/cmd`, active cue, preset/palette replies |
| `test/fixtures/golden-traces.json` | Recorded Nomad-style OSC captures for regression |

Fader level tests assert **TX only** — Eos echoes `/eos/out/fader` after ~3s, so the harness does not expect an immediate echo.

Programming tools (`record_cue`, `update_cue`, etc.) refuse LIVE/unknown console state unless `allow_live=true` (mock tests cover this). `sync_show_targets` + `get_groups` / `get_cuelists` / `get_cues` populate listener cache; MCP resources `eos://show/*` read that cache.

**Automated gates in `test/show-admin.test.ts`:** `show_load` / `show_merge` require `user_intent` (≥8 chars), refuse LIVE/unknown without `allow_live`, and require `confirm_path` when `EOS_REQUIRE_CONFIRM=true`. Default path is `needsManual` (no unverified Browser OSC keys); opt-in via `press_unverified_browser_keys`. `network_session_leave` requires `user_intent` when gating is on and returns `needsManual` only (Stop Mirroring / ALT+F2 — no invented `/eos/key/exit`).

**Nomad offline smoke (manual):** with ETCnomad running and OSC enabled (see above):

1. **Playback / programming:** channel level, cue fire, group+cue record via CLI (`record_cue`), delete with `confirm_delete`
2. **Lighting-ops:** `go_to_cue` (CLI `Go To Cue N` via `/eos/newcmd` — not `/eos/key/go_0`); confirm **blackout** (`/eos/key/blackout`) is separate from **grandmaster_set_level(0)** (BO≠GM); `highlight` / `home` with channel selection (reject bare calls without selection)
3. **Show files (Browser):** `show_save` (quick save + path echo); `show_load` and `show_merge` with `user_intent` + `confirm_path` — complete the CIA Browser wizard on the desk (tools return `needsManual`; no auto-load/merge)
4. **Network:** `network_session_leave` with `user_intent` — complete mirror exit on desk via Stop Mirroring softkey or ALT+F2 (tool returns `needsManual`; no OSC key TX)
5. **Gates:** verify `show_merge` / `show_load` refuse LIVE without `allow_live`; `network_session_leave` requires `user_intent` when gating is on

Full checklist: PLAN.md §11.2.

## Cursor / Claude Desktop

```json
{
  "mcpServers": {
    "etc-nomad": {
      "command": "mcp-etc-nomad",
      "args": [],
      "env": {
        "EOS_HOST": "192.168.1.50",
        "EOS_PORT_TX": "8000",
        "EOS_PORT_RX": "9001",
        "EOS_ALLOW_LIVE": "false",
        "EOS_REQUIRE_CONFIRM": "true"
      }
    }
  }
}
```

When installed from source instead of npm, use `"command": "node"` with `"args": ["/absolute/path/to/mcp-etc-nomad/dist/index.js"]`.

## Project layout

```
src/
├── index.ts              # MCP stdio entry
├── config.ts             # Environment config
├── eos/
│   ├── client.ts         # OSC TX
│   ├── listener.ts       # OSC RX + state cache (+ multipart /list/0)
│   ├── state.ts          # Typed desk state
│   ├── addresses.ts      # OSC path builders
│   ├── keys.ts           # OSC Dictionary hardkey map + aliases
│   ├── command.ts        # CLI terminators
│   ├── context.ts        # Shared context + live/confirm gates
│   ├── sync.ts           # sync_show_targets (/eos/get/* cache)
│   ├── show-admin.ts     # Phase 3 show-file workflow builders
│   └── programming.ts    # Phase 2 CLI builders
├── tools/                # MCP tools (Phase 0–3)
├── resources/            # MCP resources (playback + show + console)
└── prompts/              # eos-operator, eos-live, eos-programmer, eos-patch, nomad-setup, eos-showfile
```

## License

MIT

TDQS

C2.9/5.0

Scored across 20 tools

Disambiguation2/5

Several tools overlap heavily: eos_command, eos_new_command, and eos_event are easily confused, especially since eos_event is described as using 'same syntax as eos_command.' Also, cue_fire, cue_go, and key_press all relate to the GO action, while channel_set_level and at_set_level both set intensity, creating boundary ambiguity.

Naming Consistency3/5

Naming is not chaotic and consistently uses snake_case, but conventions vary: object-first names like channel_set_level and cue_fire sit alongside verb-first names like get_console_state and wait_for_osc, with oddities like eos_command and eos_new_command that have no clear action verb. It is readable but not predictable.

Tool Count3/5

20 tools is on the heavy/borderline side. The Eos Nomad control domain is fairly broad and most tools target distinct console resources, but several overlapping command/event/Go tools could be consolidated, making the count feel higher than necessary.

Completeness4/5

The set covers core console workflows well: channels, groups, cues, submasters, macros, command-line entry, key presses, and state read-back. The generic eos_command tool helps fill CLI-only gaps, though there are minor missing read-back operations such as querying submaster or channel levels directly.

Maintenance

ActivityMaintained
ResponsivenessNo issues