spacepal-mcp 0.1.0 → 0.2.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 (4) hide show
  1. package/README.md +24 -4
  2. package/index.js +211 -16
  3. package/package.json +29 -6
  4. package/server.json +2 -2
package/README.md CHANGED
@@ -4,6 +4,8 @@ Lets Claude, Cursor and other MCP clients work with your macOS Spaces through
4
4
  [Spacepal](https://getspacepal.com): "switch to my Mail Space", "what's on Desktop 3?",
5
5
  "name this desktop Research".
6
6
 
7
+ ![An agent lists Spaces, switches to Research and names a desktop through the Spacepal MCP server (illustration)](https://getspacepal.com/media/mcp-agent.gif)
8
+
7
9
  It runs locally over stdio. It reads the Space list Spacepal writes to
8
10
  `~/Library/Application Support/Spacepal/state.json` and sends commands through the
9
11
  `spacepal://` URL scheme. It makes no network calls.
@@ -17,17 +19,35 @@ It runs locally over stdio. It reads the Space list Spacepal writes to
17
19
 
18
20
  | Tool | What it does |
19
21
  |---|---|
20
- | `list_spaces` | Every Space with its number, name, display, apps, and which one is active |
22
+ | `list_spaces` | Every Space with its number, name, display, apps, note, and which one is active |
21
23
  | `current_space` | The Space you are on |
22
24
  | `switch_space` | Switch by number or name, or step with `next`, `previous`, `back` |
23
25
  | `move_window_to_space` | Send the frontmost window to a Space by number or name, or to `next` or `previous` |
24
26
  | `rename_space` | Name a Space; an empty name restores "Desktop N" |
25
- | `open_overview` | Open Spacepal's overview grid |
26
27
  | `set_space_note` | Attach a short note to a Space |
27
- | `apply_profile` | Apply a saved Spacepal profile |
28
+ | `open_overview` | Open Spacepal's overview grid |
29
+ | `list_profiles` | Names of your saved profiles |
30
+ | `apply_profile` | Apply a saved profile (names, colours, notes and automations) |
31
+ | `apply_template` | Name the Spaces from a starter template: Developer, Designer, Freelancer, Student, Streamer |
32
+ | `set_up_space` | Switch to a Space and run its automations: open its apps, URLs and files |
33
+ | `save_window_layout` | Save which Space and position every window is in |
34
+ | `restore_window_layout` | Put windows back from the last saved layout |
35
+ | `save_sessions` | Save terminal sessions and Chrome windows on one Space or all of them |
36
+ | `restore_sessions` | Reopen saved sessions on their Spaces |
37
+ | `add_desktop` | Add a desktop on the current display |
38
+ | `remove_desktop` | Remove a desktop; its windows move to another one |
39
+
40
+ Every tool is marked with MCP hints, so clients can tell the read-only tools apart and ask
41
+ before `remove_desktop` or `apply_template`, which change your setup.
28
42
 
29
43
  `move_window_to_space` acts on the frontmost window. In Claude Desktop that is usually
30
- Claude's own window. Moving the frontmost window works on every supported macOS version.
44
+ Claude's own window. It moves the window directly on macOS 14.0–14.4 and 26.4+, and by
45
+ dragging it on other versions. `restore_window_layout` can only move windows between
46
+ Spaces on 14.0–14.4 and 26.4+.
47
+
48
+ The MCP server is free. The tools follow the app's own limits: after Spacepal's 14-day
49
+ trial, names and notes stay free, while the overview, sessions and other Pro features need a
50
+ license.
31
51
 
32
52
  ## Install
33
53
 
package/index.js CHANGED
@@ -13,13 +13,17 @@ import { join } from "node:path";
13
13
  import { promisify } from "node:util";
14
14
 
15
15
  const run = promisify(execFile);
16
- const VERSION = "0.1.0";
16
+ const VERSION = "0.2.0";
17
17
 
18
18
  // Direct build writes to Application Support; the App Store build is sandboxed.
19
- const STATE_FILES = [
20
- join(homedir(), "Library/Application Support/Spacepal/state.json"),
21
- join(homedir(), "Library/Containers/com.halberd.spacepal/Data/Library/Application Support/Spacepal/state.json"),
19
+ const SUPPORT_DIRS = [
20
+ join(homedir(), "Library/Application Support/Spacepal"),
21
+ join(homedir(), "Library/Containers/com.halberd.spacepal/Data/Library/Application Support/Spacepal"),
22
22
  ];
23
+ const STATE_FILES = SUPPORT_DIRS.map((d) => join(d, "state.json"));
24
+ const PROFILE_FILES = SUPPORT_DIRS.map((d) => join(d, "profiles.json"));
25
+ // Built into the app (Profiles.swift, SpaceTemplate.all()).
26
+ const TEMPLATES = ["Developer", "Designer", "Freelancer", "Student", "Streamer"];
23
27
 
24
28
  const NOT_RUNNING = "Spacepal is not running. Open Spacepal (getspacepal.com) and try again.";
25
29
 
@@ -32,16 +36,30 @@ async function isRunning() {
32
36
  }
33
37
  }
34
38
 
35
- async function readState() {
39
+ async function newestFile(paths) {
36
40
  let newest = null;
37
- for (const path of STATE_FILES) {
41
+ for (const path of paths) {
38
42
  try {
39
43
  const { mtimeMs } = await stat(path);
40
44
  if (!newest || mtimeMs > newest.mtimeMs) newest = { path, mtimeMs };
41
45
  } catch {}
42
46
  }
43
- if (!newest) return null;
44
- return JSON.parse(await readFile(newest.path, "utf8"));
47
+ return newest?.path ?? null;
48
+ }
49
+
50
+ async function readState() {
51
+ const path = await newestFile(STATE_FILES);
52
+ return path ? JSON.parse(await readFile(path, "utf8")) : null;
53
+ }
54
+
55
+ async function readProfiles() {
56
+ const path = await newestFile(PROFILE_FILES);
57
+ if (!path) return [];
58
+ try {
59
+ return JSON.parse(await readFile(path, "utf8")).map((p) => p.name);
60
+ } catch {
61
+ return [];
62
+ }
45
63
  }
46
64
 
47
65
  async function send(path, query = {}) {
@@ -104,9 +122,9 @@ server.registerTool(
104
122
  "list_spaces",
105
123
  {
106
124
  title: "List Spaces",
107
- description: "List every macOS Space (desktop) with its number, name, display, apps and which one is active.",
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.",
108
126
  inputSchema: {},
109
- annotations: { readOnlyHint: true },
127
+ annotations: { readOnlyHint: true, openWorldHint: false },
110
128
  },
111
129
  guarded(async () => {
112
130
  if (!(await isRunning())) return fail(NOT_RUNNING);
@@ -115,7 +133,8 @@ server.registerTool(
115
133
  const lines = state.spaces.map((s) => {
116
134
  const label = s.isFullscreen ? "full screen" : `Desktop ${s.number ?? "?"}`;
117
135
  const apps = s.apps?.length ? ` — ${s.apps.join(", ")}` : "";
118
- return `${s.isActive ? "*" : " "} ${label}: ${s.name}${apps}`;
136
+ const note = s.note ? ` (note: ${s.note})` : "";
137
+ return `${s.isActive ? "*" : " "} ${label}: ${s.name}${apps}${note}`;
119
138
  });
120
139
  const displays = new Set(state.spaces.map((s) => s.display)).size;
121
140
  return {
@@ -133,7 +152,7 @@ server.registerTool(
133
152
  title: "Current Space",
134
153
  description: "Name and number of the Space you are on.",
135
154
  inputSchema: {},
136
- annotations: { readOnlyHint: true },
155
+ annotations: { readOnlyHint: true, openWorldHint: false },
137
156
  },
138
157
  guarded(async () => {
139
158
  if (!(await isRunning())) return fail(NOT_RUNNING);
@@ -150,6 +169,7 @@ server.registerTool(
150
169
  title: "Switch Space",
151
170
  description: "Switch to a Space by desktop number or name. Use \"next\", \"previous\" or \"back\" to step.",
152
171
  inputSchema: { space: spaceArg },
172
+ annotations: { openWorldHint: false },
153
173
  },
154
174
  guarded(async ({ space }) => {
155
175
  const before = (await readState())?.current;
@@ -175,8 +195,11 @@ server.registerTool(
175
195
  title: "Move window to Space",
176
196
  description:
177
197
  "Send the frontmost window to another Space (by number or name, or \"next\"/\"previous\"). " +
178
- "Note: moving windows needs macOS 14.0-14.4 or 26.4+, a macOS limit.",
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.",
179
201
  inputSchema: { space: spaceArg },
202
+ annotations: { openWorldHint: false },
180
203
  },
181
204
  guarded(async ({ space }) => {
182
205
  const s = String(space).toLowerCase();
@@ -199,6 +222,7 @@ server.registerTool(
199
222
  space: spaceArg.optional(),
200
223
  name: z.string().max(60).describe("New name. Empty text restores \"Desktop N\"."),
201
224
  },
225
+ annotations: { openWorldHint: false },
202
226
  },
203
227
  guarded(async ({ space, name }) => {
204
228
  if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
@@ -214,10 +238,11 @@ server.registerTool(
214
238
  title: "Open overview",
215
239
  description: "Open Spacepal's overview grid of all Spaces on screen.",
216
240
  inputSchema: {},
241
+ annotations: { openWorldHint: false },
217
242
  },
218
243
  guarded(async () => {
219
244
  await send("overview");
220
- return text("Overview opened.");
245
+ return text("Asked Spacepal to open the overview. It opens if the trial is running or Spacepal is licensed; otherwise Spacepal shows its license window.");
221
246
  }),
222
247
  );
223
248
 
@@ -227,8 +252,10 @@ server.registerTool(
227
252
  title: "Set Space note",
228
253
  description: "Attach a short note to a Space (what it is for).",
229
254
  inputSchema: { space: spaceArg.optional(), text: z.string().max(500) },
255
+ annotations: { openWorldHint: false },
230
256
  },
231
257
  guarded(async ({ space, text: note }) => {
258
+ if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
232
259
  await send(`note/${ref(space)}`, { text: note });
233
260
  return text("Note saved.");
234
261
  }),
@@ -240,10 +267,178 @@ server.registerTool(
240
267
  title: "Apply profile",
241
268
  description: "Apply a saved Spacepal profile by name (names, layouts and rules).",
242
269
  inputSchema: { name: z.string().min(1) },
270
+ annotations: { openWorldHint: false },
271
+ },
272
+ guarded(async ({ name }) => {
273
+ const names = await readProfiles();
274
+ const match = names.find((n) => n.toLowerCase() === name.toLowerCase());
275
+ if (!match) {
276
+ return fail(names.length ? `No profile called "${name}". Profiles: ${names.join(", ")}.` : "No saved profiles yet. Save one in Spacepal Settings, Profiles.");
277
+ }
278
+ await send(`profile/${encodeURIComponent(match)}`);
279
+ return text(`Applied profile "${match}".`);
280
+ }),
281
+ );
282
+
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
+ server.registerTool(
298
+ "apply_template",
299
+ {
300
+ title: "Apply template",
301
+ 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 },
243
306
  },
244
307
  guarded(async ({ name }) => {
245
- await send(`profile/${encodeURIComponent(name)}`);
246
- return text(`Applied profile "${name}".`);
308
+ await send(`template/${encodeURIComponent(name)}`);
309
+ await settle();
310
+ return text(`Applied the ${name} template.`);
311
+ }),
312
+ );
313
+
314
+ server.registerTool(
315
+ "set_up_space",
316
+ {
317
+ 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 },
321
+ },
322
+ guarded(async ({ space }) => {
323
+ const target = space === undefined ? null : await findSpace(space);
324
+ if (space !== undefined && !target) return fail(`No Space called "${space}".`);
325
+ await send(`setup/${ref(space)}`);
326
+ return text(`Setting up ${target?.name ?? "the current Space"}.`);
327
+ }),
328
+ );
329
+
330
+ server.registerTool(
331
+ "save_window_layout",
332
+ {
333
+ title: "Save window layout",
334
+ description: "Save which Space and position every window is in, for this display setup. Replaces the previous manual save.",
335
+ inputSchema: {},
336
+ annotations: { idempotentHint: true, openWorldHint: false },
337
+ },
338
+ guarded(async () => {
339
+ await send("save-layout");
340
+ return text("Saved the window layout.");
341
+ }),
342
+ );
343
+
344
+ server.registerTool(
345
+ "restore_window_layout",
346
+ {
347
+ title: "Restore window layout",
348
+ 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+.",
351
+ inputSchema: {},
352
+ annotations: { openWorldHint: false },
353
+ },
354
+ guarded(async () => {
355
+ await send("restore-layout");
356
+ return text("Asked Spacepal to restore the saved layout. It shows how many windows it moved.");
357
+ }),
358
+ );
359
+
360
+ server.registerTool(
361
+ "save_sessions",
362
+ {
363
+ title: "Save sessions",
364
+ 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.",
367
+ inputSchema: {
368
+ space: spaceArg.optional(),
369
+ all: z.boolean().optional().describe("Save every Space instead of one."),
370
+ },
371
+ annotations: { idempotentHint: true, openWorldHint: false },
372
+ },
373
+ guarded(async ({ space, all }) => {
374
+ if (all) {
375
+ await send("session-save/all");
376
+ return text("Asked Spacepal to save sessions on every Space. It shows how many windows it saved.");
377
+ }
378
+ if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
379
+ await send(`session-save/${ref(space)}`);
380
+ return text("Asked Spacepal to save the sessions. It shows how many windows it saved.");
381
+ }),
382
+ );
383
+
384
+ server.registerTool(
385
+ "restore_sessions",
386
+ {
387
+ title: "Restore sessions",
388
+ description: "Reopen the saved terminal sessions and Chrome windows on their Spaces. Needs the Spacepal trial or a license.",
389
+ inputSchema: {},
390
+ annotations: { openWorldHint: false },
391
+ },
392
+ guarded(async () => {
393
+ await send("session-restore");
394
+ return text("Asked Spacepal to reopen the saved sessions.");
395
+ }),
396
+ );
397
+
398
+ server.registerTool(
399
+ "add_desktop",
400
+ {
401
+ title: "Add desktop",
402
+ description: "Add a new desktop (Space) on the current display.",
403
+ inputSchema: {},
404
+ annotations: { openWorldHint: false },
405
+ },
406
+ guarded(async () => {
407
+ const before = (await readState())?.spaces.length ?? 0;
408
+ await send("add-desktop");
409
+ const end = Date.now() + 4000;
410
+ while (Date.now() < end) {
411
+ await new Promise((r) => setTimeout(r, 250));
412
+ const after = (await readState())?.spaces.length ?? 0;
413
+ if (after > before) return text(`Added a desktop. ${after} Spaces now.`);
414
+ }
415
+ return text("Asked Spacepal to add a desktop; it has not confirmed it yet.");
416
+ }),
417
+ );
418
+
419
+ server.registerTool(
420
+ "remove_desktop",
421
+ {
422
+ title: "Remove desktop",
423
+ 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 },
428
+ },
429
+ guarded(async ({ space }) => {
430
+ const target = await findSpace(space);
431
+ if (!target) return fail(`No Space called "${space}". Call list_spaces to see the names.`);
432
+ if (target.isFullscreen) return fail(`${target.name} is a full-screen Space. Leave full screen in that app instead.`);
433
+ const before = (await readState())?.spaces.length ?? 0;
434
+ await send(`remove-desktop/${ref(space)}`);
435
+ const end = Date.now() + 4000;
436
+ while (Date.now() < end) {
437
+ await new Promise((r) => setTimeout(r, 250));
438
+ const after = (await readState())?.spaces.length ?? 0;
439
+ if (after < before) return text(`Removed ${target.name}. ${after} Spaces now.`);
440
+ }
441
+ return text(`Asked Spacepal to remove ${target.name}; it has not confirmed it yet.`);
247
442
  }),
248
443
  );
249
444
 
package/package.json CHANGED
@@ -1,15 +1,38 @@
1
1
  {
2
2
  "name": "spacepal-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
7
- "bin": { "spacepal-mcp": "index.js" },
8
- "files": ["index.js", "README.md", "LICENSE", "server.json"],
9
- "engines": { "node": ">=18" },
10
- "os": ["darwin"],
11
- "keywords": ["mcp", "model-context-protocol", "macos", "spaces", "mission-control", "spacepal", "claude"],
7
+ "bin": {
8
+ "spacepal-mcp": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "README.md",
13
+ "LICENSE",
14
+ "server.json"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "os": [
20
+ "darwin"
21
+ ],
22
+ "keywords": [
23
+ "mcp",
24
+ "model-context-protocol",
25
+ "macos",
26
+ "spaces",
27
+ "mission-control",
28
+ "spacepal",
29
+ "claude"
30
+ ],
12
31
  "homepage": "https://getspacepal.com/integrations/mcp/",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/TanKucukhas/spacepal-mcp.git"
35
+ },
13
36
  "author": "Halberd LLC",
14
37
  "license": "MIT",
15
38
  "dependencies": {
package/server.json CHANGED
@@ -3,13 +3,13 @@
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.1.0",
6
+ "version": "0.2.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.1.0",
12
+ "version": "0.2.0",
13
13
  "transport": { "type": "stdio" }
14
14
  }
15
15
  ]