Skip to main content

Documentation

MCP Server

6 min readEdit on GitHub

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

bash
./lightsctl.sh mcp-install

This 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.

snippet
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.

Tools

Discovery (read-only)

ToolReturns
get_statusAI provider, QLC+ service, workspace, WebSocket state
list_fixturesAll fixtures with .qxf-derived channel_info
get_fixture_channelsPer-channel role/preset/colour for one fixture
list_groupsFixture groups (named subsets)
list_scenesSaved scene functions in workspace
list_templatesBuilt-in templates (party, ambient, …)
get_channel_valuesLive DMX channel snapshot

Actions (write)

ToolEffect
activate_sceneApply existing saved scene by name or numeric ID
apply_templateApply a built-in template, optionally to a list of groups
adjust_brightnessSet/nudge master/dimmer (0-255, '75%', '+30', '-20')
adjust_colorSet a color preset (red, warm, cool, …) with optional intensity
color_temperatureSet Kelvin white balance (1800K–10000K), role-aware per fixture type
paletteAssign different colors / Kelvin values to different groups in one call
strobeStrobe targeted fixtures at a given Hz rate (0–20Hz, or "off")
fadeFade brightness to target over N seconds
generate_sceneAI-synthesize a scene from a description and apply live
set_channelDirect DMX channel write (power-user escape hatch)
save_scenePersist a scene XML (e.g. from generate_scene) to workspace
snapshot_sceneCapture current live state as a new saved scene
blackoutInstantly zero every channel on targeted fixtures (kill-all)
batch_actionExecute an ordered list of actions in a single round trip
identify_fixtureFlash a single fixture so the operator can locate it physically

Group management

ToolEffect
create_groupNew named subset from a fixture-ID list
delete_groupRemove a group
update_groupRename, change description, or replace fixture list
add_fixtures_to_groupAppend fixtures to an existing group
remove_fixtures_from_groupRemove fixtures from an existing group

Scene management

ToolEffect
describe_sceneReturn per-fixture channel values for a saved scene
delete_sceneRemove a saved scene from the workspace
rename_sceneRename a scene (and/or move its folder Path)
duplicate_sceneCopy a scene under a new name

Diagnostics

ToolEffect
test_dmxR → G → B → restore sweep to verify DMX reaches the rig
get_logsRead N lines of a service's systemd journal (allowlisted services)
get_system_infoPi-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.

ToolEffect
list_cue_listsList every saved cue list with runtime status
describe_cue_listFull definition + runtime status for one list
get_active_cue_listsOnly currently-playing lists, with elapsed time
create_cue_listBuild a new cue list from name + cues array
update_cue_listRename, change description, or replace the cues array
delete_cue_listRemove (stops playback first if running)
go_cue_listGO — start playback from the top
stop_cue_listHalt 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.

ToolEffect
list_chasesList all chases in the workspace
describe_chaseReturn a chase's full step list with resolved scene names
create_chaseBuild a chase from a name + ordered list of scene references
delete_chaseRemove a chase from the workspace
start_chaseBegin playback (loops forever unless run_order is SingleShot)
stop_chaseHalt playback; fixtures hold their current state

Resources

URIPayload
lights://workspaceOne-shot dump: status + fixtures + groups + scenes + templates

Client Wiring

Claude Desktop / Cursor

Add to your MCP config:

json
1{
2  "mcpServers": {
3    "qlc-lights": {
4      "url": "http://lights.local:5001/mcp"
5    }
6  }
7}

MCP Inspector

bash
npx @modelcontextprotocol/inspector http://lights.local:5001/mcp

Custom Python client

python
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):

VariableDefaultNotes
CONTROL_URLhttp://localhost:5000Flask backend URL
MCP_HOST0.0.0.0Bind address
MCP_PORT5001Listen port
MCP_PATH/mcpStreamable HTTP mount path
MCP_BEARER_TOKEN(unset)Reserved for auth — scaffolded, not enforced
MCP_HTTP_TIMEOUT30Seconds for upstream Flask calls

Management Commands

bash
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 rule

Auth (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?