crestron-mcp 1.8.1

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/dist/index.js ADDED
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Crestron MCP Server (Node/TypeScript)
4
+ *
5
+ * Exposes a Crestron 4-Series system to Claude over MCP. Connects to the
6
+ * processor (or the simulator) using the text protocol in PROTOCOL.md.
7
+ *
8
+ * Transport is stdio (the standard way an MCP client launches a local server),
9
+ * so NOTHING may be written to stdout except the MCP protocol itself; all
10
+ * diagnostics go to stderr.
11
+ */
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { z } from "zod";
15
+ import { CrestronConnection } from "./connection.js";
16
+ import { loadConfig } from "./config.js";
17
+ const cfg = loadConfig();
18
+ const crestron = new CrestronConnection(cfg.host, cfg.port, cfg.auth, cfg.key, cfg.tls);
19
+ const server = new McpServer({ name: "crestron-control", version: "1.8.1" });
20
+ const ok = (obj) => ({
21
+ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
22
+ });
23
+ const fail = (e) => ({
24
+ content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
25
+ });
26
+ server.registerTool("discover_crestron_system", {
27
+ description: "Discover the devices and capabilities available in the Crestron system. " +
28
+ "Returns rooms, categories, and device counts.",
29
+ }, async () => {
30
+ try {
31
+ return ok(await crestron.discoverSystem());
32
+ }
33
+ catch (e) {
34
+ return fail(e);
35
+ }
36
+ });
37
+ server.registerTool("list_crestron_rooms", { description: "List all rooms in the building with their device counts." }, async () => {
38
+ try {
39
+ return ok(await crestron.listRooms());
40
+ }
41
+ catch (e) {
42
+ return fail(e);
43
+ }
44
+ });
45
+ server.registerTool("list_crestron_devices", {
46
+ description: "List controllable devices, optionally filtered by room and/or category " +
47
+ "(Lighting, AV, HVAC, Shades).",
48
+ inputSchema: {
49
+ room: z.string().optional().describe("Optional room name or id to filter by."),
50
+ category: z.string().optional().describe("Optional category to filter by (Lighting, AV, HVAC, Shades)."),
51
+ },
52
+ }, async ({ room, category }) => {
53
+ try {
54
+ return ok(await crestron.listDevices(room, category));
55
+ }
56
+ catch (e) {
57
+ return fail(e);
58
+ }
59
+ });
60
+ server.registerTool("query_crestron_device", {
61
+ description: "Get the live state of a device: its current value plus whether it is idle, ramping " +
62
+ "(with target and completes_at), or pulsing (releases_at), and any pending scheduled action " +
63
+ "(with fires_at). Time fields are epoch milliseconds (matching get_crestron_time); " +
64
+ "remaining_ms / in_ms tell you directly how long until it finishes/fires, so you can decide " +
65
+ "how long to wait without polling a clock. One read tells you idle vs in-flight vs scheduled.",
66
+ inputSchema: {
67
+ device_id: z.string().describe('Unique device identifier (e.g. "conf_rm_a_lights_on").'),
68
+ },
69
+ }, async ({ device_id }) => {
70
+ try {
71
+ return ok(await crestron.queryDevice(device_id));
72
+ }
73
+ catch (e) {
74
+ return fail(e);
75
+ }
76
+ });
77
+ server.registerTool("get_crestron_time", {
78
+ description: "Get the processor's current time as epoch milliseconds (epoch_ms) and ISO 8601 (iso). Use " +
79
+ "it to correlate the absolute *_at timestamps from query_crestron_device, or whenever you " +
80
+ "need the system's real time (no need to decode a wired clock).",
81
+ }, async () => {
82
+ try {
83
+ return ok(await crestron.getTime());
84
+ }
85
+ catch (e) {
86
+ return fail(e);
87
+ }
88
+ });
89
+ server.registerTool("control_crestron_device", {
90
+ description: "Set a device's value. Optionally schedule it to run after a delay (delay_ms), e.g. " +
91
+ '"turn the porch light on in 30 seconds". The result confirms the outcome: a "status" ' +
92
+ 'summary (e.g. "now 50000", or "feedback reads X (set Y)" if it differs, or "scheduled to ' +
93
+ 'set ... in ~30s") plus the full "confirmed" state, so you can see what actually happened ' +
94
+ "without a separate query.",
95
+ inputSchema: {
96
+ device_id: z.string().describe('Unique device identifier (e.g. "conf_rm_a_lights_on").'),
97
+ value: z.string().describe('New value - digital "0"/"1", analog "0"-"65535", or serial text.'),
98
+ delay_ms: z
99
+ .number()
100
+ .int()
101
+ .optional()
102
+ .describe("Optional delay in milliseconds before the set runs on the processor (0 / omit = immediate)."),
103
+ },
104
+ }, async ({ device_id, value, delay_ms }) => {
105
+ try {
106
+ return ok(await crestron.setDevice(device_id, value, delay_ms ?? 0));
107
+ }
108
+ catch (e) {
109
+ return fail(e);
110
+ }
111
+ });
112
+ server.registerTool("set_crestron_devices", {
113
+ description: "Apply a scene/macro: set many devices in one call. Each entry can optionally fade " +
114
+ "(duration_ms, analog only - the device ramps to value instead of snapping) and/or start " +
115
+ 'after a wait (delay_ms). Use for "movie night" (fade lights down over 2s + lower screen + ' +
116
+ "projector on) or staged sequences. Values follow control_crestron_device rules " +
117
+ "(digital/analog/serial). A plain (no-timing) value may contain colons but not commas. " +
118
+ 'Each result entry carries a "status" summary and "confirmed" state for that device.',
119
+ inputSchema: {
120
+ assignments: z
121
+ .array(z.object({
122
+ device_id: z.string().describe('Device id, e.g. "lounge_d1".'),
123
+ value: z.string().describe('Value - digital "0"/"1", analog "0"-"65535", or serial text.'),
124
+ duration_ms: z
125
+ .number()
126
+ .int()
127
+ .optional()
128
+ .describe("Optional fade time in ms (ANALOG only); the device ramps to value over this time instead of snapping."),
129
+ delay_ms: z
130
+ .number()
131
+ .int()
132
+ .optional()
133
+ .describe("Optional wait in ms before this entry runs (works for set, fade, pulse-via-value)."),
134
+ }))
135
+ .describe("The devices to set, each {device_id, value, duration_ms?, delay_ms?}."),
136
+ },
137
+ }, async ({ assignments }) => {
138
+ try {
139
+ return ok(await crestron.setDevices(assignments));
140
+ }
141
+ catch (e) {
142
+ return fail(e);
143
+ }
144
+ });
145
+ server.registerTool("pulse_crestron_device", {
146
+ description: "Momentarily pulse a DIGITAL device: drive it on for pulse_ms, then back off - a simulated " +
147
+ 'button press. Use for momentary triggers like "press the doorbell", "tap the projector power ' +
148
+ 'button", "trigger the gate". Optionally wait delay_ms before the pulse. Digital devices only; ' +
149
+ "analog and serial devices are rejected (use control_crestron_device / ramp_crestron_device). " +
150
+ 'The result includes a "status" (e.g. "pulsing, releases in ~500ms") plus "confirmed" state.',
151
+ inputSchema: {
152
+ device_id: z.string().describe('Unique digital device identifier (e.g. "lounge_d3").'),
153
+ pulse_ms: z.number().int().describe("How long to hold it on, in milliseconds (e.g. 500)."),
154
+ delay_ms: z
155
+ .number()
156
+ .int()
157
+ .optional()
158
+ .describe("Optional delay in milliseconds before the pulse starts (0 / omit = immediate)."),
159
+ },
160
+ }, async ({ device_id, pulse_ms, delay_ms }) => {
161
+ try {
162
+ return ok(await crestron.pulseDevice(device_id, pulse_ms, delay_ms ?? 0));
163
+ }
164
+ catch (e) {
165
+ return fail(e);
166
+ }
167
+ });
168
+ server.registerTool("cancel_crestron_device", {
169
+ description: "Stop/cancel activity on a device: stop a ramp (fade) in progress and leave the level where " +
170
+ "it is, release a pulse in progress to off, and clear any pending delayed action (a scheduled " +
171
+ "set or pulse). Does not otherwise change the device's value - a device that is simply on/high " +
172
+ 'from a normal set stays on. Use for "stop the fade", "stop ringing the bell", "cancel that ' +
173
+ 'timer". Works on any device type.',
174
+ inputSchema: {
175
+ device_id: z.string().describe('Unique device identifier (e.g. "lounge_a3").'),
176
+ },
177
+ }, async ({ device_id }) => {
178
+ try {
179
+ return ok(await crestron.cancelDevice(device_id));
180
+ }
181
+ catch (e) {
182
+ return fail(e);
183
+ }
184
+ });
185
+ server.registerTool("ramp_crestron_device", {
186
+ description: 'Smoothly ramp (fade) an ANALOG device to a value over a duration. Use for requests like ' +
187
+ '"fade the lounge lights to 50% over 3 seconds". Optionally start the fade after delay_ms ' +
188
+ '("fade down in 30 seconds, over 2 seconds"). Analog devices only; digital and serial ' +
189
+ "devices don't ramp - use control_crestron_device for those (and for an instant analog set). " +
190
+ 'The result includes a "status" like "fading to 50000, ~3s left" plus "confirmed" state.',
191
+ inputSchema: {
192
+ device_id: z.string().describe('Unique device identifier (e.g. "lounge_a1").'),
193
+ value: z.string().describe('Target analog value "0"-"65535".'),
194
+ duration_ms: z.number().int().describe("Ramp duration in milliseconds (e.g. 3000 for 3 seconds)."),
195
+ delay_ms: z
196
+ .number()
197
+ .int()
198
+ .optional()
199
+ .describe("Optional delay in ms before the fade starts (0 / omit = immediate)."),
200
+ },
201
+ }, async ({ device_id, value, duration_ms, delay_ms }) => {
202
+ try {
203
+ return ok(await crestron.rampDevice(device_id, value, duration_ms, delay_ms ?? 0));
204
+ }
205
+ catch (e) {
206
+ return fail(e);
207
+ }
208
+ });
209
+ server.registerTool("get_room_status", {
210
+ description: "Get the status of every device in a room.",
211
+ inputSchema: {
212
+ room_name: z.string().describe('Room name or id (e.g. "Conference Room A" or "conf_rm_a").'),
213
+ },
214
+ }, async ({ room_name }) => {
215
+ try {
216
+ const devices = (await crestron.listDevices(room_name));
217
+ return ok({ room: room_name, device_count: devices.length, devices });
218
+ }
219
+ catch (e) {
220
+ return fail(e);
221
+ }
222
+ });
223
+ server.registerTool("activate_crestron_license", {
224
+ description: "Activate (license) the Crestron processor with a license key the user provides. Use this " +
225
+ "when a command fails because the processor isn't licensed: the error explains how, and " +
226
+ "shows the processor's activation code (MAC). Ask the user for the license key issued for " +
227
+ "that code, then call this with it. Activation is one-time - the key is stored on the " +
228
+ "processor, so it stays licensed for every client and across reboots. The key is not a " +
229
+ "secret (it only works on this one processor), so it's fine to receive it in chat.",
230
+ inputSchema: {
231
+ license_key: z.string().describe("The license key the user obtained for this processor."),
232
+ },
233
+ }, async ({ license_key }) => {
234
+ try {
235
+ return ok(await crestron.activateLicense(license_key));
236
+ }
237
+ catch (e) {
238
+ return fail(e);
239
+ }
240
+ });
241
+ server.registerTool("get_crestron_license_status", {
242
+ description: "Check the processor's license/trial state: whether it's licensed right now, whether that's a " +
243
+ "time-limited free trial (time_limited), how much trial time remains (remaining_human / " +
244
+ "remaining_ms), the processor MAC, and a buy_url. Call it to orient at the start of a session " +
245
+ "and whenever license status is relevant. If it's a trial, mention the remaining time naturally; " +
246
+ "as it gets low (under ~2 days) gently offer to start another free trial or buy a license. " +
247
+ "Nudge, don't nag.",
248
+ }, async () => {
249
+ try {
250
+ return ok(await crestron.licenseStatus());
251
+ }
252
+ catch (e) {
253
+ return fail(e);
254
+ }
255
+ });
256
+ server.registerTool("start_crestron_trial", {
257
+ description: "Start a free 7-day trial on this processor - no payment, no card, nothing for the user to " +
258
+ "paste. It contacts Solution AV's licensing server, which mints a signed trial bound to the " +
259
+ "processor's MAC and counts it there (this enforces the per-processor limit); the only data " +
260
+ "sent is the MAC, and the signed trial is then stored on the processor. If asked, describe it " +
261
+ "accurately (issued and counted online by the licensing server, then stored on the box) and do " +
262
+ "not claim nothing happens online. Use it when the processor is unlicensed, or when a trial has " +
263
+ "lapsed and the user wants to keep going. Each processor gets up to 3 one-week trials; this " +
264
+ "reports trials_remaining and the expiry after starting one. When the trials are used up the " +
265
+ "result carries buy_url (with the MAC pre-filled) and a next_step; present that full link to " +
266
+ "the user right away, don't wait to be asked for it. The underlying AV keeps working " +
267
+ "regardless; licensing only gates this natural-language layer.",
268
+ }, async () => {
269
+ try {
270
+ return ok(await crestron.startTrial());
271
+ }
272
+ catch (e) {
273
+ return fail(e);
274
+ }
275
+ });
276
+ async function main() {
277
+ const transport = new StdioServerTransport();
278
+ await server.connect(transport);
279
+ console.error(`crestron-mcp: ready (target ${cfg.host}:${cfg.port}, ` +
280
+ `${cfg.key ? "mode 2 key" : cfg.auth ? "mode 1 password" : "open"}${cfg.tls ? " + TLS" : ""})`);
281
+ }
282
+ main().catch((e) => {
283
+ console.error("crestron-mcp: fatal", e);
284
+ process.exit(1);
285
+ });
@@ -0,0 +1,76 @@
1
+ {
2
+ "manifest_version": "0.2",
3
+ "name": "crestron-mcp",
4
+ "display_name": "Crestron Control",
5
+ "version": "1.8.1",
6
+ "description": "Query and control a Crestron 4-Series AV system in natural language.",
7
+ "long_description": "Connects Claude to a Crestron 4-Series processor running the CrestronMCP modules. Lets you discover rooms and devices, read and set lighting, AV, HVAC and shades, and smoothly ramp analog levels - all in plain English. Talks to the processor over the CrestronMCP text protocol, with optional password (mode 1) or secure-key + TLS (mode 2) authentication.",
8
+ "author": {
9
+ "name": "Solution AV Automation"
10
+ },
11
+ "license": "UNLICENSED",
12
+ "server": {
13
+ "type": "node",
14
+ "entry_point": "server/index.mjs",
15
+ "mcp_config": {
16
+ "command": "node",
17
+ "args": ["${__dirname}/server/index.mjs"],
18
+ "env": {
19
+ "CRESTRON_HOST": "${user_config.host}",
20
+ "CRESTRON_PORT": "${user_config.port}",
21
+ "CRESTRON_KEY": "${user_config.key}",
22
+ "CRESTRON_AUTH": "${user_config.password}"
23
+ }
24
+ }
25
+ },
26
+ "tools": [
27
+ { "name": "discover_crestron_system", "description": "Discover rooms, categories, and device counts in the Crestron system." },
28
+ { "name": "list_crestron_rooms", "description": "List all rooms with their device counts." },
29
+ { "name": "list_crestron_devices", "description": "List controllable devices, optionally filtered by room and/or category." },
30
+ { "name": "query_crestron_device", "description": "Get the live state of a device: value plus whether it is idle, ramping (target + completes_at), or pulsing (releases_at), and any pending scheduled action." },
31
+ { "name": "get_crestron_time", "description": "Get the processor's current time as epoch milliseconds and ISO 8601." },
32
+ { "name": "control_crestron_device", "description": "Set a device's value (digital, analog, or serial), optionally after a delay." },
33
+ { "name": "set_crestron_devices", "description": "Set multiple devices at once in one round trip - for scenes/macros." },
34
+ { "name": "pulse_crestron_device", "description": "Momentarily pulse a digital device on then off (a simulated button press), optionally after a delay." },
35
+ { "name": "ramp_crestron_device", "description": "Smoothly ramp (fade) an analog device to a value over a duration." },
36
+ { "name": "cancel_crestron_device", "description": "Stop a ramp/pulse in progress and clear any pending delayed action on a device, without otherwise changing its value." },
37
+ { "name": "get_room_status", "description": "Get the status of every device in a room." },
38
+ { "name": "activate_crestron_license", "description": "Activate (license) the processor with a key the user provides, when a command reports the processor is unlicensed. One-time; stored on the processor." },
39
+ { "name": "get_crestron_license_status", "description": "Check the processor's license/trial state: licensed now, whether it's a time-limited trial, remaining trial time, MAC, and a buy URL." },
40
+ { "name": "start_crestron_trial", "description": "Start a free 7-day trial on the processor (no payment). Up to 3 per processor; reports how many remain, or a buy link when used up." }
41
+ ],
42
+ "user_config": {
43
+ "host": {
44
+ "type": "string",
45
+ "title": "Processor address",
46
+ "description": "IP address or hostname of the Crestron processor on your network (e.g. 10.0.1.38).",
47
+ "required": true
48
+ },
49
+ "port": {
50
+ "type": "number",
51
+ "title": "Port",
52
+ "description": "TCP port the CrestronMCP server listens on.",
53
+ "default": 50794,
54
+ "required": false
55
+ },
56
+ "key": {
57
+ "type": "string",
58
+ "title": "Secure key",
59
+ "description": "Recommended. The processor's secure key, shown on the MCP Server Config module's Key output. Setting it turns on encryption (TLS) and secure authentication automatically. Leave blank only if your processor is in open or password mode.",
60
+ "sensitive": true,
61
+ "required": false
62
+ },
63
+ "password": {
64
+ "type": "string",
65
+ "title": "Password (only for password mode)",
66
+ "description": "Use this only if your processor is set to password mode. Leave blank otherwise. Ignored when a secure key is set.",
67
+ "sensitive": true,
68
+ "required": false
69
+ }
70
+ },
71
+ "compatibility": {
72
+ "runtimes": {
73
+ "node": ">=18.0.0"
74
+ }
75
+ }
76
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "crestron-mcp",
3
+ "version": "1.8.1",
4
+ "description": "MCP server exposing a Crestron 4-Series AV system to Claude in natural language.",
5
+ "type": "module",
6
+ "bin": {
7
+ "crestron-mcp": "dist/index.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "mcpb/manifest.json",
13
+ "AGENT_GUIDE.md",
14
+ "PROTOCOL.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "start": "node dist/index.js",
22
+ "bundle": "bun build src/index.ts --target=node --format=esm --outfile mcpb/server/index.mjs",
23
+ "mcpb": "npm run bundle && mcpb pack mcpb crestron-mcp.mcpb",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/SolutionAVAutomation/crestron-mcp-client.git"
29
+ },
30
+ "homepage": "https://solutionav.com.au/crestron-mcp/",
31
+ "bugs": {
32
+ "url": "https://github.com/SolutionAVAutomation/crestron-mcp-client/issues"
33
+ },
34
+ "keywords": [
35
+ "mcp",
36
+ "model-context-protocol",
37
+ "crestron",
38
+ "claude",
39
+ "av",
40
+ "automation",
41
+ "home-automation"
42
+ ],
43
+ "author": "Solution AV Automation Pty Ltd",
44
+ "license": "MIT",
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "zod": "^4.4.3"
48
+ },
49
+ "devDependencies": {
50
+ "@anthropic-ai/mcpb": "^2.1.2",
51
+ "@types/node": "^25.9.3",
52
+ "bun": "^1.3.14",
53
+ "typescript": "^6.0.3"
54
+ }
55
+ }