spacepal-mcp 0.4.2 → 0.5.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/README.md CHANGED
@@ -24,6 +24,7 @@ It runs locally over stdio. It reads the Space list Spacepal writes to
24
24
  | `current_space` | The Space you are on |
25
25
  | `switch_space` | Switch by number or name, or step with `next`, `previous`, `back` |
26
26
  | `move_window_to_space` | Send the frontmost window to a Space by number or name, or to `next` or `previous` |
27
+ | `open_url` | Open a link in a new browser window on a chosen Space (or the current one), so it does not land in a browser window on another Space |
27
28
  | `rename_space` | Name a Space; an empty name restores "Desktop N" |
28
29
  | `set_space_note` | Attach a short note to a Space |
29
30
  | `open_overview` | Open Spacepal's overview grid |
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.4.1";
16
+ const VERSION = "0.5.0";
17
17
 
18
18
  // Direct build writes to Application Support; the App Store build is sandboxed.
19
19
  const SUPPORT_DIRS = [
@@ -21,6 +21,8 @@ const SUPPORT_DIRS = [
21
21
  join(homedir(), "Library/Containers/com.halberd.spacepal/Data/Library/Application Support/Spacepal"),
22
22
  ];
23
23
  const STATE_FILES = SUPPORT_DIRS.map((d) => join(d, "state.json"));
24
+ // Per-Space settings, including the folders of the terminals that belong to each Space.
25
+ const SPACE_FILES = SUPPORT_DIRS.map((d) => join(d, "spaces.json"));
24
26
  const PROFILE_FILES = SUPPORT_DIRS.map((d) => join(d, "profiles.json"));
25
27
  // Built into the app (Profiles.swift, SpaceTemplate.all()).
26
28
  const TEMPLATES = ["Developer", "Designer", "Freelancer", "Student", "Streamer"];
@@ -139,6 +141,8 @@ Start with list_spaces to learn the Spaces, their numbers, names, notes and the
139
141
 
140
142
  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.
141
143
 
144
+ To show the user a web page (docs, a pull request, a local dev server, a sign-in page), call open_url instead of running "open <url>" in a shell. A shell open puts the page in whichever browser window was used last, often on another Space where the user cannot find it. Leave open_url's space out and the page opens on this project's Space.
145
+
142
146
  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. If it says Spacepal is not installed, give the user the download link from the message. The overview and sessions need Spacepal's trial or a license; names, notes and switching work in the free version.`;
143
147
 
144
148
  const server = new McpServer({ name: "spacepal", version: VERSION }, { instructions: INSTRUCTIONS });
@@ -285,6 +289,131 @@ server.registerTool(
285
289
  }),
286
290
  );
287
291
 
