streamdeck-mcp
# streamdeck-mcp
An [MCP](https://modelcontextprotocol.io) server that lets AI agents read and
write **Elgato Stream Deck desktop profiles** — the plain-JSON manifests the
Elgato Stream Deck app uses — plus a small toolkit for generating icons,
wrapping shell commands in script-backed keys, and restarting the app so
changes take effect.
Fork of [verygoodplugins/streamdeck-mcp](https://github.com/verygoodplugins/streamdeck-mcp)
(MIT, Jack Arturo / Very Good Plugins), extended with:
- **`http_service.py`** — the same tool surface over **MCP Streamable HTTP**,
behind a bearer-token gate, for agents that run on a *different* machine
(the stdio server assumes its client runs where the deck is).
- **`deck_status`** — a one-call health verdict for remote agents.
- **`examples/`** — a launchd service template, an env template, and a
read-only end-to-end smoke client.
Ask an agent to "put a key for restarting Docker on the top-left of my
deck" and it edits those manifests — no hunting for settings in the app.
## The one rule that bites
The Elgato desktop app keeps profiles **in memory** and rewrites the
manifest files *from memory* when it quits. Edits made behind the app's
back are clobbered at the next quit. The correct cycle is:
1. stop the app (`streamdeck_write_page` refuses to write while the app is
running; `auto_quit_app=true` stops it for you),
2. make your manifest edits (one or more `streamdeck_write_page` calls),
3. restart the app (`streamdeck_restart_app`) so it reads your layout.
Always back up the profiles directory before writing.
## Transports
| Transport | When | How |
| --- | --- | --- |
| stdio | the MCP client runs on the deck's machine | the `streamdeck-mcp` CLI is spawned by the client (Claude Desktop, Claude Code, any MCP host) |
| Streamable HTTP | the agent runs elsewhere and needs the deck over the network | `streamdeck-mcp-service` (or `python http_service.py`) behind a bearer token; default bind `127.0.0.1:18802` |
The HTTP service adds one tool on top of the stdio surface: `deck_status`.
## Quick start — stdio
```bash
git clone <this repo>
python3.11 -m venv .venv
.venv/bin/pip install -e .
```
Then point any local MCP host at `.venv/bin/streamdeck-mcp` (or at the
upstream PyPI release if you don't need the HTTP service).
## Quick start — HTTP service
```bash
git clone <this repo>
python3.11 -m venv .venv
.venv/bin/pip install -e .
# bearer token (keep it secret; chmod 600)
openssl rand -hex 24 > .token
chmod 600 .token
.venv/bin/streamdeck-mcp-service # listens on 127.0.0.1:18802 by default
```
MCP client config (Streamable HTTP):
```json
{
"mcpServers": {
"streamdeck": {
"type": "http",
"url": "http://127.0.0.1:18802/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
```
For a remote agent on your private/tailnet network, set `DECK_MCP_BIND` to
a private interface address. The service enforces DNS-rebinding protection
(it only accepts requests whose `Host` matches the bind address or
loopback), so it is not internet-facing by default; put a
TLS-terminating reverse proxy in front of it if the path between client
and deck crosses an untrusted network.
Smoke-test a running service (read-only):
```bash
.venv/bin/python examples/test_client.py http://127.0.0.1:18802/mcp .token
```
On macOS, run it as a launchd service with `examples/launchd.plist.tpl`
(the template comments cover install, stop, and log locations).
### Environment
| Variable | Default | Purpose |
| --- | --- | --- |
| `DECK_MCP_BIND` | `127.0.0.1:18802` | `host:port` the HTTP service listens on |
| `DECK_MCP_TOKEN_FILE` | `./.token` | bearer token file; re-read per request, so rotation needs no restart |
| `DECK_MCP_PROFILES_DIR` | auto (`ProfilesV3`, then `ProfilesV2`, under the Elgato app's application-support dir) | override the profiles directory |
| `DECK_MCP_SCRIPTS_DIR` | `~/StreamDeckScripts` | where `streamdeck_create_action` writes scripts |
| `DECK_MCP_ICONS_DIR` | `~/.streamdeck-mcp/generated-icons` | where `streamdeck_create_icon(s)` writes PNGs |
| `DECK_MCP_LOG` | — (warnings to stderr) | JSONL audit file, one line per tool call |
## Tools
| Tool | What it does |
| --- | --- |
| `streamdeck_find_actions` | search installed plugins/actions by text, plugin, controller, or profile usage; each result carries a paste-ready native `action` object |
| `streamdeck_read_profiles` | list profiles (V3/V2), pages, current/default page, device model |
| `streamdeck_read_page` | read one page, button by button, each with its raw native action object |
| `streamdeck_write_page` | create or replace a page manifest; refuses while the app is running unless `auto_quit_app=true` |
| `streamdeck_create_icon` | render one PNG icon from a Material Design Icons glyph and/or text |
| `streamdeck_create_icons` | batch-render icons |
| `streamdeck_create_action` | wrap a shell command in a script-backed `Open` action |
| `streamdeck_read_plugins` | installed plugins with action/settings schemas (for synthesizing actions) |
| `streamdeck_restart_app` | stop + relaunch the Elgato app (macOS) to pick up manifest edits |
| `streamdeck_install_mcp_plugin` | install the bundled in-app `.sdPlugin` |
| `deck_status` *(HTTP only)* | app running? profile inventory? uptime? last tool call? |
### Authoring a key, in preference order
1. **Paste a native action** — copy the `action` object from
`streamdeck_find_actions`, or a `streamdeck_read_page` button's `raw`
field, into `streamdeck_write_page`. Best for installed plugins whose
settings schema is opaque.
2. **Synthesize** — from `streamdeck_read_plugins`: `plugin_uuid` +
`action_uuid` + `settings`.
3. **Script-backed** — `streamdeck_create_action` writes a shell script
and returns an `Open` action; the key then runs your command.
## Security model
- The HTTP endpoint is **bearer-token gated**; the token file is re-read on
every request and compared in constant time, so rotation needs no
restart.
- `GET /health` is intentionally unauthenticated and returns no profile
data (liveness only).
- DNS-rebinding protection: only requests whose `Host` header matches the
bind host or loopback are accepted; anything else is dropped
fail-closed (the connection is closed without a response).
- The service is not meant to be exposed to the public internet; keep it
on a private/tailnet interface, behind TLS if the path is untrusted. No
telemetry, no outbound connections.
## Troubleshooting
| Symptom | Meaning / fix |
| --- | --- |
| `⚠️ ... app is running ...` | the Elgato app holds the manifests in memory; close it (or pass `auto_quit_app=true`), write, then `streamdeck_restart_app` |
| `❌ ... profile not found` | check `DECK_MCP_PROFILES_DIR` / that the profiles directory exists on the deck's machine |
| connection closed with no response | the client's `Host` header is not the bind address or loopback (DNS-rebinding protection) — point the client at the actual bind host:port, not a different alias |
| keys look stale after a write | the app rewrote the manifests from memory on quit; redo the cycle with the app stopped |
## Development
```bash
python3.11 -m venv .venv
.venv/bin/pip install -e .[dev]
.venv/bin/pytest # profile engine, stdio server, HTTP service
.venv/bin/ruff check .
```
Tests run on any platform: the profile engine is pure file manipulation
and the Stream Deck app is stubbed out.
## Layout
```
profile_manager.py the profile engine (V3/V2 manifests, pages, actions,
icons, app control)
profile_server.py stdio MCP server (9 tools)
server.py legacy USB/streamdeck-python device server (optional)
http_service.py Streamable HTTP service (10 tools, bearer-token gated)
install_skill.py installs the bundled streamdeck-designer skill
streamdeck_assets/ Material Design Icons (Apache-2.0) + designer skill
streamdeck_plugin/ bundled in-app .sdPlugin
tests/ pytest suite (synthetic profile fixtures)
examples/ launchd template, env template, smoke client
```
## Attribution
Forked from [verygoodplugins/streamdeck-mcp](https://github.com/verygoodplugins/streamdeck-mcp)
(MIT, © Jack Arturo / Very Good Plugins). The HTTP service, `deck_status`,
the examples and the fork packaging are © Hugo Soares. The bundled
Material Design Icons font is Apache-2.0 (see
`streamdeck_assets/MDI-LICENSE`).
TDQS
Scored across 9 tools
Each tool has a clearly distinct primary purpose: profile/page reading, page writing, icon generation, action discovery/creation, plugin inspection/installation, and app restart. The potentially overlapping discovery tools (find_actions, read_plugins, read_page) are differentiated by whether they search configured instances, enumerate installed plugin declarations, or read a specific page, and the descriptions explicitly guide when to prefer each.
All tool names use a consistent snake_case verb_noun pattern with a uniform streamdeck_ prefix. The verbs are predictable and readable, with no mixing of camelCase or vague generic names.
Nine tools is well-scoped for a Stream Deck automation server, covering reading, writing, asset creation, action discovery, plugin setup, and app lifecycle. Each tool appears to earn its place without obvious redundancy.
The surface covers the main workflow: list/read profiles, read/write pages, find/create actions, generate icons, inspect plugins, and restart/install the helper plugin. Minor gaps remain around explicit page listing/deletion or profile lifecycle management, but core authoring and discovery workflows are supported.