Documentation
MCP Server
The MCP server runs on the Pi at port 5001 and exposes the lighting rig as a Model Context Protocol endpoint. Any MCP-capable LLM client — Claude Desktop, ChatGPT, Cursor, custom agent — can discover fixtures, scenes, and groups and issue commands without hand-rolling REST integrations.
Overview
Endpoint: http://lights.local:5001/mcp (Streamable HTTP transport)
The MCP server provides:
- Discovery tools — List fixtures (with
.qxf-derived channel info), groups, saved scenes, templates, and live channel values - Action tools — Activate scenes, apply templates, adjust brightness/color, fade, generate AI scenes, save scenes, set raw channels
- Workspace resource — One-shot context dump (
lights://workspace) for the LLM to load at session start - Sibling-service deployment — runs as
lighting-mcp.service, ordered after the Flask control server so the rig boots fully wired
Installation
./lightsctl.sh mcp-installThis creates a Python venv on the Pi, installs mcp[cli] + httpx, copies the server code, and sets up lighting-mcp.service ordered after lighting-control.service.
Architecture
The MCP server is a thin wrapper over the control server's REST API. It holds no QLC+ connection of its own — it makes HTTP calls into the Flask app on localhost:5000. This preserves the "single writer" invariant on the persistent QLC+ WebSocket and makes the MCP process stateless and crash-safe to restart.
LLM agent ──MCP/HTTP──▶ lighting-mcp.service ──HTTP──▶ lighting-control.service ──WS──▶ QLC+
:5001/mcp :5000 :9999When an LLM calls adjust_color("warm"), the MCP server posts directly to POST /api/action on Flask, bypassing the natural-language AI interpreter — the LLM is already on the other end of the MCP socket, so re-running a structured tool call through another LLM would waste latency.
See MCP_SERVER.md for the full technical deep-dive.
Tools
Discovery (read-only)
| Tool | Returns |
|---|---|
get_status | AI provider, QLC+ service, workspace, WebSocket state |
list_fixtures | All fixtures with .qxf-derived channel_info |
get_fixture_channels | Per-channel role/preset/colour for one fixture |
list_groups | Fixture groups (named subsets) |
list_scenes | Saved scene functions in workspace |
list_templates | Built-in templates (party, ambient, …) |
get_channel_values | Live DMX channel snapshot |
Actions (write)
| Tool | Effect |
|---|---|
activate_scene | Apply existing saved scene by name or numeric ID |
apply_template | Apply a built-in template, optionally to a list of groups |
adjust_brightness | Set/nudge master/dimmer (0-255, '75%', '+30', '-20') |
adjust_color | Set a color preset (red, warm, cool, …) with optional intensity |
color_temperature | Set Kelvin white balance (1800K–10000K), role-aware per fixture type |
palette | Assign different colors / Kelvin values to different groups in one call |
strobe | Strobe targeted fixtures at a given Hz rate (0–20Hz, or "off") |
fade | Fade brightness to target over N seconds |
generate_scene | AI-synthesize a scene from a description and apply live |
set_channel | Direct DMX channel write (power-user escape hatch) |
save_scene | Persist a scene XML (e.g. from generate_scene) to workspace |
snapshot_scene | Capture current live state as a new saved scene |
blackout | Instantly zero every channel on targeted fixtures (kill-all) |
batch_action | Execute an ordered list of actions in a single round trip |
identify_fixture | Flash a single fixture so the operator can locate it physically |
Group management
| Tool | Effect |
|---|---|
create_group | New named subset from a fixture-ID list |
delete_group | Remove a group |
update_group | Rename, change description, or replace fixture list |
add_fixtures_to_group | Append fixtures to an existing group |
remove_fixtures_from_group | Remove fixtures from an existing group |
Scene management
| Tool | Effect |
|---|---|
describe_scene | Return per-fixture channel values for a saved scene |
delete_scene | Remove a saved scene from the workspace |
rename_scene | Rename a scene (and/or move its folder Path) |
duplicate_scene | Copy a scene under a new name |
Diagnostics
| Tool | Effect |
|---|---|
test_dmx | R → G → B → restore sweep to verify DMX reaches the rig |
get_logs | Read N lines of a service's systemd journal (allowlisted services) |
get_system_info | Pi-level health: CPU temp, load, memory, disk, uptime, USB, services |
Cue lists (audio-synced show programming)
Cue lists are the QLab / ETC Ion "cue stack" model — an ordered list of cues, each with an absolute timestamp. Press GO and the server fires each cue at its time. Sync-mode only: the user runs their audio in OBS / Logic / etc. and presses GO at the same moment as the track starts.
| Tool | Effect |
|---|---|
list_cue_lists | List every saved cue list with runtime status |
describe_cue_list | Full definition + runtime status for one list |
get_active_cue_lists | Only currently-playing lists, with elapsed time |
create_cue_list | Build a new cue list from name + cues array |
update_cue_list | Rename, change description, or replace the cues array |
delete_cue_list | Remove (stops playback first if running) |
go_cue_list | GO — start playback from the top |
stop_cue_list | Halt playback; fixtures hold their last fired state |
Each cue accepts a timestamp (at_ms integer or human-readable at like "0:32.500", "32s", "1:23:45") and an action (a scene name, a chase name, or any execute_lighting_action-compatible action with parameters).
Chase management
Chases are ordered sequences of saved scenes with per-step timing — the time-based programming primitive. Stored as QLC+ chaser functions, played back via QLC+'s native chase engine.
| Tool | Effect |
|---|---|
list_chases | List all chases in the workspace |
describe_chase | Return a chase's full step list with resolved scene names |
create_chase | Build a chase from a name + ordered list of scene references |
delete_chase | Remove a chase from the workspace |
start_chase | Begin playback (loops forever unless run_order is SingleShot) |
stop_chase | Halt playback; fixtures hold their current state |
Resources
| URI | Payload |
|---|---|
lights://workspace | One-shot dump: status + fixtures + groups + scenes + templates |
Client Wiring
Claude Desktop / Cursor
Add to your MCP config:
1{
2 "mcpServers": {
3 "qlc-lights": {
4 "url": "http://lights.local:5001/mcp"
5 }
6 }
7}MCP Inspector
npx @modelcontextprotocol/inspector http://lights.local:5001/mcpCustom Python client
1from mcp.client.streamable_http import streamablehttp_client
2from mcp import ClientSession
3
4async with streamablehttp_client("http://lights.local:5001/mcp") as (r, w, _):
5 async with ClientSession(r, w) as s:
6 await s.initialize()
7 tools = await s.list_tools()
8 result = await s.call_tool("adjust_color", {"color": "warm", "intensity": "70%"})Configuration
Env vars (set in /home/<user>/mcp-server/.env, loaded by the systemd unit):
| Variable | Default | Notes |
|---|---|---|
CONTROL_URL | http://localhost:5000 | Flask backend URL |
MCP_HOST | 0.0.0.0 | Bind address |
MCP_PORT | 5001 | Listen port |
MCP_PATH | /mcp | Streamable HTTP mount path |
MCP_BEARER_TOKEN | (unset) | Reserved for auth — scaffolded, not enforced |
MCP_HTTP_TIMEOUT | 30 | Seconds for upstream Flask calls |
Management Commands
1./lightsctl.sh mcp-status # systemctl status lighting-mcp.service
2./lightsctl.sh mcp-logs # journalctl -u lighting-mcp.service -n 50
3./lightsctl.sh mcp-restart # restart after .env or code changes
4./lightsctl.sh mcp-uninstall # disable, remove unit, drop firewall ruleAuth (Future Work)
MCP_BEARER_TOKEN is plumbed through the systemd unit and read at startup, but not yet enforced. To enable, wrap mcp.streamable_http_app() with an ASGI middleware that checks the Authorization: Bearer … header. FastMCP also supports a full OAuth provider — overkill for a LAN rig, but the right choice if the endpoint is ever exposed off-network through the nginx/stunnel TLS proxy.
Was this page helpful?