Skip to main content
Glama
MaybeItsAdam

zeros-mcp

by MaybeItsAdam
README.md
# zeros-mcp

An MCP server for controlling [Zero 88](https://www.zero88.com/) lighting consoles running
**ZerOS** over OSC. It exposes playbacks, cues, the grand master, blackout and user macros as
MCP tools.

Implements the OSC surface documented in
[ZerOS & OSC ("Open Sound Control")](https://vari-lite.s3.eu-west-1.amazonaws.com/graphics/technical/documents/zeros-osc-guide.pdf),
which requires **ZerOS 7.14 or later** — OSC did not exist before that release.

Zero 88 document OSC for **FLX, FLX S24/S48 and ZerOS Server**. The 7.14 release notes also
list ORB, Solution, SCD and Leap Frog under products affected, and the OSC section carries no
console qualifier — but the manual's variant filter does not include them, so treat OSC on
those desks as unconfirmed and try it rather than assuming either way.

> **This drives real lights.** Commands take effect immediately on whatever the console is
> connected to. Point it at a desk with nothing patched, or one that isn't in front of an
> audience, until you trust it.

## The thing to understand first

**ZerOS receives OSC. It never sends any.** The console has a *Receive Port Number* and no
transmit port; there is no feedback, no acknowledgement, and no way to read the desk's state
from here. This is not a gap in this server — it is the shape of the protocol.

Two consequences run through everything below:

- **No queries about the console exist, or can.** There is no `get_active_cue`. The only reads
  are `get_command_journal` (what *this server* sent) and `get_connection_health` (whether the
  link looks alive). Both say so in their own output, so a model reading them cannot quietly
  promote "I sent 50%" into "the desk is at 50%".
- **`ok: true` means the packet left this machine**, nothing more. Over UDP that is all it can
  ever mean, because UDP happily accepts writes addressed to a switched-off console. Every
  success result carries `confirmed_by_console: false` permanently.

If you want the console to talk back, it can't. If you want to know whether it's *there*, set
`ZEROS_TRANSPORT=tcp` — see [Transports](#transports).

## How it works

One outbound path:

```
                 commands  ->  ZEROS_PORT (console's "Receive Port Number")
  zeros-mcp  ─────────────────────────────────────────────────────►  ZerOS console

                 (nothing comes back, ever)
```

ZerOS takes its arguments **in the address path** — `/zeros/cue/go/1/5` fires cue 5 on
playback 1 — rather than as OSC arguments. Every message this server sends therefore has an
empty argument list, which also sidesteps the fact that Zero 88 never document which OSC type
tag the console expects. It does mean every tool argument is interpolated into an address, so
validating them is load-bearing: a `/` slipping through wouldn't error, it would fire a
different command.

```
zeros_mcp/
├── app.py            FastMCP instance
├── server.py         entry point: logging, mcp.run()
├── config.py         ZerosConfig, validated from the environment
├── journal.py        what this server sent - not console state
├── errors.py         ZerosConfigError / ZerosValidationError / ZerosSendError
├── logging_setup.py  stderr logging (stdout carries the MCP protocol)
├── osc/
│   ├── address.py    validates anything interpolated into an OSC address
│   ├── slip.py       RFC 1055 framing for the TCP transport
│   └── client.py     ZerosClient - lazy socket, UDP or TCP, structured errors
└── tools/            one module per area of the console
```

## Prerequisites

- A ZerOS console running **7.14 or later**
- [`uv`](https://docs.astral.sh/uv/getting-started/installation/) — no separate Python install
  or clone needed; `uv` fetches Python and the server itself.

## Setup

**1. Enable OSC on the console.**
Setup → Triggers → OSC:

| Setting | Value |
| --- | --- |
| OSC | Enabled |
| Receive Port Number | `8830` |
| Protocol | UDP (or TCP (SLIP) — see below) |

`Receive Port Number` must match this server's `ZEROS_PORT`, and the protocol must match
`ZEROS_TRANSPORT`. There is no transmit side to configure, which removes the most common
setup failure on other consoles — and replaces it with a quieter one: because nothing is ever
returned, a wrong port over UDP looks *exactly* like a working setup from this end. Commands
report `ok: true` and the rig does nothing.

A console that is not set to DHCP ships on a static `192.168.1.10 / 255.255.255.0`.

**2. Add it to your MCP client.**

For Claude Desktop, add this to `claude_desktop_config.json`
(Settings → Developer → Edit Config):

```json
{
  "mcpServers": {
    "zeros": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/MaybeItsAdam/zeros-mcp", "zeros-mcp"],
      "env": {
        "ZEROS_IP": "192.168.1.10",
        "ZEROS_PORT": "8830"
      }
    }
  }
}
```

`uvx` fetches and caches the server straight from GitHub on first launch — nothing to clone or
install by hand. Restart Claude Desktop after editing the config. Any MCP client that launches a
command works the same way; drop the `command`/`args`/`env` above into its config format.

To pin to a specific commit instead of tracking `main`, append `@<commit-sha>` to the repo URL.

The server also publishes a `system_instructions` prompt covering the no-feedback rule, the
macro escape hatch, and when to confirm before acting.

## Configuration

All optional. Defaults suit a console on the same machine.

| Variable | Default | Description |
| --- | --- | --- |
| `ZEROS_IP` | `127.0.0.1` | Console address. A desk on factory settings is `192.168.1.10`. |
| `ZEROS_PORT` | `8830` | Match the console's **Receive Port Number**. |
| `ZEROS_TRANSPORT` | `udp` | `udp` or `tcp`. Must match the console's protocol setting. |
| `ZEROS_LOG_LEVEL` | `INFO` | `DEBUG` logs every OSC message sent. |

Invalid values are rejected at startup with a message naming the variable. Note there is one
port here, not the TX/RX pair an Eos-style server needs — inventing a second would misdescribe
the protocol.

## Transports

**UDP** is the default and matches ZerOS's own default. It is fire-and-forget: nothing can be
learned about the console from it at all.

**TCP (SLIP)** carries the same messages under [RFC 1055](https://www.rfc-editor.org/rfc/rfc1055)
framing. It is worth choosing for one reason: a TCP connection either establishes or it does
not, which is the *only* evidence of the console's existence available anywhere in this
protocol. With it, `get_connection_health` can distinguish "something is listening on that
address" from "the packet left this machine". Without it, that question is unanswerable.

> The TCP path is written from Zero 88's documented "TCP (SLIP)" option and is covered by tests
> against a local listener, but it has **not been verified against a real console**. UDP is the
> tested-in-anger default. If TCP misbehaves on your desk, please open an issue.

## Tools

Every tool returns a structured result with an `ok` field. `ok: false` means the command never
left this machine, and `error` says why. `ok: true` means it did — and, as above, never more
than that.

**Cues** — `fire_cue`, `go_cue`, `set_pending_cue`

`fire_cue` takes a cue and an optional playback, and supports point cues such as `1.5`.
`set_pending_cue` is ZerOS's "Next": it moves the standby cue without firing it.

**The Master Playback is playback 0.** Playback numbers start at 0, not 1. If a cue will not
fire, try omitting the playback — an FLX S48 user reported `/zeros/cue/go/0/4` being ignored
where `/zeros/cue/go/4` worked, so the cue-only form is worth trying before concluding the
console isn't listening.

**Playbacks** — `pause_playback`, `release_playback`, `view_playback`, `set_playback_level`

`go_cue`, `pause_playback` and `release_playback` all take an optional playback number.
Omitting it targets whichever playback the console currently considers active — which this
server has no way to know, so naming the playback is the safer call.

Omitting `fade_seconds` on a level is not the same as passing `0`: ZerOS then uses the
playback's own raise and lower times, moving rather than snapping.

**Whole rig** — `set_grand_master`, `set_blackout`

Both affect everything at once, and because nothing is reported back you will not be able to
tell that you have blacked out a show.

**Macros** — `fire_macro`

**This is the escape hatch, and it is the answer to most "can it…?" questions.** The documented
OSC surface has no command line, no channel or colour control, and no way to record anything.
Anything outside the list above is done by recording a **user macro at the desk** once, and
firing it by number from here. So "set channel 1 to 50%" is not impossible — it is a macro.

**This server** — `get_connection_health`, `get_command_journal`

Neither reports console state, because none is observable. `get_command_journal` returns this
server's own history of sent messages, bounded to the most recent 50; an operator moving a
fader by hand a second after you sent a command leaves no trace in it.

Argument ranges are declared in each tool's schema, so out-of-range input is rejected before it
reaches the console.

## Troubleshooting

Start with `get_connection_health`. Over TCP it can tell you whether anything is listening,
which narrows the list below considerably. Over UDP it cannot, and it says so rather than guess.

**Commands report `ok: true` but nothing happens.** The likeliest failure, and the one with no
signal to go on. Check, in this order:

1. **ZerOS version is 7.14 or later.** Older software ignores OSC entirely while looking
   perfectly healthy — nothing in the UI indicates the feature is absent.
2. **OSC is enabled** under Setup → Triggers → OSC.
3. **Receive Port Number** equals `ZEROS_PORT`.
4. **The console's protocol setting** (UDP / TCP (SLIP)) matches `ZEROS_TRANSPORT`. A UDP
   sender talking to a console expecting TCP produces exactly this symptom.

To confirm the packets are at least leaving this machine, listen for them yourself:

```bash
python3 -c "import socket;s=socket.socket(2,2);s.bind(('',8830));print(s.recvfrom(9999))"
```

Point `ZEROS_IP`/`ZEROS_PORT` at that listener and fire a tool. If the datagram arrives, the
send path is fine and the problem is at the console.

**Commands return `ok: false` with a send error.** The console is unreachable at `ZEROS_IP`.
Check the address and that both machines are on the same network. Over TCP this is also what a
refused connection looks like, which is more informative than UDP's silence.

**Firewall.** On Windows, allow the Python interpreter through for the port you configured.

**Nothing works and there is no log output.** Set `ZEROS_LOG_LEVEL=DEBUG`. Logs go to stderr;
your MCP client may hide them.

## Development

```bash
git clone https://github.com/MaybeItsAdam/zeros-mcp
cd zeros-mcp
uv sync --all-groups
uv run pytest          # test suite; no console required
uv run ruff check .
uv run ruff format .
uv run mypy
```

To point a client at your local checkout instead of GitHub, use
`"command": "uv", "args": ["--directory", "/absolute/path/to/zeros-mcp", "run", "zeros_server.py"]`
in place of the `uvx` block above.

The integration tests bind real sockets on ephemeral ports and assert on the bytes that arrive,
including the SLIP frame over TCP, so message construction and framing are covered without
hardware. There is no receive path to test.

Two rules the test suite enforces mechanically:

- **Never write to stdout.** Under the stdio transport stdout carries JSON-RPC frames; a stray
  `print()` corrupts the session. Use `zeros_mcp.logging_setup.get_logger`.
  (`tests/test_no_stdout.py`)
- **Validate anything interpolated into an OSC address.** On ZerOS the address carries the
  arguments, so a `/` in a tool argument silently becomes a different command. Use the helpers
  in `zeros_mcp/osc/address.py`.

A third is enforced by convention and worth stating: **no tool may imply the console confirmed
anything.** `tests/test_schemas.py` pins the set of `get_*` tools for that reason — a new one
would mean inventing a feedback path that does not exist.

## Related

[eos-mcp](https://github.com/MaybeItsAdam/eos-mcp) — the same idea for ETC Eos family consoles.
Eos publishes its state over OSC, so that server mirrors the console live and has a far larger
tool surface. The difference is the protocol, not the effort.

## Licence

MIT — see [LICENSE](LICENSE).

TDQS

A4/5.0

Scored across 12 tools

Disambiguation4/5

Most tools have clearly distinct purposes: set_ commands target different parameters, get_ commands retrieve different information, and playback controls are separated by action (pause, release, view, level). The only potential overlap is between fire_cue and go_cue, but the descriptions clarify that one runs a specific cue while the other advances to the next cue.

Naming Consistency4/5

Tool names follow a consistent verb_noun pattern with useful prefixes (set_, get_, fire_). Minor deviations exist, such as go_cue versus fire_cue and pause_playback/release_playback/view_playback, but the overall naming is predictable and readable.

Tool Count5/5

With 12 tools, the server is well-scoped for controlling a lighting console. It covers cue firing, playback management, levels, blackout, macros, and diagnostics without unnecessary bloat.

Completeness4/5

The set covers the essential live-control operations (cues, playbacks, grand master, blackout, macros) plus useful diagnostics. The main gap is the inability to read console state—only command history is available—but this is a documented protocol limitation, so it's an acceptable minor gap.

Maintenance

ActivityStale
ResponsivenessNo issues