spacepal-mcp 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Halberd LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # Spacepal MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lets Claude, Cursor and other MCP clients work with your macOS Spaces through
4
+ [Spacepal](https://getspacepal.com): "switch to my Mail Space", "what's on Desktop 3?",
5
+ "name this desktop Research".
6
+
7
+ It runs locally over stdio. It reads the Space list Spacepal writes to
8
+ `~/Library/Application Support/Spacepal/state.json` and sends commands through the
9
+ `spacepal://` URL scheme. It makes no network calls.
10
+
11
+ ## Requirements
12
+
13
+ - macOS 14 or later, with Spacepal running (direct or Mac App Store build)
14
+ - Node.js 18 or later
15
+
16
+ ## Tools
17
+
18
+ | Tool | What it does |
19
+ |---|---|
20
+ | `list_spaces` | Every Space with its number, name, display, apps, and which one is active |
21
+ | `current_space` | The Space you are on |
22
+ | `switch_space` | Switch by number or name, or step with `next`, `previous`, `back` |
23
+ | `move_window_to_space` | Send the frontmost window to a Space by number or name, or to `next` or `previous` |
24
+ | `rename_space` | Name a Space; an empty name restores "Desktop N" |
25
+ | `open_overview` | Open Spacepal's overview grid |
26
+ | `set_space_note` | Attach a short note to a Space |
27
+ | `apply_profile` | Apply a saved Spacepal profile |
28
+
29
+ `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.
31
+
32
+ ## Install
33
+
34
+ ### Claude Code
35
+
36
+ ```sh
37
+ claude mcp add spacepal -- npx -y spacepal-mcp
38
+ ```
39
+
40
+ ### Claude Desktop
41
+
42
+ Add this to `~/Library/Application Support/Claude/claude_desktop_config.json`, then restart
43
+ Claude:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "spacepal": { "command": "npx", "args": ["-y", "spacepal-mcp"] }
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### Cursor
54
+
55
+ Add the same block to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "spacepal": { "command": "npx", "args": ["-y", "spacepal-mcp"] }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### From source
66
+
67
+ ```sh
68
+ cd mcp && npm install
69
+ claude mcp add spacepal -- node "$PWD/index.js"
70
+ ```
71
+
72
+ ## Troubleshooting
73
+
74
+ - **"Spacepal is not running"**: open Spacepal from Applications.
75
+ - **Names are out of date**: Spacepal rewrites `state.json` when Spaces change. Open its
76
+ menu once to refresh it.
77
+ - **Switching does nothing**: check Spacepal > Settings > Setup. It needs Accessibility
78
+ permission.
79
+
80
+ MIT © Halberd LLC
package/index.js ADDED
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ // Spacepal MCP server (stdio).
3
+ // Reads the live Space list from the state.json Spacepal writes, and sends
4
+ // commands through the spacepal:// URL scheme. Nothing leaves the Mac.
5
+
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { z } from "zod";
9
+ import { execFile } from "node:child_process";
10
+ import { readFile, stat } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { promisify } from "node:util";
14
+
15
+ const run = promisify(execFile);
16
+ const VERSION = "0.1.0";
17
+
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"),
22
+ ];
23
+
24
+ const NOT_RUNNING = "Spacepal is not running. Open Spacepal (getspacepal.com) and try again.";
25
+
26
+ async function isRunning() {
27
+ try {
28
+ await run("/usr/bin/pgrep", ["-x", "Spacepal"]);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function readState() {
36
+ let newest = null;
37
+ for (const path of STATE_FILES) {
38
+ try {
39
+ const { mtimeMs } = await stat(path);
40
+ if (!newest || mtimeMs > newest.mtimeMs) newest = { path, mtimeMs };
41
+ } catch {}
42
+ }
43
+ if (!newest) return null;
44
+ return JSON.parse(await readFile(newest.path, "utf8"));
45
+ }
46
+
47
+ async function send(path, query = {}) {
48
+ if (!(await isRunning())) throw new Error(NOT_RUNNING);
49
+ // Encode with %20, not "+": the app reads query values with URLComponents, which keeps "+".
50
+ const qs = Object.entries(query)
51
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
52
+ .join("&");
53
+ const url = `spacepal://${path}${qs ? `?${qs}` : ""}`;
54
+ // -g keeps Spacepal from stealing focus from the client.
55
+ await run("/usr/bin/open", ["-g", url]);
56
+ return url;
57
+ }
58
+
59
+ // A Space can be addressed by desktop number or by name.
60
+ function ref(space) {
61
+ if (space === undefined || space === null || space === "") return "current";
62
+ if (typeof space === "number" || /^\d+$/.test(String(space))) return String(space);
63
+ return encodeURIComponent(String(space));
64
+ }
65
+
66
+ async function findSpace(space) {
67
+ const state = await readState();
68
+ if (!state) return null;
69
+ const key = String(space).toLowerCase();
70
+ return state.spaces.find((s) => String(s.number) === key || s.name.toLowerCase() === key) ?? null;
71
+ }
72
+
73
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
74
+ const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
75
+ const settle = () => new Promise((r) => setTimeout(r, 600));
76
+
77
+ // state.json is rewritten after the switch animation; poll until "current" changes.
78
+ async function waitForSwitch(before, timeoutMs = 2500) {
79
+ const end = Date.now() + timeoutMs;
80
+ while (Date.now() < end) {
81
+ await new Promise((r) => setTimeout(r, 150));
82
+ const current = (await readState())?.current;
83
+ if (current && current !== before) return current;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function guarded(fn) {
89
+ return async (args) => {
90
+ try {
91
+ return await fn(args ?? {});
92
+ } catch (e) {
93
+ return fail(e.message ?? String(e));
94
+ }
95
+ };
96
+ }
97
+
98
+ const server = new McpServer({ name: "spacepal", version: VERSION });
99
+ const spaceArg = z
100
+ .union([z.number().int().positive(), z.string().min(1)])
101
+ .describe("Desktop number (1, 2, ...) or Space name, e.g. \"Mail\".");
102
+
103
+ server.registerTool(
104
+ "list_spaces",
105
+ {
106
+ title: "List Spaces",
107
+ description: "List every macOS Space (desktop) with its number, name, display, apps and which one is active.",
108
+ inputSchema: {},
109
+ annotations: { readOnlyHint: true },
110
+ },
111
+ guarded(async () => {
112
+ if (!(await isRunning())) return fail(NOT_RUNNING);
113
+ const state = await readState();
114
+ if (!state) return fail("Spacepal has not written its Space list yet. Open the Spacepal menu once and try again.");
115
+ const lines = state.spaces.map((s) => {
116
+ const label = s.isFullscreen ? "full screen" : `Desktop ${s.number ?? "?"}`;
117
+ const apps = s.apps?.length ? ` — ${s.apps.join(", ")}` : "";
118
+ return `${s.isActive ? "*" : " "} ${label}: ${s.name}${apps}`;
119
+ });
120
+ const displays = new Set(state.spaces.map((s) => s.display)).size;
121
+ return {
122
+ content: [
123
+ { type: "text", text: `Current: ${state.current}\n${displays} display(s), ${state.spaces.length} Spaces (* = active)\n${lines.join("\n")}` },
124
+ ],
125
+ structuredContent: state,
126
+ };
127
+ }),
128
+ );
129
+
130
+ server.registerTool(
131
+ "current_space",
132
+ {
133
+ title: "Current Space",
134
+ description: "Name and number of the Space you are on.",
135
+ inputSchema: {},
136
+ annotations: { readOnlyHint: true },
137
+ },
138
+ guarded(async () => {
139
+ if (!(await isRunning())) return fail(NOT_RUNNING);
140
+ const state = await readState();
141
+ const active = state?.spaces.filter((s) => s.isActive) ?? [];
142
+ if (!active.length) return text(state?.current ? `Current: ${state.current}` : "Unknown");
143
+ return text(active.map((s) => `${s.name} (Desktop ${s.number ?? "full screen"})`).join("\n"));
144
+ }),
145
+ );
146
+
147
+ server.registerTool(
148
+ "switch_space",
149
+ {
150
+ title: "Switch Space",
151
+ description: "Switch to a Space by desktop number or name. Use \"next\", \"previous\" or \"back\" to step.",
152
+ inputSchema: { space: spaceArg },
153
+ },
154
+ guarded(async ({ space }) => {
155
+ const before = (await readState())?.current;
156
+ const s = String(space).toLowerCase();
157
+ if (["next", "right"].includes(s)) await send("next");
158
+ else if (["previous", "prev", "left"].includes(s)) await send("previous");
159
+ else if (["back", "last"].includes(s)) await send("back");
160
+ else {
161
+ const target = await findSpace(space);
162
+ if (!target) return fail(`No Space called "${space}". Call list_spaces to see the names.`);
163
+ // Spacepal treats "switch to the Space you are on" as back-and-forth, so skip it.
164
+ if (target.isActive) return text(`Already on ${target.name}.`);
165
+ await send(`switch/${ref(space)}`);
166
+ }
167
+ const now = await waitForSwitch(before);
168
+ return text(now ? `Switched. Now on: ${now}` : "Switch sent; Spacepal has not confirmed it yet.");
169
+ }),
170
+ );
171
+
172
+ server.registerTool(
173
+ "move_window_to_space",
174
+ {
175
+ title: "Move window to Space",
176
+ description:
177
+ "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.",
179
+ inputSchema: { space: spaceArg },
180
+ },
181
+ guarded(async ({ space }) => {
182
+ const s = String(space).toLowerCase();
183
+ if (["next", "right"].includes(s)) await send("move-window/next");
184
+ else if (["previous", "prev", "left"].includes(s)) await send("move-window/previous");
185
+ else {
186
+ if (!(await findSpace(space))) return fail(`No Space called "${space}". Call list_spaces to see the names.`);
187
+ await send(`move-window/${ref(space)}`);
188
+ }
189
+ return text(`Asked Spacepal to move the front window to ${space}.`);
190
+ }),
191
+ );
192
+
193
+ server.registerTool(
194
+ "rename_space",
195
+ {
196
+ title: "Rename Space",
197
+ description: "Give a Space a name. Leave space empty for the current one; an empty name restores the default.",
198
+ inputSchema: {
199
+ space: spaceArg.optional(),
200
+ name: z.string().max(60).describe("New name. Empty text restores \"Desktop N\"."),
201
+ },
202
+ },
203
+ guarded(async ({ space, name }) => {
204
+ if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
205
+ await send(`rename/${ref(space)}`, { name });
206
+ await settle();
207
+ return text(name ? `Renamed to "${name}".` : "Name reset to default.");
208
+ }),
209
+ );
210
+
211
+ server.registerTool(
212
+ "open_overview",
213
+ {
214
+ title: "Open overview",
215
+ description: "Open Spacepal's overview grid of all Spaces on screen.",
216
+ inputSchema: {},
217
+ },
218
+ guarded(async () => {
219
+ await send("overview");
220
+ return text("Overview opened.");
221
+ }),
222
+ );
223
+
224
+ server.registerTool(
225
+ "set_space_note",
226
+ {
227
+ title: "Set Space note",
228
+ description: "Attach a short note to a Space (what it is for).",
229
+ inputSchema: { space: spaceArg.optional(), text: z.string().max(500) },
230
+ },
231
+ guarded(async ({ space, text: note }) => {
232
+ await send(`note/${ref(space)}`, { text: note });
233
+ return text("Note saved.");
234
+ }),
235
+ );
236
+
237
+ server.registerTool(
238
+ "apply_profile",
239
+ {
240
+ title: "Apply profile",
241
+ description: "Apply a saved Spacepal profile by name (names, layouts and rules).",
242
+ inputSchema: { name: z.string().min(1) },
243
+ },
244
+ guarded(async ({ name }) => {
245
+ await send(`profile/${encodeURIComponent(name)}`);
246
+ return text(`Applied profile "${name}".`);
247
+ }),
248
+ );
249
+
250
+ await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "spacepal-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Spacepal: list, switch, rename macOS Spaces and move windows between them from Claude, Cursor or any MCP client.",
5
+ "mcpName": "com.getspacepal/spacepal",
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"],
12
+ "homepage": "https://getspacepal.com/integrations/mcp/",
13
+ "author": "Halberd LLC",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.17.0",
17
+ "zod": "^3.23.8"
18
+ }
19
+ }
package/server.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
3
+ "name": "com.getspacepal/spacepal",
4
+ "title": "Spacepal",
5
+ "description": "List, switch and rename macOS Spaces and move windows between them. Needs the Spacepal app.",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://getspacepal.com/integrations/mcp/",
8
+ "packages": [
9
+ {
10
+ "registryType": "npm",
11
+ "identifier": "spacepal-mcp",
12
+ "version": "0.1.0",
13
+ "transport": { "type": "stdio" }
14
+ }
15
+ ]
16
+ }