Skip to content

MCP Server

Griffen Fargo edited this page May 16, 2026 · 8 revisions

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

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)

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:

{
  "mcpServers": {
    "qlc-lights": {
      "url": "http://lights.local:5001/mcp"
    }
  }
}

MCP Inspector

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

Custom Python client

from 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%"})

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

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

Clone this wiki locally