spacepal-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +168 -77
  2. package/package.json +5 -2
  3. package/server.json +5 -3
package/index.js CHANGED
@@ -13,7 +13,7 @@ import { join } from "node:path";
13
13
  import { promisify } from "node:util";
14
14
 
15
15
  const run = promisify(execFile);
16
- const VERSION = "0.2.0";
16
+ const VERSION = "0.3.0";
17
17
 
18
18
  // Direct build writes to Application Support; the App Store build is sandboxed.
19
19
  const SUPPORT_DIRS = [
@@ -113,18 +113,53 @@ function guarded(fn) {
113
113
  };
114
114
  }
115
115
 
116
- const server = new McpServer({ name: "spacepal", version: VERSION });
116
+ const INSTRUCTIONS = `Spacepal MCP server: work with macOS Spaces (desktops) through the Spacepal app on this Mac.
117
+
118
+ Start with list_spaces to learn the Spaces, their numbers, names, notes and the apps on each; most tools take a Space by desktop number or by name from that list. Use current_space when you only need the active one.
119
+
120
+ Tools that only read: list_spaces, current_space, list_profiles. Tools that change the user's screen or setup: everything else. Ask the user before remove_desktop, apply_template and apply_profile, which replace or remove things; rename_space and set_space_note are easy to undo by calling them again.
121
+
122
+ Every action goes through Spacepal, so it must be running (macOS 14 or later). Nothing is sent over the network. If a tool says Spacepal is not running, tell the user to open it. The overview and sessions need Spacepal's trial or a license; names, notes and switching work in the free version.`;
123
+
124
+ const server = new McpServer({ name: "spacepal", version: VERSION }, { instructions: INSTRUCTIONS });
125
+
117
126
  const spaceArg = z
118
127
  .union([z.number().int().positive(), z.string().min(1)])
119
- .describe("Desktop number (1, 2, ...) or Space name, e.g. \"Mail\".");
128
+ .describe(
129
+ 'Which Space: its desktop number (1 = leftmost on the display) or its name as list_spaces shows it, e.g. "Mail". Names match without regard to case.',
130
+ );
131
+ const optionalSpaceArg = spaceArg.optional().describe(
132
+ 'Which Space: desktop number or name from list_spaces. Leave it out for the Space the user is on now.',
133
+ );
134
+
135
+ const spaceEntry = z
136
+ .object({
137
+ id: z.string().describe("Stable id of the Space"),
138
+ name: z.string().describe("Name shown in Spacepal, or \"Desktop N\" if unnamed"),
139
+ number: z.number().int().optional().describe("Desktop number on its display; absent for full-screen Spaces"),
140
+ display: z.string().describe("Id of the display the Space is on"),
141
+ isActive: z.boolean().describe("True for the Space currently shown on its display"),
142
+ isFullscreen: z.boolean().describe("True for an app's full-screen Space"),
143
+ apps: z.array(z.string()).describe("Apps with a window on this Space"),
144
+ note: z.string().optional().describe("The user's note about what the Space is for"),
145
+ color: z.string().optional().describe("Colour as #RRGGBB"),
146
+ })
147
+ .passthrough();
148
+
149
+ const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
150
+ const CHANGE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
120
151
 
121
152
  server.registerTool(
122
153
  "list_spaces",
123
154
  {
124
155
  title: "List Spaces",
125
- description: "List every macOS Space (desktop) with its number, name, display, the apps that have windows there, its note, and which one is active.",
156
+ description:
157
+ "List every macOS Space (desktop) on every display: desktop number, name, note, the apps with windows on it, and which one is active. " +
158
+ "Read-only. Call this first to get valid Space numbers and names for the other tools. " +
159
+ "Fails if Spacepal is not running or has not written its list yet.",
126
160
  inputSchema: {},
127
- annotations: { readOnlyHint: true, openWorldHint: false },
161
+ outputSchema: { current: z.string().describe("Name of the active Space"), spaces: z.array(spaceEntry) },
162
+ annotations: { title: "List Spaces", ...RO },
128
163
  },
129
164
  guarded(async () => {
130
165
  if (!(await isRunning())) return fail(NOT_RUNNING);
@@ -141,7 +176,7 @@ server.registerTool(
141
176
  content: [
142
177
  { type: "text", text: `Current: ${state.current}\n${displays} display(s), ${state.spaces.length} Spaces (* = active)\n${lines.join("\n")}` },
143
178
  ],
144
- structuredContent: state,
179
+ structuredContent: { current: state.current ?? "", spaces: state.spaces },
145
180
  };
146
181
  }),
147
182
  );
