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 +21 -0
- package/README.md +79 -2
- package/index.js +250 -0
- package/package.json +17 -4
- package/server.json +16 -0
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
|
-
#
|
|
1
|
+
# Spacepal MCP server
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|