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/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/config-manager.d.ts +92 -0
- package/dist/config-manager.d.ts.map +1 -0
- package/dist/config-manager.js +209 -0
- package/dist/config-manager.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1133 -0
- package/dist/index.js.map +1 -0
- package/dist/macros-template.d.ts +38 -0
- package/dist/macros-template.d.ts.map +1 -0
- package/dist/macros-template.js +263 -0
- package/dist/macros-template.js.map +1 -0
- package/dist/test.d.ts +5 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +137 -0
- package/dist/test.js.map +1 -0
- package/dist/ui/config.html +794 -0
- package/package.json +58 -0
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(/</g, "<")
|
|
91
|
+
.replace(/>/g, ">")
|
|
92
|
+
.replace(/"/g, '"')
|
|
93
|
+
.replace(/'/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
|