keyboard-maestro-mcp 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1133 @@
1
+ #!/usr/bin/env node
2
+ // Allow self-signed certificates for local Keyboard Maestro servers
3
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { readFile, writeFile, access, mkdir } from "fs/promises";
8
+ import { constants } from "fs";
9
+ import { join, dirname } from "path";
10
+ import { randomUUID } from "crypto";
11
+ import { homedir } from "os";
12
+ import { fileURLToPath } from "url";
13
+ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
14
+ import { SAVE_CLIPBOARD_MACRO_UID, generateMacrosFile, expandPath, validatePath, SUGGESTED_PATHS, } from "./macros-template.js";
15
+ import { testMachineConnection, checkMacroGroupExists, readMcpConfig, writeMcpConfig, generateServerConfig, getConfigState, } from "./config-manager.js";
16
+ // Get __dirname equivalent in ESM
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = dirname(__filename);
19
+ // Parse machine configuration from environment
20
+ export function getMachines() {
21
+ const machinesEnv = process.env.KM_MACHINES;
22
+ if (!machinesEnv) {
23
+ // Check for single-machine config via individual environment variables
24
+ const host = process.env.KM_HOST;
25
+ const port = process.env.KM_PORT;
26
+ const username = process.env.KM_USERNAME;
27
+ const password = process.env.KM_PASSWORD;
28
+ if (host && username && password) {
29
+ return [
30
+ {
31
+ name: process.env.KM_NAME || host,
32
+ host,
33
+ port: port ? parseInt(port, 10) : 4490,
34
+ username,
35
+ password,
36
+ secure: process.env.KM_SECURE === "true",
37
+ outputDir: process.env.KM_OUTPUT_DIR,
38
+ saveClipboardMacroUid: process.env.KM_SAVE_CLIPBOARD_MACRO_UID || SAVE_CLIPBOARD_MACRO_UID,
39
+ },
40
+ ];
41
+ }
42
+ return [];
43
+ }
44
+ try {
45
+ const parsed = JSON.parse(machinesEnv);
46
+ if (!Array.isArray(parsed)) {
47
+ console.error("KM_MACHINES must be a JSON array");
48
+ return [];
49
+ }
50
+ return parsed.map((m) => ({
51
+ name: m.name || "unnamed",
52
+ host: m.host,
53
+ port: m.port || 4490,
54
+ username: m.username,
55
+ password: m.password,
56
+ secure: m.secure ?? false,
57
+ outputDir: m.outputDir,
58
+ saveClipboardMacroUid: m.saveClipboardMacroUid || SAVE_CLIPBOARD_MACRO_UID,
59
+ }));
60
+ }
61
+ catch (e) {
62
+ console.error("Failed to parse KM_MACHINES:", e);
63
+ return [];
64
+ }
65
+ }
66
+ // Build the base URL for a machine
67
+ export function getBaseUrl(machine) {
68
+ const protocol = machine.secure ? "https" : "http";
69
+ const port = machine.secure ? machine.port + 1 : machine.port;
70
+ return `${protocol}://${machine.host}:${port}`;
71
+ }
72
+ // Build Basic Auth header
73
+ export function getAuthHeader(machine) {
74
+ const credentials = Buffer.from(`${machine.username}:${machine.password}`).toString("base64");
75
+ return `Basic ${credentials}`;
76
+ }
77
+ // Find a machine by name (case-insensitive)
78
+ export function findMachine(machines, name) {
79
+ if (!name) {
80
+ return machines[0];
81
+ }
82
+ return machines.find((m) => m.name.toLowerCase() === name.toLowerCase());
83
+ }
84
+ // Decode HTML entities in strings
85
+ export function decodeHtmlEntities(text) {
86
+ return text
87
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(parseInt(code, 10)))
88
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCodePoint(parseInt(code, 16)))
89
+ .replace(/&/g, "&")
90
+ .replace(/&lt;/g, "<")
91
+ .replace(/&gt;/g, ">")
92
+ .replace(/&quot;/g, '"')
93
+ .replace(/&apos;/g, "'");
94
+ }
95
+ // Fetch and parse the list of macros from a machine's web server
96
+ export async function listMacros(machine) {
97
+ // Use the authenticated HTTPS endpoint to get all macros (public + protected)
98
+ const httpsPort = machine.port + 1;
99
+ const url = `https://${machine.host}:${httpsPort}/authenticated.html`;
100
+ try {
101
+ const response = await fetch(url, {
102
+ method: "GET",
103
+ headers: {
104
+ Authorization: getAuthHeader(machine),
105
+ },
106
+ });
107
+ if (!response.ok) {
108
+ if (response.status === 401) {
109
+ return {
110
+ success: false,
111
+ message: `Authentication failed for ${machine.name}. Check username/password.`,
112
+ };
113
+ }
114
+ return {
115
+ success: false,
116
+ message: `Failed to fetch macros from ${machine.name}: HTTP ${response.status}`,
117
+ };
118
+ }
119
+ const html = await response.text();
120
+ // Parse the HTML to extract macros from the Protected Macros section
121
+ // Macros are in <option> tags within <optgroup> tags:
122
+ // <optgroup label="Group Name">
123
+ // <option label="Macro Name" value="UUID">Macro Name</option>
124
+ // </optgroup>
125
+ const macros = [];
126
+ // Find the Protected Macros select (uses authenticatedaction.html)
127
+ const protectedSection = html.match(/<form[^>]*action="authenticatedaction\.html"[^>]*>[\s\S]*?<\/form>/i);
128
+ const htmlToParse = protectedSection ? protectedSection[0] : html;
129
+ // Parse optgroups and their options
130
+ const optgroupRegex = /<optgroup[^>]*label="([^"]*)"[^>]*>([\s\S]*?)<\/optgroup>/gi;
131
+ const optionRegex = /<option[^>]*label="([^"]*)"[^>]*value="([^"]*)"[^>]*>/gi;
132
+ let groupMatch;
133
+ while ((groupMatch = optgroupRegex.exec(htmlToParse)) !== null) {
134
+ const groupName = decodeHtmlEntities(groupMatch[1].trim());
135
+ const groupContent = groupMatch[2];
136
+ let optionMatch;
137
+ // Reset regex for each group
138
+ optionRegex.lastIndex = 0;
139
+ while ((optionMatch = optionRegex.exec(groupContent)) !== null) {
140
+ const name = decodeHtmlEntities(optionMatch[1].trim());
141
+ const uid = optionMatch[2];
142
+ if (name && uid) {
143
+ macros.push({ name, uid, group: groupName });
144
+ }
145
+ }
146
+ }
147
+ return {
148
+ success: true,
149
+ macros,
150
+ };
151
+ }
152
+ catch (error) {
153
+ const message = error instanceof Error ? error.message : "Unknown error occurred";
154
+ return {
155
+ success: false,
156
+ message: `Failed to connect to ${machine.name} (${machine.host}:${machine.port}): ${message}`,
157
+ };
158
+ }
159
+ }
160
+ // Trigger a macro on a machine
161
+ export async function triggerMacro(machine, macro, value) {
162
+ // Use authenticated HTTPS endpoint to access both public and protected macros
163
+ const httpsPort = machine.port + 1;
164
+ const params = new URLSearchParams({ macro });
165
+ if (value) {
166
+ params.set("value", value);
167
+ }
168
+ const url = `https://${machine.host}:${httpsPort}/authenticatedaction.html?${params.toString()}`;
169
+ try {
170
+ const response = await fetch(url, {
171
+ method: "GET",
172
+ headers: {
173
+ Authorization: getAuthHeader(machine),
174
+ },
175
+ });
176
+ if (response.ok) {
177
+ const responseBody = await response.text();
178
+ return {
179
+ success: true,
180
+ message: `Macro "${macro}" triggered successfully on ${machine.name}`,
181
+ result: responseBody || undefined,
182
+ };
183
+ }
184
+ else if (response.status === 401) {
185
+ return {
186
+ success: false,
187
+ message: `Authentication failed for ${machine.name}. Check username/password.`,
188
+ };
189
+ }
190
+ else if (response.status === 403) {
191
+ return {
192
+ success: false,
193
+ message: `Access denied for ${machine.name}. The macro may not exist or may not be enabled.`,
194
+ };
195
+ }
196
+ else {
197
+ return {
198
+ success: false,
199
+ message: `Failed to trigger macro on ${machine.name}: HTTP ${response.status}`,
200
+ };
201
+ }
202
+ }
203
+ catch (error) {
204
+ const message = error instanceof Error ? error.message : "Unknown error occurred";
205
+ return {
206
+ success: false,
207
+ message: `Failed to connect to ${machine.name} (${machine.host}:${machine.port}): ${message}`,
208
+ };
209
+ }
210
+ }
211
+ // Generic fetch for exploring the web server
212
+ export async function fetchUrl(machine, path) {
213
+ const baseUrl = getBaseUrl(machine);
214
+ const url = path.startsWith("http") ? path : `${baseUrl}${path}`;
215
+ const response = await fetch(url, {
216
+ method: "GET",
217
+ headers: {
218
+ Authorization: getAuthHeader(machine),
219
+ },
220
+ });
221
+ const headers = {};
222
+ response.headers.forEach((value, key) => {
223
+ headers[key] = value;
224
+ });
225
+ return {
226
+ status: response.status,
227
+ headers,
228
+ body: await response.text(),
229
+ };
230
+ }
231
+ // Wait for a file to exist, with timeout
232
+ async function waitForFile(filePath, timeoutMs = 5000) {
233
+ const startTime = Date.now();
234
+ const pollInterval = 100;
235
+ while (Date.now() - startTime < timeoutMs) {
236
+ try {
237
+ await access(filePath, constants.R_OK);
238
+ return true;
239
+ }
240
+ catch {
241
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
242
+ }
243
+ }
244
+ return false;
245
+ }
246
+ // Trigger the Save Clipboard macro and retrieve the result
247
+ export async function getClipboardResult(machine) {
248
+ if (!machine.outputDir) {
249
+ return {
250
+ success: false,
251
+ message: `Clipboard capture not configured for machine "${machine.name}".
252
+
253
+ To enable clipboard capture, run the setup wizard:
254
+ setup_wizard with action="start"
255
+
256
+ The wizard will:
257
+ 1. Generate the required Keyboard Maestro macros
258
+ 2. Guide you through importing them
259
+ 3. Help you configure the outputDir setting
260
+
261
+ Or manually configure:
262
+ 1. Set "outputDir" in your machine config to the folder where clipboard files are saved
263
+ 2. Make sure the "Keyboard Maestro MCP" macro group is installed in Keyboard Maestro`,
264
+ };
265
+ }
266
+ const macroUid = machine.saveClipboardMacroUid || SAVE_CLIPBOARD_MACRO_UID;
267
+ const requestId = randomUUID().slice(0, 8); // Short unique ID
268
+ const outputDir = machine.outputDir;
269
+ // Trigger the save clipboard macro with our unique ID
270
+ const triggerResult = await triggerMacro(machine, macroUid, requestId);
271
+ if (!triggerResult.success) {
272
+ return {
273
+ success: false,
274
+ message: `Failed to trigger Save Clipboard macro: ${triggerResult.message}`,
275
+ };
276
+ }
277
+ // Check for both possible file types
278
+ const textPath = join(outputDir, `clipsav_${requestId}.txt`);
279
+ const imagePath = join(outputDir, `clipsav_${requestId}.png`);
280
+ // Wait for one of the files to appear
281
+ const [textExists, imageExists] = await Promise.all([
282
+ waitForFile(textPath, 3000),
283
+ waitForFile(imagePath, 3000),
284
+ ]);
285
+ if (imageExists) {
286
+ try {
287
+ const imageData = await readFile(imagePath);
288
+ return {
289
+ success: true,
290
+ message: "Clipboard image retrieved successfully",
291
+ type: "image",
292
+ content: imageData.toString("base64"),
293
+ filePath: imagePath,
294
+ };
295
+ }
296
+ catch (error) {
297
+ return {
298
+ success: false,
299
+ message: `Failed to read image file: ${error instanceof Error ? error.message : "Unknown error"}`,
300
+ };
301
+ }
302
+ }
303
+ if (textExists) {
304
+ try {
305
+ const textData = await readFile(textPath, "utf-8");
306
+ return {
307
+ success: true,
308
+ message: "Clipboard text retrieved successfully",
309
+ type: "text",
310
+ content: textData,
311
+ filePath: textPath,
312
+ };
313
+ }
314
+ catch (error) {
315
+ return {
316
+ success: false,
317
+ message: `Failed to read text file: ${error instanceof Error ? error.message : "Unknown error"}`,
318
+ };
319
+ }
320
+ }
321
+ return {
322
+ success: false,
323
+ message: `Clipboard save file not found. Expected: ${textPath} or ${imagePath}. Make sure the "Save Clipboard to File" macro is installed and the outputDir is configured correctly.`,
324
+ };
325
+ }
326
+ const wizardStates = new Map();
327
+ // Config wizard handler (shared by CLI config tool)
328
+ async function handleConfigWizard({ action, session_id, path, output_path }) {
329
+ // Handle start action
330
+ if (action === "start") {
331
+ const newSessionId = randomUUID().slice(0, 8);
332
+ wizardStates.set(newSessionId, { step: "start" });
333
+ const suggestedPaths = Object.entries(SUGGESTED_PATHS)
334
+ .map(([name, p]) => ` - ${name}: ${p}`)
335
+ .join("\n");
336
+ return {
337
+ content: [
338
+ {
339
+ type: "text",
340
+ text: `# Keyboard Maestro MCP Setup Wizard
341
+
342
+ Session ID: ${newSessionId}
343
+
344
+ ## Step 1: Choose a storage path
345
+
346
+ The macros need a folder to store clipboard data (screenshots, text, etc.).
347
+ This folder must be accessible from both:
348
+ - The Mac running Keyboard Maestro
349
+ - The machine running this MCP server
350
+
351
+ **Suggested paths:**
352
+ ${suggestedPaths}
353
+
354
+ **For multiple machines:** Use a synced folder (iCloud, Dropbox, etc.) so all machines can share data.
355
+
356
+ **For single machine:** ~/Documents/Keyboard Maestro MCP works well.
357
+
358
+ ## Next step
359
+
360
+ Call this tool with:
361
+ - action: "set_path"
362
+ - session_id: "${newSessionId}"
363
+ - path: "<your chosen path>"`,
364
+ },
365
+ ],
366
+ };
367
+ }
368
+ // Get or create session (we've already handled "start" above, so we need a session)
369
+ const state = session_id ? wizardStates.get(session_id) : undefined;
370
+ if (!state) {
371
+ return {
372
+ content: [
373
+ {
374
+ type: "text",
375
+ text: `No active wizard session. Call with action="start" to begin a new session.`,
376
+ },
377
+ ],
378
+ isError: true,
379
+ };
380
+ }
381
+ // Handle set_path action
382
+ if (action === "set_path") {
383
+ if (!path) {
384
+ return {
385
+ content: [
386
+ {
387
+ type: "text",
388
+ text: `Please provide a path. Example:\n path: "~/Documents/Keyboard Maestro MCP"`,
389
+ },
390
+ ],
391
+ isError: true,
392
+ };
393
+ }
394
+ const validation = validatePath(path);
395
+ if (!validation.valid) {
396
+ return {
397
+ content: [
398
+ {
399
+ type: "text",
400
+ text: `Invalid path: ${validation.error}\n\nPlease provide an absolute path starting with / or ~/`,
401
+ },
402
+ ],
403
+ isError: true,
404
+ };
405
+ }
406
+ state.filePath = path;
407
+ state.step = "path";
408
+ wizardStates.set(session_id, state);
409
+ const expandedPath = expandPath(path);
410
+ return {
411
+ content: [
412
+ {
413
+ type: "text",
414
+ text: `# Path configured
415
+
416
+ **Storage path:** ${path}
417
+ **Expanded:** ${expandedPath}
418
+
419
+ ## Step 2: Generate the macros file
420
+
421
+ Now generate the .kmmacros file to import into Keyboard Maestro.
422
+
423
+ Call this tool with:
424
+ - action: "generate"
425
+ - session_id: "${session_id}"
426
+ - output_path: "<where to save the file>"
427
+
428
+ **Suggested output path:** ~/Downloads/keyboard-maestro-mcp.kmmacros`,
429
+ },
430
+ ],
431
+ };
432
+ }
433
+ // Handle generate action
434
+ if (action === "generate") {
435
+ if (!state.filePath) {
436
+ return {
437
+ content: [
438
+ {
439
+ type: "text",
440
+ text: `Please set a storage path first using action="set_path"`,
441
+ },
442
+ ],
443
+ isError: true,
444
+ };
445
+ }
446
+ const savePath = output_path || join(homedir(), "Downloads", "keyboard-maestro-mcp.kmmacros");
447
+ const expandedSavePath = expandPath(savePath);
448
+ const expandedStoragePath = expandPath(state.filePath);
449
+ // Generate the macros file
450
+ const macrosContent = generateMacrosFile(expandedStoragePath);
451
+ try {
452
+ // Ensure directory exists
453
+ await mkdir(dirname(expandedSavePath), { recursive: true });
454
+ // Write the file
455
+ await writeFile(expandedSavePath, macrosContent, "utf-8");
456
+ state.generatedFile = expandedSavePath;
457
+ state.step = "complete";
458
+ wizardStates.set(session_id, state);
459
+ return {
460
+ content: [
461
+ {
462
+ type: "text",
463
+ text: `# Macros file generated!
464
+
465
+ **Saved to:** ${expandedSavePath}
466
+
467
+ ## Step 3: Import into Keyboard Maestro
468
+
469
+ 1. **Create the storage folder** (if it doesn't exist):
470
+ \`\`\`bash
471
+ mkdir -p "${expandedStoragePath}"
472
+ \`\`\`
473
+
474
+ 2. **Import the macros:**
475
+ - Double-click the file: ${expandedSavePath}
476
+ - Or: Open Keyboard Maestro → File → Import Macros
477
+
478
+ 3. **Verify installation:**
479
+ - In Keyboard Maestro, find the "Keyboard Maestro MCP" macro group
480
+ - It should contain two macros:
481
+ - "Set Keyboard Maestro MCP File Path"
482
+ - "Save Clipboard to File"
483
+
484
+ ## Step 4: Configure the MCP server
485
+
486
+ Add \`outputDir\` and \`saveClipboardMacroUid\` to your machine config:
487
+
488
+ \`\`\`json
489
+ {
490
+ "name": "YourMachine",
491
+ "host": "...",
492
+ "port": 4490,
493
+ "username": "...",
494
+ "password": "...",
495
+ "outputDir": "${expandedStoragePath}",
496
+ "saveClipboardMacroUid": "${SAVE_CLIPBOARD_MACRO_UID}"
497
+ }
498
+ \`\`\`
499
+
500
+ ## For multiple machines
501
+
502
+ Repeat steps 2-4 on each Mac:
503
+ 1. Import the same .kmmacros file
504
+ 2. Use a shared folder (iCloud, Dropbox) for outputDir so all machines can access clipboard data
505
+
506
+ Setup complete! Try \`trigger_and_capture\` with a screenshot macro to test.`,
507
+ },
508
+ ],
509
+ };
510
+ }
511
+ catch (error) {
512
+ return {
513
+ content: [
514
+ {
515
+ type: "text",
516
+ text: `Failed to write file: ${error instanceof Error ? error.message : "Unknown error"}\n\nTry a different output_path.`,
517
+ },
518
+ ],
519
+ isError: true,
520
+ };
521
+ }
522
+ }
523
+ // Handle status action
524
+ if (action === "status") {
525
+ return {
526
+ content: [
527
+ {
528
+ type: "text",
529
+ text: `# Wizard Status
530
+
531
+ **Session ID:** ${session_id}
532
+ **Step:** ${state.step}
533
+ **Storage path:** ${state.filePath || "(not set)"}
534
+ **Generated file:** ${state.generatedFile || "(not generated)"}`,
535
+ },
536
+ ],
537
+ };
538
+ }
539
+ // TypeScript exhaustiveness check - this should never be reached
540
+ const _exhaustiveCheck = action;
541
+ return _exhaustiveCheck;
542
+ }
543
+ // Main server setup
544
+ async function main() {
545
+ const machines = getMachines();
546
+ const server = new McpServer({
547
+ name: "keyboard-maestro-mcp",
548
+ version: "0.1.0",
549
+ });
550
+ // UI Resource URI for the interactive config tool
551
+ const configResourceUri = "ui://keyboard-maestro-mcp/config.html";
552
+ // Register the UI resource for MCP Apps-supporting clients
553
+ registerAppResource(server, configResourceUri, configResourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
554
+ try {
555
+ // Try to load the bundled UI from dist/ui
556
+ const uiPath = join(__dirname, "ui", "config.html");
557
+ const html = await readFile(uiPath, "utf-8");
558
+ return {
559
+ contents: [
560
+ { uri: configResourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
561
+ ],
562
+ };
563
+ }
564
+ catch (error) {
565
+ // Fallback message if UI is not built
566
+ return {
567
+ contents: [
568
+ {
569
+ uri: configResourceUri,
570
+ mimeType: RESOURCE_MIME_TYPE,
571
+ text: `<!DOCTYPE html>
572
+ <html>
573
+ <head><title>Keyboard Maestro MCP Config</title></head>
574
+ <body style="font-family: sans-serif; padding: 20px; background: #1a1a1a; color: #e0e0e0;">
575
+ <h1>Interactive Config Not Available</h1>
576
+ <p>The interactive UI is not bundled. Please use the text-based config tool instead:</p>
577
+ <pre style="background: #333; padding: 12px; border-radius: 8px;">config with action="start"</pre>
578
+ </body>
579
+ </html>`,
580
+ },
581
+ ],
582
+ };
583
+ }
584
+ });
585
+ // Register the config tool with UI support
586
+ // On MCP Apps-capable clients (Claude Desktop), this shows an interactive UI
587
+ // On other clients, it still works as a regular tool
588
+ registerAppTool(server, "config", {
589
+ title: "Keyboard Maestro MCP Config",
590
+ description: `Configuration tool for Keyboard Maestro MCP.
591
+
592
+ This tool helps you:
593
+ - Add and manage Keyboard Maestro machines
594
+ - Test connections to machines
595
+ - Update your MCP client configuration
596
+ - Generate and install the required macros`,
597
+ inputSchema: {},
598
+ _meta: { ui: { resourceUri: configResourceUri } },
599
+ }, async () => {
600
+ const state = await getConfigState();
601
+ return {
602
+ content: [
603
+ {
604
+ type: "text",
605
+ text: JSON.stringify({
606
+ _uiDisplayed: true,
607
+ _message: "Interactive configuration UI is now displayed. The user will complete setup through the visual interface.",
608
+ ...state,
609
+ }),
610
+ },
611
+ ],
612
+ };
613
+ });
614
+ // Tool: get_config_state - Get current configuration state
615
+ server.tool("get_config_state", "Get the current configuration state for Keyboard Maestro MCP", {}, async () => {
616
+ const state = await getConfigState();
617
+ return {
618
+ content: [
619
+ {
620
+ type: "text",
621
+ text: JSON.stringify(state),
622
+ },
623
+ ],
624
+ };
625
+ });
626
+ // Tool: test_machine_connection - Test connection to a KM machine
627
+ server.tool("test_machine_connection", "Test connection to a Keyboard Maestro machine", {
628
+ host: z.string().describe("Hostname or IP address"),
629
+ port: z.number().default(4490).describe("HTTP port (default: 4490)"),
630
+ username: z.string().describe("Web server username"),
631
+ password: z.string().describe("Web server password"),
632
+ secure: z.boolean().optional().describe("Use HTTPS instead of HTTP"),
633
+ }, async ({ host, port, username, password, secure }) => {
634
+ const result = await testMachineConnection({ host, port, username, password, secure });
635
+ return {
636
+ content: [
637
+ {
638
+ type: "text",
639
+ text: JSON.stringify(result),
640
+ },
641
+ ],
642
+ };
643
+ });
644
+ // Tool: update_mcp_config - Update MCP config file with machine definitions
645
+ server.tool("update_mcp_config", "Update an MCP client config file with Keyboard Maestro machine definitions", {
646
+ configPath: z.string().describe("Path to the MCP config file"),
647
+ machines: z.array(z.object({
648
+ name: z.string(),
649
+ host: z.string(),
650
+ port: z.number(),
651
+ username: z.string(),
652
+ password: z.string(),
653
+ secure: z.boolean().optional(),
654
+ })).describe("Array of machine configurations"),
655
+ storagePath: z.string().describe("Storage path for clipboard files"),
656
+ }, async ({ configPath, machines, storagePath }) => {
657
+ // Read existing config
658
+ const readResult = await readMcpConfig(configPath);
659
+ if (!readResult.success) {
660
+ return {
661
+ content: [
662
+ {
663
+ type: "text",
664
+ text: JSON.stringify({ success: false, error: readResult.error }),
665
+ },
666
+ ],
667
+ };
668
+ }
669
+ // Generate server config
670
+ const serverConfig = generateServerConfig(machines.map((m) => ({ ...m, outputDir: storagePath })));
671
+ // Update config
672
+ const config = readResult.config || {};
673
+ // Handle both Claude Desktop format (mcpServers) and Claude Code format (flat)
674
+ if ("mcpServers" in config || configPath.includes("claude_desktop_config")) {
675
+ // Claude Desktop format
676
+ const mcpServers = config.mcpServers || {};
677
+ config.mcpServers = {
678
+ ...mcpServers,
679
+ "keyboard-maestro": serverConfig,
680
+ };
681
+ }
682
+ else {
683
+ // Claude Code format (flat)
684
+ config["keyboard-maestro"] = serverConfig;
685
+ }
686
+ // Write config
687
+ const writeResult = await writeMcpConfig(configPath, config);
688
+ return {
689
+ content: [
690
+ {
691
+ type: "text",
692
+ text: JSON.stringify(writeResult),
693
+ },
694
+ ],
695
+ };
696
+ });
697
+ // Helper tool for the UI to generate macros file and save to Downloads
698
+ server.tool("generate_macros_content", "Generate Keyboard Maestro macros file, save to Downloads, and create the storage directory", {
699
+ path: z.string().describe("The storage path for clipboard files"),
700
+ }, async ({ path }) => {
701
+ const validation = validatePath(path);
702
+ if (!validation.valid) {
703
+ return {
704
+ content: [
705
+ {
706
+ type: "text",
707
+ text: JSON.stringify({ error: validation.error }),
708
+ },
709
+ ],
710
+ isError: true,
711
+ };
712
+ }
713
+ const expandedPath = expandPath(path);
714
+ // Create the storage directory if it doesn't exist
715
+ try {
716
+ await mkdir(expandedPath, { recursive: true });
717
+ }
718
+ catch (error) {
719
+ // Ignore errors - directory might already exist or we can't create it
720
+ }
721
+ const macrosContent = generateMacrosFile(expandedPath);
722
+ // Save the file to Downloads
723
+ const downloadsPath = join(homedir(), "Downloads", "keyboard-maestro-mcp.kmmacros");
724
+ try {
725
+ await writeFile(downloadsPath, macrosContent, "utf-8");
726
+ }
727
+ catch (error) {
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text",
732
+ text: JSON.stringify({
733
+ error: `Failed to save file: ${error instanceof Error ? error.message : "Unknown error"}`,
734
+ }),
735
+ },
736
+ ],
737
+ isError: true,
738
+ };
739
+ }
740
+ return {
741
+ content: [
742
+ {
743
+ type: "text",
744
+ text: JSON.stringify({
745
+ success: true,
746
+ filePath: downloadsPath,
747
+ storagePath: expandedPath,
748
+ macroUid: SAVE_CLIPBOARD_MACRO_UID,
749
+ }),
750
+ },
751
+ ],
752
+ };
753
+ });
754
+ // Tool: verify_macros_installed - Check if macros are installed on a machine
755
+ server.tool("verify_macros_installed", "Check if the Keyboard Maestro MCP macros are installed on configured machines", {
756
+ machineName: z.string().optional().describe("Specific machine to check (checks all if not specified)"),
757
+ }, async ({ machineName }) => {
758
+ const machinesToCheck = machineName
759
+ ? machines.filter((m) => m.name.toLowerCase() === machineName.toLowerCase())
760
+ : machines;
761
+ if (machinesToCheck.length === 0) {
762
+ return {
763
+ content: [
764
+ {
765
+ type: "text",
766
+ text: JSON.stringify({
767
+ success: false,
768
+ error: machineName
769
+ ? `Machine "${machineName}" not found`
770
+ : "No machines configured",
771
+ }),
772
+ },
773
+ ],
774
+ };
775
+ }
776
+ const results = await Promise.all(machinesToCheck.map(async (machine) => {
777
+ const check = await checkMacroGroupExists(machine);
778
+ return {
779
+ name: machine.name,
780
+ connected: check.exists || check.hasClipboardMacro,
781
+ hasMacroGroup: check.exists,
782
+ hasClipboardMacro: check.hasClipboardMacro,
783
+ clipboardMacroUid: check.clipboardMacroUid,
784
+ };
785
+ }));
786
+ const allInstalled = results.every((r) => r.hasClipboardMacro);
787
+ return {
788
+ content: [
789
+ {
790
+ type: "text",
791
+ text: JSON.stringify({
792
+ success: true,
793
+ allInstalled,
794
+ machines: results,
795
+ }),
796
+ },
797
+ ],
798
+ };
799
+ });
800
+ // Tool: list_machines
801
+ server.tool("list_machines", "List all configured Keyboard Maestro machines", {}, async () => {
802
+ if (machines.length === 0) {
803
+ return {
804
+ content: [
805
+ {
806
+ type: "text",
807
+ text: "No machines configured. Set KM_MACHINES environment variable with a JSON array of machine configs, or set KM_HOST, KM_USERNAME, and KM_PASSWORD for a single machine.",
808
+ },
809
+ ],
810
+ };
811
+ }
812
+ const machineList = machines.map((m) => ({
813
+ name: m.name,
814
+ host: m.host,
815
+ port: m.port,
816
+ secure: m.secure,
817
+ }));
818
+ return {
819
+ content: [
820
+ {
821
+ type: "text",
822
+ text: JSON.stringify(machineList, null, 2),
823
+ },
824
+ ],
825
+ };
826
+ });
827
+ // Tool: list_macros
828
+ server.tool("list_macros", "List available macros from a Keyboard Maestro machine's web server", {
829
+ machine: z
830
+ .string()
831
+ .optional()
832
+ .describe("Name of the machine to list macros from (defaults to first configured machine)"),
833
+ }, async ({ machine: machineName }) => {
834
+ const machine = findMachine(machines, machineName);
835
+ if (!machine) {
836
+ const availableMachines = machines.map((m) => m.name).join(", ");
837
+ return {
838
+ content: [
839
+ {
840
+ type: "text",
841
+ text: machineName
842
+ ? `Machine "${machineName}" not found. Available machines: ${availableMachines || "none configured"}`
843
+ : "No machines configured. Set KM_MACHINES environment variable.",
844
+ },
845
+ ],
846
+ isError: true,
847
+ };
848
+ }
849
+ const result = await listMacros(machine);
850
+ if (!result.success) {
851
+ return {
852
+ content: [
853
+ {
854
+ type: "text",
855
+ text: result.message || "Failed to list macros",
856
+ },
857
+ ],
858
+ isError: true,
859
+ };
860
+ }
861
+ return {
862
+ content: [
863
+ {
864
+ type: "text",
865
+ text: JSON.stringify({
866
+ machine: machine.name,
867
+ macros: result.macros,
868
+ count: result.macros?.length || 0,
869
+ }, null, 2),
870
+ },
871
+ ],
872
+ };
873
+ });
874
+ // Tool: trigger_macro
875
+ server.tool("trigger_macro", `Trigger a Keyboard Maestro macro by name or UUID. The macro runs on the remote machine.
876
+
877
+ IMPORTANT: Keyboard Maestro's web server does not return macro output directly. If you need to retrieve results from a macro:
878
+ - For macros that capture screenshots or copy data to clipboard: call get_clipboard_result afterward to retrieve the clipboard contents
879
+ - For macros that write to files: the files are saved on the remote machine
880
+
881
+ Common patterns:
882
+ - Screenshot workflow: trigger_macro("Take Screenshot...") → get_clipboard_result() to see the image
883
+ - Text capture: trigger_macro("Copy Selection") → get_clipboard_result() to get the text`, {
884
+ macro: z
885
+ .string()
886
+ .describe("The macro name or UUID to trigger"),
887
+ value: z
888
+ .string()
889
+ .optional()
890
+ .describe("Optional value to pass to the macro (available as %TriggerValue% in the macro)"),
891
+ machine: z
892
+ .string()
893
+ .optional()
894
+ .describe("Name of the machine to trigger the macro on (defaults to first configured machine)"),
895
+ }, async ({ macro, value, machine: machineName }) => {
896
+ const machine = findMachine(machines, machineName);
897
+ if (!machine) {
898
+ const availableMachines = machines.map((m) => m.name).join(", ");
899
+ return {
900
+ content: [
901
+ {
902
+ type: "text",
903
+ text: machineName
904
+ ? `Machine "${machineName}" not found. Available machines: ${availableMachines || "none configured"}`
905
+ : "No machines configured. Set KM_MACHINES environment variable.",
906
+ },
907
+ ],
908
+ isError: true,
909
+ };
910
+ }
911
+ const result = await triggerMacro(machine, macro, value);
912
+ const responseText = result.result
913
+ ? `${result.message}\n\nResult:\n${result.result}`
914
+ : result.message;
915
+ return {
916
+ content: [
917
+ {
918
+ type: "text",
919
+ text: responseText,
920
+ },
921
+ ],
922
+ isError: !result.success,
923
+ };
924
+ });
925
+ // Tool: trigger_macro_on_all
926
+ server.tool("trigger_macro_on_all", "Trigger a Keyboard Maestro macro on all configured machines", {
927
+ macro: z
928
+ .string()
929
+ .describe("The macro name or UUID to trigger"),
930
+ value: z
931
+ .string()
932
+ .optional()
933
+ .describe("Optional value to pass to the macro (available as %TriggerValue% in the macro)"),
934
+ }, async ({ macro, value }) => {
935
+ if (machines.length === 0) {
936
+ return {
937
+ content: [
938
+ {
939
+ type: "text",
940
+ text: "No machines configured.",
941
+ },
942
+ ],
943
+ isError: true,
944
+ };
945
+ }
946
+ const results = await Promise.all(machines.map((m) => triggerMacro(m, macro, value)));
947
+ const summary = results
948
+ .map((r, i) => `${machines[i].name}: ${r.message}`)
949
+ .join("\n");
950
+ const allSuccess = results.every((r) => r.success);
951
+ return {
952
+ content: [
953
+ {
954
+ type: "text",
955
+ text: summary,
956
+ },
957
+ ],
958
+ isError: !allSuccess,
959
+ };
960
+ });
961
+ // Tool: get_clipboard_result
962
+ server.tool("get_clipboard_result", `Retrieve the current clipboard contents from a remote machine. Returns text directly or displays images.
963
+
964
+ This is the companion tool to trigger_macro - use it to retrieve results after triggering macros that:
965
+ - Take screenshots (the image will be on the clipboard)
966
+ - Copy text or data to clipboard
967
+ - Perform OCR or text extraction
968
+ - Any operation that puts results on the system clipboard
969
+
970
+ The tool works by triggering a helper macro that saves the clipboard to a file, then reading that file.
971
+ Requires the "Save Clipboard to File" macro to be installed (see error message for setup instructions if not configured).`, {
972
+ machine: z
973
+ .string()
974
+ .optional()
975
+ .describe("Name of the machine to get clipboard from (defaults to first configured machine)"),
976
+ }, async ({ machine: machineName }) => {
977
+ const machine = findMachine(machines, machineName);
978
+ if (!machine) {
979
+ const availableMachines = machines.map((m) => m.name).join(", ");
980
+ return {
981
+ content: [
982
+ {
983
+ type: "text",
984
+ text: machineName
985
+ ? `Machine "${machineName}" not found. Available machines: ${availableMachines || "none configured"}`
986
+ : "No machines configured. Set KM_MACHINES environment variable.",
987
+ },
988
+ ],
989
+ isError: true,
990
+ };
991
+ }
992
+ const result = await getClipboardResult(machine);
993
+ if (!result.success) {
994
+ return {
995
+ content: [
996
+ {
997
+ type: "text",
998
+ text: result.message,
999
+ },
1000
+ ],
1001
+ isError: true,
1002
+ };
1003
+ }
1004
+ if (result.type === "image") {
1005
+ return {
1006
+ content: [
1007
+ {
1008
+ type: "image",
1009
+ data: result.content,
1010
+ mimeType: "image/png",
1011
+ },
1012
+ {
1013
+ type: "text",
1014
+ text: `Image saved to: ${result.filePath}`,
1015
+ },
1016
+ ],
1017
+ };
1018
+ }
1019
+ return {
1020
+ content: [
1021
+ {
1022
+ type: "text",
1023
+ text: result.content,
1024
+ },
1025
+ {
1026
+ type: "text",
1027
+ text: `\n---\nSaved to: ${result.filePath}`,
1028
+ },
1029
+ ],
1030
+ };
1031
+ });
1032
+ // Tool: trigger_and_capture
1033
+ server.tool("trigger_and_capture", `Trigger a macro and automatically capture the clipboard result. This is a convenience tool that combines trigger_macro + get_clipboard_result.
1034
+
1035
+ Use this when you want to:
1036
+ - Take a screenshot and see it immediately
1037
+ - Run a macro that copies something to clipboard and retrieve it
1038
+ - Perform OCR and get the extracted text
1039
+
1040
+ The macro runs first, then after a short delay the clipboard is captured and returned.`, {
1041
+ macro: z
1042
+ .string()
1043
+ .describe("The macro name or UUID to trigger"),
1044
+ value: z
1045
+ .string()
1046
+ .optional()
1047
+ .describe("Optional value to pass to the macro (available as %TriggerValue% in the macro)"),
1048
+ machine: z
1049
+ .string()
1050
+ .optional()
1051
+ .describe("Name of the machine (defaults to first configured machine)"),
1052
+ delay: z
1053
+ .number()
1054
+ .optional()
1055
+ .describe("Milliseconds to wait after triggering before capturing clipboard (default: 500)"),
1056
+ }, async ({ macro, value, machine: machineName, delay }) => {
1057
+ const machine = findMachine(machines, machineName);
1058
+ if (!machine) {
1059
+ const availableMachines = machines.map((m) => m.name).join(", ");
1060
+ return {
1061
+ content: [
1062
+ {
1063
+ type: "text",
1064
+ text: machineName
1065
+ ? `Machine "${machineName}" not found. Available machines: ${availableMachines || "none configured"}`
1066
+ : "No machines configured. Set KM_MACHINES environment variable.",
1067
+ },
1068
+ ],
1069
+ isError: true,
1070
+ };
1071
+ }
1072
+ // First trigger the macro
1073
+ const triggerResult = await triggerMacro(machine, macro, value);
1074
+ if (!triggerResult.success) {
1075
+ return {
1076
+ content: [
1077
+ {
1078
+ type: "text",
1079
+ text: `Failed to trigger macro: ${triggerResult.message}`,
1080
+ },
1081
+ ],
1082
+ isError: true,
1083
+ };
1084
+ }
1085
+ // Wait for the macro to complete and populate clipboard
1086
+ const waitTime = delay ?? 500;
1087
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
1088
+ // Now capture the clipboard
1089
+ const clipResult = await getClipboardResult(machine);
1090
+ if (!clipResult.success) {
1091
+ return {
1092
+ content: [
1093
+ {
1094
+ type: "text",
1095
+ text: `Macro triggered successfully, but clipboard capture failed: ${clipResult.message}`,
1096
+ },
1097
+ ],
1098
+ isError: true,
1099
+ };
1100
+ }
1101
+ if (clipResult.type === "image") {
1102
+ return {
1103
+ content: [
1104
+ {
1105
+ type: "text",
1106
+ text: `Macro "${macro}" executed. Clipboard contained an image:`,
1107
+ },
1108
+ {
1109
+ type: "image",
1110
+ data: clipResult.content,
1111
+ mimeType: "image/png",
1112
+ },
1113
+ ],
1114
+ };
1115
+ }
1116
+ return {
1117
+ content: [
1118
+ {
1119
+ type: "text",
1120
+ text: `Macro "${macro}" executed. Clipboard contents:\n\n${clipResult.content}`,
1121
+ },
1122
+ ],
1123
+ };
1124
+ });
1125
+ // Start the server
1126
+ const transport = new StdioServerTransport();
1127
+ await server.connect(transport);
1128
+ }
1129
+ main().catch((error) => {
1130
+ console.error("Fatal error:", error);
1131
+ process.exit(1);
1132
+ });
1133
+ //# sourceMappingURL=index.js.map