qsys-mcp 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/index.js +14 -0
- package/dist/server.js +354 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Owens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# qsys-mcp
|
|
2
|
+
|
|
3
|
+
> Let an AI agent inspect and control a **Q-SYS** audio/video system over QSC's published **QRC** protocol — against a real Core or Q-SYS Designer's built-in emulator.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/qsys-mcp)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
`qsys-mcp` is an [MCP](https://modelcontextprotocol.io) server. It speaks QSC's **QRC** external-control protocol (JSON-RPC 2.0 over TCP) — the same interface third-party control systems like Crestron and AMX use — and exposes it to an LLM agent as a set of tools. Point it at a physical Q-SYS Core *or* at Q-SYS Designer running in **Emulate mode** and the agent can read meters, flip mutes, ramp gains, and watch controls for changes.
|
|
10
|
+
|
|
11
|
+
It's a pure wire-protocol client: **zero QSC code**, no SDK, no hardware required for development. That makes it a clean, sanctioned layer QSC ships on no platform — AI-native control — and it runs anywhere Node does.
|
|
12
|
+
|
|
13
|
+
## Highlights
|
|
14
|
+
|
|
15
|
+
- **18 tools** covering connect, status, discovery, read, write (with ramps), snapshots, the full change-group lifecycle (add/poll/remove/clear/invalidate/destroy), and disconnect.
|
|
16
|
+
- **No hardware needed** — develop entirely against Designer's Emulate-mode soft-core on `localhost`.
|
|
17
|
+
- **Cross-platform** — `node:net` only; CI proves it on Linux, macOS, and Windows × Node 18 & 20.
|
|
18
|
+
- **Context-friendly** — list/get tools take `filter` / `names_only` / `type` so large designs don't flood the agent's context.
|
|
19
|
+
- **Safe by default** — write tools warn when they're hitting a live Core (not an emulator); a 30 s `NoOp` keepalive holds the socket open through QRC's 60 s idle close.
|
|
20
|
+
- **Self-healing** — on a dropped socket (Core restart, leaving Emulate, a network blip) the client auto-reconnects and replays your change-group registrations, so polling resumes without re-calling `qsys_connect`. Opt out with `reconnect: false`.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
Run it straight from npm (no install):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx -y qsys-mcp # MCP server on stdio
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> Formerly published as `q-sys-mcp` (now unpublished) — as of 0.3.0 the package
|
|
31
|
+
> name matches the command: **`qsys-mcp`**. Update any existing MCP configs.
|
|
32
|
+
|
|
33
|
+
Or from source:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/reowens/qsys-mcp.git
|
|
37
|
+
cd qsys-mcp
|
|
38
|
+
npm install # builds dist/ via the prepare hook
|
|
39
|
+
node dist/index.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Connect it to your agent
|
|
43
|
+
|
|
44
|
+
Add it to your MCP client config (Claude Desktop, etc.):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"q-sys": {
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "qsys-mcp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
From a local checkout instead, use `"command": "node"` with `"args": ["/absolute/path/to/qsys-mcp/dist/index.js"]`.
|
|
58
|
+
|
|
59
|
+
Always call `qsys_connect` first (host `127.0.0.1`, port `1710` for a local emulator) before any other tool.
|
|
60
|
+
|
|
61
|
+
## What it can do
|
|
62
|
+
|
|
63
|
+
Once connected, just ask in natural language — the agent picks the tools.
|
|
64
|
+
|
|
65
|
+
> **You:** *"Connect to my Q-SYS emulator and bring the main gain down to −20 dB over 2 seconds."*
|
|
66
|
+
|
|
67
|
+
The agent runs:
|
|
68
|
+
|
|
69
|
+
1. `qsys_connect` → `{ host: "127.0.0.1", port: 1710 }`
|
|
70
|
+
2. `qsys_list_components` → `{ type: "gain" }` — finds the `Levels` gain block
|
|
71
|
+
3. `qsys_set_component` → `{ name: "Levels", controls: [{ name: "gain", value: -20, ramp: 2 }] }`
|
|
72
|
+
|
|
73
|
+
Or, if you've exposed that fader as a **Named Control** in Designer:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
qsys_set_control → { name: "MainGain", value: -20, ramp: 2 }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
To watch a control live (meters, button states), create a change group and poll it:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
qsys_create_change_group → { id: "meters", controls: ["MainGain"] }
|
|
83
|
+
qsys_poll_change_group → { id: "meters" } // returns only what changed since the last poll
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Tools
|
|
87
|
+
|
|
88
|
+
| Tool | QRC method | Purpose |
|
|
89
|
+
|------|------------|---------|
|
|
90
|
+
| `qsys_connect` | (socket) + `Logon`/`StatusGet` | Connect to a Core/emulator |
|
|
91
|
+
| `qsys_status` | `StatusGet` | Engine status (platform, design, run state) |
|
|
92
|
+
| `qsys_list_components` | `Component.GetComponents` | List named components |
|
|
93
|
+
| `qsys_get_component_controls` | `Component.GetControls` | A component's controls + values |
|
|
94
|
+
| `qsys_get_control` | `Control.Get` | Get Named Control values |
|
|
95
|
+
| `qsys_get_component` | `Component.Get` | Get specific component control values |
|
|
96
|
+
| `qsys_set_control` | `Control.Set` | Set a Named Control (with optional ramp) |
|
|
97
|
+
| `qsys_set_component` | `Component.Set` | Set component controls (with optional ramps) |
|
|
98
|
+
| `qsys_load_snapshot` | `Snapshot.Load` | Recall a saved snapshot (with optional ramp) |
|
|
99
|
+
| `qsys_save_snapshot` | `Snapshot.Save` | Capture current settings into a snapshot |
|
|
100
|
+
| `qsys_create_change_group` | `ChangeGroup.AddControl` | Watch Named Controls for changes |
|
|
101
|
+
| `qsys_change_group_add_component` | `ChangeGroup.AddComponentControl` | Watch a component's controls |
|
|
102
|
+
| `qsys_poll_change_group` | `ChangeGroup.Poll` | Get changes since last poll |
|
|
103
|
+
| `qsys_change_group_remove` | `ChangeGroup.Remove` | Stop watching specific Named Controls |
|
|
104
|
+
| `qsys_change_group_clear` | `ChangeGroup.Clear` | Remove all controls, keep the group |
|
|
105
|
+
| `qsys_change_group_invalidate` | `ChangeGroup.Invalidate` | Force the next poll to resend everything |
|
|
106
|
+
| `qsys_destroy_change_group` | `ChangeGroup.Destroy` | Free a change group's server-side state |
|
|
107
|
+
| `qsys_disconnect` | (socket) | Close the connection |
|
|
108
|
+
|
|
109
|
+
`qsys_list_components` and `qsys_get_component_controls` accept optional `filter` (case-insensitive name substring), `names_only`, and — for components — `type`, to trim large designs before they reach the agent's context.
|
|
110
|
+
|
|
111
|
+
### Named Controls vs. components
|
|
112
|
+
|
|
113
|
+
Q-SYS exposes controls two ways, and the tools mirror that split:
|
|
114
|
+
|
|
115
|
+
- **Named Controls** (`qsys_get_control` / `qsys_set_control`) reach a control only if it's been *explicitly exposed* — dragged into the **Named Controls** pane in Designer with a unique name. Flat namespace, addressed by that one name.
|
|
116
|
+
- **Component controls** (`qsys_get_component_controls` / `qsys_get_component` / `qsys_set_component`) reach any control on a component whose parent has a **Code Name** with **Script Access** enabled — no per-control naming needed.
|
|
117
|
+
|
|
118
|
+
If `qsys_get_control` can't find a name, it almost always means the control hasn't been added to the Named Controls pane.
|
|
119
|
+
|
|
120
|
+
## Requirements
|
|
121
|
+
|
|
122
|
+
- **Node.js ≥ 18.**
|
|
123
|
+
- **A control target on port 1710:**
|
|
124
|
+
- a real **Q-SYS Core** with a design loaded and in **Run** mode, or
|
|
125
|
+
- **Q-SYS Designer in Emulate mode** — open a design and press **F6** (*Save to Design & Run*; **not** F5, which deploys to a physical Core). Connect to `127.0.0.1:1710`.
|
|
126
|
+
|
|
127
|
+
QRC is fully functional in Emulate mode, so you can build and test without any hardware.
|
|
128
|
+
|
|
129
|
+
> Writes mutate the running/emulated system. On an emulator, nothing persists unless you save the design in Designer.
|
|
130
|
+
|
|
131
|
+
## Develop & verify
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npm test # offline: QRC integration + MCP-over-mock (no hardware)
|
|
135
|
+
npm run smoke -- 127.0.0.1 1710 # read-only smoke against a live emulator/Core
|
|
136
|
+
npm run smoke:mcp -- 127.0.0.1 1710 # full MCP-over-stdio smoke against a live target
|
|
137
|
+
npm run smoke:write -- 127.0.0.1 1710 # live WRITE round-trip: set a gain, verify, restore
|
|
138
|
+
npm run smoke:named -- MainGain # live Named-Control read/set + change-group poll
|
|
139
|
+
npm run smoke:keepalive # idle >60s, prove the socket survives QRC's idle close
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`npm test` needs no hardware; every `smoke:*` script needs a live target (a real Core, or Designer in Emulate mode, on port 1710).
|
|
143
|
+
|
|
144
|
+
**CI** runs `npm ci && npm run build && npm run typecheck && npm test` on Linux, macOS, and Windows × Node 18 & 20 ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)). The whole suite is hardware-free — a mock QRC server plus an in-memory MCP transport — so the full matrix runs without a Core.
|
|
145
|
+
|
|
146
|
+
## Roadmap / out of scope
|
|
147
|
+
|
|
148
|
+
- **WebSocket transport** via `@q-sys/qrwc` — a convenience adapter for real Cores. Raw QRC is the primary transport today; the lib is still beta.
|
|
149
|
+
- **Real-Core validation** — every test so far runs against Designer's emulator; a physical Core run is the honest trigger to graduate to `1.0.0`.
|
|
150
|
+
- **Design authoring** (reading/writing `.qsys` files) — out of scope: `.qsys` is a compressed .NET `BinaryFormatter` graph type-coupled to QSC's assemblies.
|
|
151
|
+
|
|
152
|
+
## Changelog
|
|
153
|
+
|
|
154
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes, or the [GitHub releases](https://github.com/reowens/qsys-mcp/releases) page.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT — see [LICENSE](LICENSE). Q-SYS and QRC are trademarks/protocols of QSC, LLC; this project is an independent client and is not affiliated with or endorsed by QSC.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { buildServer } from './server.js';
|
|
4
|
+
async function main() {
|
|
5
|
+
const server = buildServer();
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
await server.connect(transport);
|
|
8
|
+
// stdout is the MCP channel; logs go to stderr.
|
|
9
|
+
console.error('qsys-mcp running on stdio');
|
|
10
|
+
}
|
|
11
|
+
main().catch((err) => {
|
|
12
|
+
console.error('fatal error:', err);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { QrcClient } from 'qsys-qrc';
|
|
4
|
+
let client = null;
|
|
5
|
+
let lastEngineStatus = null;
|
|
6
|
+
function ok(data) {
|
|
7
|
+
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
8
|
+
return { content: [{ type: 'text', text }] };
|
|
9
|
+
}
|
|
10
|
+
function fail(message) {
|
|
11
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
12
|
+
}
|
|
13
|
+
function requireClient() {
|
|
14
|
+
// Don't gate on isConnected(): a reconnect-enabled client may be mid-drop, and
|
|
15
|
+
// its send() transparently waits for the socket to come back. Only a missing
|
|
16
|
+
// client (never connected, or explicitly disconnected) is a hard error.
|
|
17
|
+
if (!client) {
|
|
18
|
+
throw new Error('Not connected to Q-SYS. Call qsys_connect first.');
|
|
19
|
+
}
|
|
20
|
+
return client;
|
|
21
|
+
}
|
|
22
|
+
/** Warn when a write targets a live Core rather than an emulator. */
|
|
23
|
+
function liveCoreWarning() {
|
|
24
|
+
if (lastEngineStatus && lastEngineStatus.IsEmulator === false) {
|
|
25
|
+
return `⚠ Writing to a LIVE Q-SYS Core (design "${lastEngineStatus.DesignName}"), not an emulator — this changes real audio.`;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const controlValue = z.union([z.number(), z.string(), z.boolean()]);
|
|
30
|
+
/** Client-side trim — QRC has no server-side filter/pagination, so we shape the full response. */
|
|
31
|
+
function shapeComponents(comps, opts) {
|
|
32
|
+
let out = comps;
|
|
33
|
+
if (opts.filter) {
|
|
34
|
+
const f = opts.filter.toLowerCase();
|
|
35
|
+
out = out.filter((c) => c.Name.toLowerCase().includes(f));
|
|
36
|
+
}
|
|
37
|
+
if (opts.type) {
|
|
38
|
+
const t = opts.type.toLowerCase();
|
|
39
|
+
out = out.filter((c) => (c.Type ?? '').toLowerCase().includes(t));
|
|
40
|
+
}
|
|
41
|
+
return opts.names_only ? out.map((c) => ({ Name: c.Name, Type: c.Type })) : out;
|
|
42
|
+
}
|
|
43
|
+
function shapeControls(res, opts) {
|
|
44
|
+
let ctrls = res.Controls;
|
|
45
|
+
if (opts.filter) {
|
|
46
|
+
const f = opts.filter.toLowerCase();
|
|
47
|
+
ctrls = ctrls.filter((c) => c.Name.toLowerCase().includes(f));
|
|
48
|
+
}
|
|
49
|
+
if (opts.names_only)
|
|
50
|
+
return { Name: res.Name, Controls: ctrls.map((c) => c.Name) };
|
|
51
|
+
return { ...res, Controls: ctrls };
|
|
52
|
+
}
|
|
53
|
+
export function buildServer() {
|
|
54
|
+
const server = new McpServer({ name: 'qsys-mcp', version: '0.1.0' });
|
|
55
|
+
server.registerTool('qsys_connect', {
|
|
56
|
+
title: 'Connect to Q-SYS',
|
|
57
|
+
description: 'Connect to a Q-SYS Core or to Q-SYS Designer running in Emulate mode (press F6 in Designer), over the QRC protocol (TCP). For a local emulator use host "127.0.0.1" and port 1710. Must be called before any other tool.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
host: z.string().default('127.0.0.1').describe('Core IP/hostname, or 127.0.0.1 for a local Designer emulator'),
|
|
60
|
+
port: z.number().int().default(1710).describe('QRC port (default 1710)'),
|
|
61
|
+
user: z.string().optional().describe('Username, if the design requires authentication'),
|
|
62
|
+
password: z.string().optional().describe('Password, if the design requires authentication'),
|
|
63
|
+
reconnect: z
|
|
64
|
+
.boolean()
|
|
65
|
+
.default(true)
|
|
66
|
+
.describe('Auto-reconnect on a dropped socket (Core restart, network blip), replaying change-group registrations so polling resumes. Default true.'),
|
|
67
|
+
},
|
|
68
|
+
}, async ({ host, port, user, password, reconnect }) => {
|
|
69
|
+
try {
|
|
70
|
+
if (client)
|
|
71
|
+
client.close();
|
|
72
|
+
const c = new QrcClient({ host, port, reconnect });
|
|
73
|
+
c.on('engineStatus', (s) => {
|
|
74
|
+
lastEngineStatus = s;
|
|
75
|
+
});
|
|
76
|
+
c.on('error', () => {
|
|
77
|
+
/* surfaced per-request; avoid crashing the server on transient socket errors */
|
|
78
|
+
});
|
|
79
|
+
c.on('reconnecting', (attempt) => console.error(`[qrc] connection dropped — reconnecting (attempt ${attempt})…`));
|
|
80
|
+
c.on('reconnected', () => console.error('[qrc] reconnected; change-group registrations replayed'));
|
|
81
|
+
c.on('reconnectFailed', () => console.error('[qrc] reconnect gave up; will retry on the next request'));
|
|
82
|
+
await c.connect();
|
|
83
|
+
if (user && password)
|
|
84
|
+
await c.logon(user, password);
|
|
85
|
+
client = c;
|
|
86
|
+
const status = await c.statusGet();
|
|
87
|
+
lastEngineStatus = status;
|
|
88
|
+
return ok({ connected: true, host, port, status });
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
client = null;
|
|
92
|
+
return fail(e.message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
server.registerTool('qsys_status', {
|
|
96
|
+
title: 'Q-SYS engine status',
|
|
97
|
+
description: 'Get the Q-SYS engine status: platform, design name, run state, emulator flag.',
|
|
98
|
+
inputSchema: {},
|
|
99
|
+
}, async () => {
|
|
100
|
+
try {
|
|
101
|
+
return ok(await requireClient().statusGet());
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return fail(e.message);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
server.registerTool('qsys_list_components', {
|
|
108
|
+
title: 'List components',
|
|
109
|
+
description: 'List named components in the running/emulated design, with type and properties (Component.GetComponents). On large designs use filter/type/names_only to trim the response.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
filter: z.string().optional().describe('Case-insensitive substring; only components whose name contains it are returned'),
|
|
112
|
+
type: z.string().optional().describe('Case-insensitive substring on component type (e.g. "gain", "mixer")'),
|
|
113
|
+
names_only: z.boolean().optional().describe('Return only name + type per component (drop properties) to save context'),
|
|
114
|
+
},
|
|
115
|
+
}, async ({ filter, type, names_only }) => {
|
|
116
|
+
try {
|
|
117
|
+
return ok(shapeComponents(await requireClient().getComponents(), { filter, type, names_only }));
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return fail(e.message);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
server.registerTool('qsys_get_component_controls', {
|
|
124
|
+
title: 'Get component controls',
|
|
125
|
+
description: "List a named component's controls and current values (Component.GetControls). Use filter/names_only to trim large components.",
|
|
126
|
+
inputSchema: {
|
|
127
|
+
name: z.string().describe('Component name (as returned by qsys_list_components)'),
|
|
128
|
+
filter: z.string().optional().describe('Case-insensitive substring; only controls whose name contains it are returned'),
|
|
129
|
+
names_only: z.boolean().optional().describe('Return only control names (drop values/positions) to save context'),
|
|
130
|
+
},
|
|
131
|
+
}, async ({ name, filter, names_only }) => {
|
|
132
|
+
try {
|
|
133
|
+
return ok(shapeControls(await requireClient().getComponentControls(name), { filter, names_only }));
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
return fail(e.message);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
server.registerTool('qsys_get_control', {
|
|
140
|
+
title: 'Get control values',
|
|
141
|
+
description: 'Get the current values of one or more Named Controls (Control.Get).',
|
|
142
|
+
inputSchema: { names: z.array(z.string()).min(1).describe('Named Control names') },
|
|
143
|
+
}, async ({ names }) => {
|
|
144
|
+
try {
|
|
145
|
+
return ok(await requireClient().getControl(names));
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
return fail(e.message);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
server.registerTool('qsys_get_component', {
|
|
152
|
+
title: 'Get specific component control values',
|
|
153
|
+
description: 'Get specific control values within a named component (Component.Get).',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
name: z.string().describe('Component name'),
|
|
156
|
+
controls: z.array(z.string()).min(1).describe('Control names within the component'),
|
|
157
|
+
},
|
|
158
|
+
}, async ({ name, controls }) => {
|
|
159
|
+
try {
|
|
160
|
+
return ok(await requireClient().getComponent(name, controls));
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return fail(e.message);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
server.registerTool('qsys_set_control', {
|
|
167
|
+
title: 'Set a control value',
|
|
168
|
+
description: 'Set a Named Control value, optionally ramped over a number of seconds (Control.Set). This MUTATES the running/emulated system.',
|
|
169
|
+
inputSchema: {
|
|
170
|
+
name: z.string().describe('Named Control name'),
|
|
171
|
+
value: controlValue.describe('New value (number, string, or boolean)'),
|
|
172
|
+
ramp: z.number().optional().describe('Ramp time in seconds (optional)'),
|
|
173
|
+
},
|
|
174
|
+
}, async ({ name, value, ramp }) => {
|
|
175
|
+
try {
|
|
176
|
+
const result = await requireClient().setControl(name, value, ramp);
|
|
177
|
+
const warning = liveCoreWarning();
|
|
178
|
+
return ok(warning ? { warning, result } : result);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return fail(e.message);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
server.registerTool('qsys_set_component', {
|
|
185
|
+
title: 'Set component control values',
|
|
186
|
+
description: 'Set one or more control values within a named component, each optionally ramped (Component.Set). This MUTATES the running/emulated system.',
|
|
187
|
+
inputSchema: {
|
|
188
|
+
name: z.string().describe('Component name'),
|
|
189
|
+
controls: z
|
|
190
|
+
.array(z.object({
|
|
191
|
+
name: z.string(),
|
|
192
|
+
value: controlValue,
|
|
193
|
+
ramp: z.number().optional(),
|
|
194
|
+
}))
|
|
195
|
+
.min(1),
|
|
196
|
+
},
|
|
197
|
+
}, async ({ name, controls }) => {
|
|
198
|
+
try {
|
|
199
|
+
const mapped = controls.map((c) => ({
|
|
200
|
+
Name: c.name,
|
|
201
|
+
Value: c.value,
|
|
202
|
+
...(c.ramp != null ? { Ramp: c.ramp } : {}),
|
|
203
|
+
}));
|
|
204
|
+
const result = await requireClient().setComponent(name, mapped);
|
|
205
|
+
const warning = liveCoreWarning();
|
|
206
|
+
return ok(warning ? { warning, result } : result);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return fail(e.message);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
server.registerTool('qsys_load_snapshot', {
|
|
213
|
+
title: 'Recall a snapshot',
|
|
214
|
+
description: 'Recall control settings from a saved snapshot bank/number, optionally ramped over seconds (Snapshot.Load). This MUTATES the running/emulated system.',
|
|
215
|
+
inputSchema: {
|
|
216
|
+
bank: z.string().describe('Snapshot bank name, as named in Q-SYS Designer'),
|
|
217
|
+
number: z.number().int().min(1).describe('Snapshot number within the bank (1-based)'),
|
|
218
|
+
ramp: z.number().optional().describe('Ramp time in seconds (optional)'),
|
|
219
|
+
},
|
|
220
|
+
}, async ({ bank, number, ramp }) => {
|
|
221
|
+
try {
|
|
222
|
+
const result = await requireClient().snapshotLoad(bank, number, ramp);
|
|
223
|
+
const warning = liveCoreWarning();
|
|
224
|
+
return ok(warning ? { warning, result } : result);
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
return fail(e.message);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
server.registerTool('qsys_save_snapshot', {
|
|
231
|
+
title: 'Save a snapshot',
|
|
232
|
+
description: 'Capture the current control settings into a snapshot bank/number (Snapshot.Save). This OVERWRITES the stored snapshot.',
|
|
233
|
+
inputSchema: {
|
|
234
|
+
bank: z.string().describe('Snapshot bank name, as named in Q-SYS Designer'),
|
|
235
|
+
number: z.number().int().min(1).describe('Snapshot number within the bank (1-based)'),
|
|
236
|
+
},
|
|
237
|
+
}, async ({ bank, number }) => {
|
|
238
|
+
try {
|
|
239
|
+
return ok(await requireClient().snapshotSave(bank, number));
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
return fail(e.message);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
server.registerTool('qsys_create_change_group', {
|
|
246
|
+
title: 'Create or extend a change group',
|
|
247
|
+
description: 'Create a change group (or add Named Controls to an existing one) so you can poll for changes (ChangeGroup.AddControl).',
|
|
248
|
+
inputSchema: {
|
|
249
|
+
id: z.string().describe('Change group id (any string; reused on poll)'),
|
|
250
|
+
controls: z.array(z.string()).min(1).describe('Named Control names to watch'),
|
|
251
|
+
},
|
|
252
|
+
}, async ({ id, controls }) => {
|
|
253
|
+
try {
|
|
254
|
+
return ok(await requireClient().changeGroupAddControl(id, controls));
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
return fail(e.message);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
server.registerTool('qsys_poll_change_group', {
|
|
261
|
+
title: 'Poll a change group',
|
|
262
|
+
description: 'Poll a change group; returns the controls that changed since the last poll (ChangeGroup.Poll).',
|
|
263
|
+
inputSchema: { id: z.string().describe('Change group id') },
|
|
264
|
+
}, async ({ id }) => {
|
|
265
|
+
try {
|
|
266
|
+
return ok(await requireClient().changeGroupPoll(id));
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
return fail(e.message);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
server.registerTool('qsys_change_group_add_component', {
|
|
273
|
+
title: 'Add component controls to a change group',
|
|
274
|
+
description: "Add a named component's controls to a change group so you can poll them for changes (ChangeGroup.AddComponentControl).",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
id: z.string().describe('Change group id (any string; reused on poll)'),
|
|
277
|
+
component: z.string().describe('Component name'),
|
|
278
|
+
controls: z.array(z.string()).min(1).describe('Control names within the component to watch'),
|
|
279
|
+
},
|
|
280
|
+
}, async ({ id, component, controls }) => {
|
|
281
|
+
try {
|
|
282
|
+
return ok(await requireClient().changeGroupAddComponentControl(id, component, controls));
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
return fail(e.message);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
server.registerTool('qsys_destroy_change_group', {
|
|
289
|
+
title: 'Destroy a change group',
|
|
290
|
+
description: 'Destroy a change group, freeing its server-side state (ChangeGroup.Destroy).',
|
|
291
|
+
inputSchema: { id: z.string().describe('Change group id') },
|
|
292
|
+
}, async ({ id }) => {
|
|
293
|
+
try {
|
|
294
|
+
return ok(await requireClient().changeGroupDestroy(id));
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
return fail(e.message);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
server.registerTool('qsys_change_group_remove', {
|
|
301
|
+
title: 'Remove controls from a change group',
|
|
302
|
+
description: 'Remove Named Controls from a change group, leaving the group in place (ChangeGroup.Remove). Returns any unknown control names.',
|
|
303
|
+
inputSchema: {
|
|
304
|
+
id: z.string().describe('Change group id'),
|
|
305
|
+
controls: z.array(z.string()).min(1).describe('Named Control names to stop watching'),
|
|
306
|
+
},
|
|
307
|
+
}, async ({ id, controls }) => {
|
|
308
|
+
try {
|
|
309
|
+
return ok(await requireClient().changeGroupRemove(id, controls));
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
return fail(e.message);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
server.registerTool('qsys_change_group_clear', {
|
|
316
|
+
title: 'Clear a change group',
|
|
317
|
+
description: 'Remove all controls from a change group without destroying it (ChangeGroup.Clear).',
|
|
318
|
+
inputSchema: { id: z.string().describe('Change group id') },
|
|
319
|
+
}, async ({ id }) => {
|
|
320
|
+
try {
|
|
321
|
+
return ok(await requireClient().changeGroupClear(id));
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
return fail(e.message);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
server.registerTool('qsys_change_group_invalidate', {
|
|
328
|
+
title: 'Invalidate a change group',
|
|
329
|
+
description: 'Invalidate a change group so the next poll resends every watched control, not just the changes (ChangeGroup.Invalidate). Handy after a reconnect to force a full snapshot.',
|
|
330
|
+
inputSchema: { id: z.string().describe('Change group id') },
|
|
331
|
+
}, async ({ id }) => {
|
|
332
|
+
try {
|
|
333
|
+
return ok(await requireClient().changeGroupInvalidate(id));
|
|
334
|
+
}
|
|
335
|
+
catch (e) {
|
|
336
|
+
return fail(e.message);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
// ChangeGroup.AutoPoll is intentionally omitted: MCP stdio is request/response,
|
|
340
|
+
// so a Core-pushed poll has nowhere to land; manual qsys_poll_change_group covers it.
|
|
341
|
+
server.registerTool('qsys_disconnect', {
|
|
342
|
+
title: 'Disconnect from Q-SYS',
|
|
343
|
+
description: 'Close the QRC connection to the Core/emulator. A later qsys_connect is required before other tools.',
|
|
344
|
+
inputSchema: {},
|
|
345
|
+
}, async () => {
|
|
346
|
+
if (client) {
|
|
347
|
+
client.close();
|
|
348
|
+
client = null;
|
|
349
|
+
lastEngineStatus = null;
|
|
350
|
+
}
|
|
351
|
+
return ok({ disconnected: true });
|
|
352
|
+
});
|
|
353
|
+
return server;
|
|
354
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qsys-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Control a Q-SYS audio/video system from an AI agent over QSC's QRC protocol — against a real Core or Q-SYS Designer in Emulate mode. Open-source, cross-platform, zero QSC code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"qsys-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.build.json",
|
|
12
|
+
"postbuild": "node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
13
|
+
"prepare": "npm run build",
|
|
14
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"dev": "tsx src/index.ts",
|
|
17
|
+
"test": "node ../../scripts/run-if-emulator.mjs \"tsx test/integration.ts && tsx test/mcp-mock.ts && tsx test/reconnect.ts\"",
|
|
18
|
+
"test:mcp": "tsx test/mcp-mock.ts",
|
|
19
|
+
"test:reconnect": "tsx test/reconnect.ts",
|
|
20
|
+
"smoke": "tsx test/live-smoke.ts",
|
|
21
|
+
"smoke:write": "tsx test/live-write.ts",
|
|
22
|
+
"smoke:mcp": "tsx test/mcp-smoke.ts",
|
|
23
|
+
"smoke:named": "tsx test/live-named.ts",
|
|
24
|
+
"smoke:keepalive": "tsx test/keepalive.ts"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"q-sys",
|
|
28
|
+
"qsys",
|
|
29
|
+
"qsc",
|
|
30
|
+
"mcp",
|
|
31
|
+
"model-context-protocol",
|
|
32
|
+
"qrc",
|
|
33
|
+
"av",
|
|
34
|
+
"audiovisual",
|
|
35
|
+
"audio",
|
|
36
|
+
"dsp",
|
|
37
|
+
"ai-agent",
|
|
38
|
+
"claude-code",
|
|
39
|
+
"typescript"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/reowens/qsys-tools.git",
|
|
45
|
+
"directory": "packages/qsys-mcp"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/reowens/qsys-tools/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/reowens/qsys-tools/tree/main/packages/qsys-mcp#readme",
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
61
|
+
"qsys-qrc": "^0.1.0",
|
|
62
|
+
"zod": "^3.23.8"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^22.5.0",
|
|
66
|
+
"tsx": "^4.19.0",
|
|
67
|
+
"typescript": "^5.6.0"
|
|
68
|
+
}
|
|
69
|
+
}
|