@@ -150,16 +185,22 @@ server.registerTool(
150
185
  "current_space",
151
186
  {
152
187
  title: "Current Space",
153
- description: "Name and number of the Space you are on.",
188
+ description:
189
+ "Return the Space the user is on now: its name and desktop number (one per display). " +
190
+ "Read-only and cheaper than list_spaces when you only need the active Space.",
154
191
  inputSchema: {},
155
- annotations: { readOnlyHint: true, openWorldHint: false },
192
+ outputSchema: { spaces: z.array(spaceEntry).describe("The active Space on each display") },
193
+ annotations: { title: "Current Space", ...RO },
156
194
  },
157
195
  guarded(async () => {
158
196
  if (!(await isRunning())) return fail(NOT_RUNNING);
159
197
  const state = await readState();
160
- const active = state?.spaces.filter((s) => s.isActive) ?? [];
161
- if (!active.length) return text(state?.current ? `Current: ${state.current}` : "Unknown");
162
- return text(active.map((s) => `${s.name} (Desktop ${s.number ?? "full screen"})`).join("\n"));
198
+ if (!state) return fail("Spacepal has not written its Space list yet. Open the Spacepal menu once and try again.");
199
+ const active = state.spaces.filter((s) => s.isActive);
200
+ const t = active.length
201
+ ? active.map((s) => `${s.name} (Desktop ${s.number ?? "full screen"})`).join("\n")
202
+ : `Current: ${state.current || "unknown"}`;
203
+ return { content: [{ type: "text", text: t }], structuredContent: { spaces: active } };
163
204
  }),
164
205
  );
165
206
 