292
+ // The Space of the project this client works in: the one whose terminals run in this folder
293
+ // (Spacepal records them), or whose note names it. A coding agent's MCP server starts in the
294
+ // project folder; Claude Desktop starts it in "/", which matches nothing.
295
+ async function projectSpace(state) {
296
+ const cwd = process.cwd();
297
+ if (!state || cwd === "/" || cwd === homedir()) return null;
298
+ let best = null;
299
+ let tie = false;
300
+ try {
301
+ const path = await newestFile(SPACE_FILES);
302
+ const meta = path ? JSON.parse(await readFile(path, "utf8")) : {};
303
+ for (const [id, entry] of Object.entries(meta)) {
304
+ for (const t of entry?.automation?.terminals ?? []) {
305
+ const folder = String(t.folder ?? "").replace(/\/+$/, "");
306
+ if (!folder || folder === homedir()) continue;
307
+ if (cwd !== folder && !cwd.startsWith(folder + "/")) continue;
308
+ if (!best || folder.length > best.len) { best = { id, len: folder.length }; tie = false; }
309
+ else if (folder.length === best.len && id !== best.id) tie = true;
310
+ }
311
+ }
312
+ } catch {}
313
+ if (best && !tie) {
314
+ const space = state.spaces.find((s) => s.id === best.id && !s.isFullscreen);
315
+ if (space) return space;
316
+ }
317
+ // Notes such as "Reddit Growth (~/workspace/reddit-growth)".
318
+ const short = cwd.replace(homedir(), "~");
319
+ const named = state.spaces.filter((s) => s.note && (s.note.includes(cwd) || s.note.includes(short)));
320
+ return named.length === 1 ? named[0] : null;
321
+ }
322
+
323
+ // Default browser's app path, via AppKit from JXA (no extra permission needed).
324
+ async function defaultBrowser() {
325
+ try {
326
+ const { stdout } = await run("/usr/bin/osascript", [
327
+ "-l", "JavaScript", "-e",
328
+ "ObjC.import('AppKit'); $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString('https://example.com')).path.js",
329
+ ]);
330
+ return stdout.trim() || null;
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ // Opens the link in a NEW window of the default browser, so it lands on the Space shown now
337
+ // instead of in the browser window the user used last (which may be on another Space).
338
+ async function openInNewWindow(url) {
339
+ const app = await defaultBrowser();
340
+ const name = app ? app.split("/").pop().replace(/\.app$/, "") : "";
341
+ if (/safari/i.test(name)) {
342
+ await run("/usr/bin/osascript", [
343
+ "-e", "on run argv",
344
+ "-e", `tell application "${name}" to make new document with properties {URL:item 1 of argv}`,
345
+ "-e", `tell application "${name}" to activate`,
346
+ "-e", "end run", url,
347
+ ]);
348
+ } else if (/firefox|zen|librewolf|floorp/i.test(name)) {
349
+ await run("/usr/bin/open", ["-na", app, "--args", "-new-window", url]);
350
+ } else if (app && /chrome|chromium|brave|edge|vivaldi|opera|arc|dia|comet|thorium/i.test(name)) {
351
+ await run("/usr/bin/open", ["-na", app, "--args", "--new-window", url]);
352
+ } else {
353
+ await run("/usr/bin/open", [url]);
354
+ }
355
+ return name || "the default browser";
356
+ }
357
+
358
+ server.registerTool(
359
+ "open_url",
360
+ {
361
+ title: "Open link on a Space",
362
+ description:
363
+ "Open a web link in a new window of the user's default browser, on the Space you choose. " +
364
+ "Leave space out to open it on the Space of the project you are working in (the Space whose terminals run in your working folder), or on the user's current Space if none matches. " +
365
+ "Use it instead of the shell's open command: that adds a tab to the browser window used last, which may be on another Space, so the user cannot find it. " +
366
+ "With a space, Spacepal briefly shows that Space, opens the window there, then returns to the Space the user was on (stay=true keeps that Space on screen). " +
367
+ "Only http and https links. Fails if no Space has that name.",
368
+ inputSchema: {
369
+ url: z.string().min(1).describe('The link, e.g. "https://example.com". "example.com" is read as https.'),
370
+ space: spaceArg.optional().describe(
371
+ "Desktop number or name from list_spaces. Leave it out for this project's Space, or the current Space if Spacepal does not know the project.",
372
+ ),
373
+ stay: z.boolean().optional().describe("Stay on that Space afterwards instead of returning. Default false."),
374
+ },
375
+ annotations: { title: "Open link on a Space", ...CHANGE, openWorldHint: true },
376
+ },
377
+ guarded(async ({ url, space, stay }) => {
378
+ let link = String(url).trim();
379
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(link)) link = `https://${link}`;
380
+ let parsed;
381
+ try {
382
+ parsed = new URL(link);
383
+ } catch {
384
+ return fail(`"${url}" is not a valid link.`);
385
+ }
386
+ if (!["http:", "https:"].includes(parsed.protocol)) return fail("Only http and https links can be opened.");
387
+ if (!(await isRunning())) return fail(await notRunning());
388
+
389
+ const state = await readState();
390
+ const origin = state?.spaces.find((s) => s.isActive);
391
+ const target = space === undefined ? await projectSpace(state) : await findSpace(space);
392
+ if (space !== undefined && !target) return fail(`No Space called "${space}". Call list_spaces to see the names.`);
393
+ if (target?.isFullscreen) return fail(`${target.name} is a full-screen app; pick a desktop.`);
394
+
395
+ const elsewhere = target && !target.isActive;
396
+ // Spacepal's "Keep with me" would otherwise bring the new window back to the Space the user returns to.
397
+ // Older Spacepal versions ignore this command.
398
+ if (elsewhere) await send("hold-windows/8");
399
+ if (elsewhere) {
400
+ await send(`switch/${ref(target.number ?? target.name)}`);
401
+ if (!(await waitForSwitch(state?.current))) return fail(`Could not switch to ${target.name}; the link was not opened.`);
402
+ await new Promise((r) => setTimeout(r, 300));
403
+ }
404
+ const browser = await openInNewWindow(parsed.href);
405
+ const where = target?.name ?? origin?.name ?? "the current Space";
406
+ if (elsewhere && !stay && origin) {
407
+ // Give the browser time to put its window on this Space before leaving it.
408
+ await new Promise((r) => setTimeout(r, 1800));
409
+ await send(`switch/${ref(origin.number ?? origin.name)}`);
410
+ await waitForSwitch(target.name);
411
+ return text(`Opened ${parsed.href} in a new ${browser} window on ${where}, then returned to ${origin.name}.`);
412
+ }
413
+ return text(`Opened ${parsed.href} in a new ${browser} window on ${where}.`);
414
+ }),
415
+ );
416
+
288
417
  server.registerTool(
289
418
  "rename_space",
290
419
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spacepal-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
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.4.2",
6
+ "version": "0.5.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.4.2",
12
+ "version": "0.5.0",
13
13
  "transport": {
14
14
  "type": "stdio"
15
15
  }