-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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
./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.
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 :9999
When 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.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 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).
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 |
| URI | Payload |
|---|---|
lights://workspace |
One-shot dump: status + fixtures + groups + scenes + templates |
Add to your MCP config:
{
"mcpServers": {
"qlc-lights": {
"url": "http://lights.local:5001/mcp"
}
}
}npx @modelcontextprotocol/inspector http://lights.local:5001/mcpfrom mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
async with streamablehttp_client("http://lights.local:5001/mcp") as (r, w, _):
async with ClientSession(r, w) as s:
await s.initialize()
tools = await s.list_tools()
result = await s.call_tool("adjust_color", {"color": "warm", "intensity": "70%"})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 |
./lightsctl.sh mcp-status # systemctl status lighting-mcp.service
./lightsctl.sh mcp-logs # journalctl -u lighting-mcp.service -n 50
./lightsctl.sh mcp-restart # restart after .env or code changes
./lightsctl.sh mcp-uninstall # disable, remove unit, drop firewall ruleMCP_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.