@@ -167,9 +208,16 @@ server.registerTool(
167
208
  "switch_space",
168
209
  {
169
210
  title: "Switch Space",
170
- description: "Switch to a Space by desktop number or name. Use \"next\", \"previous\" or \"back\" to step.",
171
- inputSchema: { space: spaceArg },
172
- annotations: { openWorldHint: false },
211
+ description:
212
+ "Show another Space on the user's screen, instantly and without the slide animation. " +
213
+ 'Give a desktop number or name from list_spaces, or "next", "previous" (neighbouring desktop) or "back" (the Space shown before). ' +
214
+ "Changes only what is visible; no windows move. Undo with space=\"back\". Returns the Space now shown; fails if no Space has that name.",
215
+ inputSchema: {
216
+ space: spaceArg.describe(
217
+ 'Desktop number or name from list_spaces, or one of "next", "previous", "back".',
218
+ ),
219
+ },
220
+ annotations: { title: "Switch Space", ...CHANGE, idempotentHint: true },
173
221
  },
174
222
  guarded(async ({ space }) => {
175
223
  const before = (await readState())?.current;
@@ -194,12 +242,16 @@ server.registerTool(
194
242
  {
195
243
  title: "Move window to Space",
196
244
  description:
197
- "Send the frontmost window to another Space (by number or name, or \"next\"/\"previous\"). " +
198
- "On macOS 14.0-14.4 and 26.4+ the window moves directly; on other versions Spacepal drags it, " +
199
- "which takes a moment longer. The frontmost window is usually the one the user worked in last, " +
200
- "not the MCP client's own window when it runs in a terminal.",
201
- inputSchema: { space: spaceArg },
202
- annotations: { openWorldHint: false },
245
+ "Move the user's frontmost window to another Space; the window keeps its size and position. " +
246
+ 'Target is a desktop number or name from list_spaces, or "next"/"previous". ' +
247
+ "The frontmost window is the one the user last focused: from a terminal client that is usually the window they worked in, " +
248
+ "from Claude Desktop it is Claude's own window. On macOS 14.0-14.4 and 26.4+ the window moves directly; on other versions " +
249
+ "Spacepal drags it, which takes a moment. Depending on the user's settings the screen may follow the window. " +
250
+ "Undo by moving it back. Fails if no Space has that name.",
251
+ inputSchema: {
252
+ space: spaceArg.describe('Target desktop number or name from list_spaces, or "next" / "previous".'),
253
+ },
254
+ annotations: { title: "Move window to Space", ...CHANGE },
203
255
  },
204
256
  guarded(async ({ space }) => {
205
257
  const s = String(space).toLowerCase();
@@ -217,12 +269,15 @@ server.registerTool(
217
269
  "rename_space",
218
270
  {
219
271
  title: "Rename Space",
220
- description: "Give a Space a name. Leave space empty for the current one; an empty name restores the default.",
272
+ description:
273
+ "Set the name Spacepal shows for a Space in its menu bar, overview and Mission Control. " +
274
+ "Replaces the old name; an empty name goes back to \"Desktop N\". The name stays with the Space even if desktops are reordered. " +
275
+ "Reversible: call again with the old name (get it from list_spaces first). Fails if the Space does not exist.",
221
276
  inputSchema: {
222
- space: spaceArg.optional(),
223
- name: z.string().max(60).describe("New name. Empty text restores \"Desktop N\"."),
277
+ space: optionalSpaceArg,
278
+ name: z.string().max(60).describe('New name, up to 60 characters. Empty text ("") restores the default "Desktop N".'),
224
279
  },
225
- annotations: { openWorldHint: false },
280
+ annotations: { title: "Rename Space", ...CHANGE, idempotentHint: true },
226
281
  },
227
282
  guarded(async ({ space, name }) => {
228
283
  if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
@@ -232,13 +287,37 @@ server.registerTool(
232
287
  }),
233
288
  );
234
289
 
290
+ server.registerTool(
291
+ "set_space_note",
292
+ {
293
+ title: "Set Space note",
294
+ description:
295
+ "Set the short note that says what a Space is for, e.g. \"Client B invoices\"; list_spaces returns it and Spacepal shows it in its overview. " +
296
+ "Replaces any existing note; empty text removes the note. Reversible by setting the old note again (read it from list_spaces first). " +
297
+ "Fails if the Space does not exist.",
298
+ inputSchema: {
299
+ space: optionalSpaceArg,
300
+ text: z.string().max(500).describe('The note, up to 500 characters. Empty text ("") removes the note.'),
301
+ },
302
+ annotations: { title: "Set Space note", ...CHANGE, idempotentHint: true },
303
+ },
304
+ guarded(async ({ space, text: note }) => {
305
+ if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
306
+ await send(`note/${ref(space)}`, { text: note });
307
+ return text(note ? "Note saved." : "Note removed.");
308
+ }),
309
+ );
310
+
235
311
  server.registerTool(
236
312
  "open_overview",
237
313
  {
238
314
  title: "Open overview",
239
- description: "Open Spacepal's overview grid of all Spaces on screen.",
315
+ description:
316
+ "Show Spacepal's full-screen overview of every Space with live previews, for the user to look at and pick from. " +
317
+ "It is a toggle: calling it while the overview is open closes it. Changes nothing else. " +
318
+ "Needs Spacepal's trial or a license; without one Spacepal shows its license window instead. To get the Space list as data, use list_spaces.",
240
319
  inputSchema: {},
241
- annotations: { openWorldHint: false },
320
+ annotations: { title: "Open overview", ...CHANGE },
242
321
  },
243
322
  guarded(async () => {
244
323
  await send("overview");
@@ -247,17 +326,22 @@ server.registerTool(
247
326
  );
248
327
 
249
328
  server.registerTool(
250
- "set_space_note",
329
+ "list_profiles",
251
330
  {
252
- title: "Set Space note",
253
- description: "Attach a short note to a Space (what it is for).",
254
- inputSchema: { space: spaceArg.optional(), text: z.string().max(500) },
255
- annotations: { openWorldHint: false },
331
+ title: "List profiles",
332
+ description:
333
+ "List the names of the user's saved Spacepal profiles. A profile is a saved set of Space names, colours, notes and automations, such as \"Work\" and \"Home\". " +
334
+ "Read-only. Use it to get a valid name for apply_profile.",
335
+ inputSchema: {},
336
+ outputSchema: { profiles: z.array(z.string()).describe("Saved profile names") },
337
+ annotations: { title: "List profiles", ...RO },
256
338
  },
257
- guarded(async ({ space, text: note }) => {
258
- if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
259
- await send(`note/${ref(space)}`, { text: note });
260
- return text("Note saved.");
339
+ guarded(async () => {
340
+ const names = await readProfiles();
341
+ return {
342
+ content: [{ type: "text", text: names.length ? names.join("\n") : "No saved profiles yet." }],
343
+ structuredContent: { profiles: names },
344
+ };
261
345
  }),
262
346
  );
263
347
 
@@ -265,9 +349,15 @@ server.registerTool(
265
349
  "apply_profile",
266
350
  {
267
351
  title: "Apply profile",
268
- description: "Apply a saved Spacepal profile by name (names, layouts and rules).",
269
- inputSchema: { name: z.string().min(1) },
270
- annotations: { openWorldHint: false },
352
+ description:
353
+ "Switch to one of the user's saved profiles, replacing the names, colours, notes and automations of every Space with the profile's. " +
354
+ "If a profile was active, Spacepal first saves the current set under it, so the user can switch back. If none was active, the current names are not saved anywhere and are lost, so ask the user to save them as a profile first. " +
355
+ "Windows and desktops are not changed. Unlike apply_template, which uses built-in starter names, this uses the user's own saved sets. " +
356
+ "Get valid names from list_profiles; fails with the list of names if the profile does not exist. Ask the user first.",
357
+ inputSchema: {
358
+ name: z.string().min(1).describe("Profile name from list_profiles. Matching ignores case."),
359
+ },
360
+ annotations: { title: "Apply profile", ...CHANGE, destructiveHint: true, idempotentHint: true },
271
361
  },
272
362
  guarded(async ({ name }) => {
273
363
  const names = await readProfiles();
@@ -280,29 +370,17 @@ server.registerTool(
280
370
  }),
281
371
  );
282
372
 
283
- server.registerTool(
284
- "list_profiles",
285
- {
286
- title: "List profiles",
287
- description: "Names of the saved Spacepal profiles (sets of Space names, colours, notes and automations).",
288
- inputSchema: {},
289
- annotations: { readOnlyHint: true, openWorldHint: false },
290
- },
291
- guarded(async () => {
292
- const names = await readProfiles();
293
- return text(names.length ? names.join("\n") : "No saved profiles yet.");
294
- }),
295
- );
296
-
297
373
  server.registerTool(
298
374
  "apply_template",
299
375
  {
300
376
  title: "Apply template",
301
377
  description:
302
- `Name and colour the Spaces from a built-in starter template: ${TEMPLATES.join(", ")}. ` +
303
- "This replaces the current Space names; the user can save them first as a profile.",
304
- inputSchema: { name: z.enum(TEMPLATES) },
305
- annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: false },
378
+ `Name and colour the user's desktops from one of Spacepal's built-in starter templates: ${TEMPLATES.join(", ")}. ` +
379
+ "Overwrites the names, colours and notes of the desktops it covers, so ask the user first; to keep the current names they can save them as a profile in Spacepal. " +
380
+ "If the template needs more desktops than exist, Spacepal shows a dialog on screen asking the user whether to add them. " +
381
+ "For the user's own saved sets use apply_profile instead.",
382
+ inputSchema: { name: z.enum(TEMPLATES).describe("Template name, exactly as listed") },
383
+ annotations: { title: "Apply template", ...CHANGE, destructiveHint: true, idempotentHint: true },
306
384
  },
307
385
  guarded(async ({ name }) => {
308
386
  await send(`template/${encodeURIComponent(name)}`);
@@ -315,9 +393,12 @@ server.registerTool(
315
393
  "set_up_space",
316
394
  {
317
395
  title: "Set up Space",
318
- description: "Switch to a Space and run its automations now: open its apps, URLs and files, run its Shortcut or script.",
319
- inputSchema: { space: spaceArg.optional() },
320
- annotations: { openWorldHint: false },
396
+ description:
397
+ "Switch to a Space and open the apps, URLs and files its Spacepal automation lists, even if they were opened before. " +
398
+ "Entering the Space also runs its usual on-enter actions, such as a Shortcut. Use it to prepare a workspace; to only change the visible Space use switch_space. " +
399
+ "Opens apps on the user's Mac, so it cannot be undone automatically. Does nothing beyond switching if the Space has no automation. Fails if the Space does not exist.",
400
+ inputSchema: { space: optionalSpaceArg },
401
+ annotations: { title: "Set up Space", ...CHANGE },
321
402
  },
322
403
  guarded(async ({ space }) => {
323
404
  const target = space === undefined ? null : await findSpace(space);
@@ -331,9 +412,11 @@ server.registerTool(
331
412
  "save_window_layout",
332
413
  {
333
414
  title: "Save window layout",
334
- description: "Save which Space and position every window is in, for this display setup. Replaces the previous manual save.",
415
+ description:
416
+ "Record which Space and screen position every open window has, for the current display setup, so restore_window_layout can put them back. " +
417
+ "Overwrites the previous manual save for these displays (Spacepal also saves automatically in the background). Moves nothing.",
335
418
  inputSchema: {},
336
- annotations: { idempotentHint: true, openWorldHint: false },
419
+ annotations: { title: "Save window layout", ...CHANGE, idempotentHint: true },
337
420
  },
338
421
  guarded(async () => {
339
422
  await send("save-layout");
@@ -346,10 +429,11 @@ server.registerTool(
346
429
  {
347
430
  title: "Restore window layout",
348
431
  description:
349
- "Put windows back on the Spaces and positions from the last saved layout for this display setup. " +
350
- "Moving windows between Spaces needs macOS 14.0-14.4 or 26.4+.",
432
+ "Move open windows back to the Spaces and positions recorded by save_window_layout (or Spacepal's automatic save) for the current display setup. " +
433
+ "Moving windows between Spaces only works on macOS 14.0-14.4 and 26.4+; on other versions positions are restored but windows stay on their Space. " +
434
+ "Windows of apps that are not open are skipped; to reopen terminals and Chrome windows use restore_sessions. Spacepal shows how many windows it moved.",
351
435
  inputSchema: {},
352
- annotations: { openWorldHint: false },
436
+ annotations: { title: "Restore window layout", ...CHANGE, idempotentHint: true },
353
437
  },
354
438
  guarded(async () => {
355
439
  await send("restore-layout");
@@ -362,13 +446,14 @@ server.registerTool(
362
446
  {
363
447
  title: "Save sessions",
364
448
  description:
365
- "Save the terminal sessions and Chrome windows on a Space (or on every Space with all=true), " +
366
- "so Spacepal can reopen them after a restart. Needs the Spacepal trial or a license.",
449
+ "Save the terminal windows (with their folders) and Chrome windows (with their tabs) on one Space, or on every Space with all=true, " +
450
+ "so restore_sessions can reopen them on the right Space after a restart. Replaces the earlier save for those Spaces and drops windows that were closed. " +
451
+ "Needs Spacepal's trial or a license; without one nothing is saved.",
367
452
  inputSchema: {
368
- space: spaceArg.optional(),
369
- all: z.boolean().optional().describe("Save every Space instead of one."),
453
+ space: optionalSpaceArg,
454
+ all: z.boolean().optional().describe("true to save every Space; then space is ignored. Default false."),
370
455
  },
371
- annotations: { idempotentHint: true, openWorldHint: false },
456
+ annotations: { title: "Save sessions", ...CHANGE, idempotentHint: true },
372
457
  },
373
458
  guarded(async ({ space, all }) => {
374
459
  if (all) {
@@ -385,9 +470,12 @@ server.registerTool(
385
470
  "restore_sessions",
386
471
  {
387
472
  title: "Restore sessions",
388
- description: "Reopen the saved terminal sessions and Chrome windows on their Spaces. Needs the Spacepal trial or a license.",
473
+ description:
474
+ "Reopen the terminal and Chrome windows saved by save_sessions, each on the Space it was saved from. " +
475
+ "Opens new windows, so calling it twice can open duplicates. Needs Spacepal's trial or a license. " +
476
+ "To move windows that are already open, use restore_window_layout instead.",
389
477
  inputSchema: {},
390
- annotations: { openWorldHint: false },
478
+ annotations: { title: "Restore sessions", ...CHANGE },
391
479
  },
392
480
  guarded(async () => {
393
481
  await send("session-restore");
@@ -399,9 +487,11 @@ server.registerTool(
399
487
  "add_desktop",
400
488
  {
401
489
  title: "Add desktop",
402
- description: "Add a new desktop (Space) on the current display.",
490
+ description:
491
+ "Create a new empty desktop at the end of the display the pointer is on. Mission Control opens for a moment while Spacepal adds it. " +
492
+ "Undo with remove_desktop. Returns the new number of Spaces once Spacepal confirms it.",
403
493
  inputSchema: {},
404
- annotations: { openWorldHint: false },
494
+ annotations: { title: "Add desktop", ...CHANGE },
405
495
  },
406
496
  guarded(async () => {
407
497
  const before = (await readState())?.spaces.length ?? 0;
@@ -421,10 +511,11 @@ server.registerTool(
421
511
  {
422
512
  title: "Remove desktop",
423
513
  description:
424
- "Remove a desktop by number or name. Its windows move to another desktop, as when you close it in Mission Control. " +
425
- "Full-screen Spaces cannot be removed this way. Ask the user before calling this.",
426
- inputSchema: { space: spaceArg },
427
- annotations: { destructiveHint: true, openWorldHint: false },
514
+ "Close a desktop, as with its close button in Mission Control. Its windows move to another desktop and are not closed, " +
515
+ "but its Spacepal name, note and automation are lost and cannot be restored automatically. Full-screen Spaces cannot be removed this way. " +
516
+ "Always ask the user before calling this. Fails if the Space does not exist.",
517
+ inputSchema: { space: spaceArg.describe("Desktop number or name from list_spaces") },
518
+ annotations: { title: "Remove desktop", ...CHANGE, destructiveHint: true },
428
519
  },
429
520
  guarded(async ({ space }) => {
430
521
  const target = await findSpace(space);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spacepal-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for Spacepal: list, switch, rename macOS Spaces and move windows between them from Claude, Cursor or any MCP client.",
5
5
  "mcpName": "com.getspacepal/spacepal",
6
6
  "type": "module",
@@ -37,6 +37,9 @@
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.17.0",
40
- "zod": "^3.23.8"
40
+ "zod": "^4.6.7"
41
+ },
42
+ "scripts": {
43
+ "test": "node test/smoke.mjs"
41
44
  }
42
45
  }
package/server.json CHANGED
@@ -3,14 +3,16 @@
3
3
  "name": "com.getspacepal/spacepal",
4
4
  "title": "Spacepal",
5
5
  "description": "List, switch and rename macOS Spaces and move windows between them. Needs the Spacepal app.",
6
- "version": "0.2.0",
6
+ "version": "0.3.0",
7
7
  "websiteUrl": "https://getspacepal.com/integrations/mcp/",
8
8
  "packages": [
9
9
  {
10
10
  "registryType": "npm",
11
11
  "identifier": "spacepal-mcp",
12
- "version": "0.2.0",
13
- "transport": { "type": "stdio" }
12
+ "version": "0.3.0",
13
+ "transport": {
14
+ "type": "stdio"
15
+ }
14
16
  }
15
17
  ]
16
18
  }