spacepal-mcp 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/index.js +445 -0
- package/package.json +40 -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,100 @@
|
|
|
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
|
+

|
|
8
|
+
|
|
9
|
+
It runs locally over stdio. It reads the Space list Spacepal writes to
|
|
10
|
+
`~/Library/Application Support/Spacepal/state.json` and sends commands through the
|
|
11
|
+
`spacepal://` URL scheme. It makes no network calls.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- macOS 14 or later, with Spacepal running (direct or Mac App Store build)
|
|
16
|
+
- Node.js 18 or later
|
|
17
|
+
|
|
18
|
+
## Tools
|
|
19
|
+
|
|
20
|
+
| Tool | What it does |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `list_spaces` | Every Space with its number, name, display, apps, note, and which one is active |
|
|
23
|
+
| `current_space` | The Space you are on |
|
|
24
|
+
| `switch_space` | Switch by number or name, or step with `next`, `previous`, `back` |
|
|
25
|
+
| `move_window_to_space` | Send the frontmost window to a Space by number or name, or to `next` or `previous` |
|
|
26
|
+
| `rename_space` | Name a Space; an empty name restores "Desktop N" |
|
|
27
|
+
| `set_space_note` | Attach a short note to a Space |
|
|
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.
|
|
42
|
+
|
|
43
|
+
`move_window_to_space` acts on the frontmost window. In Claude Desktop that is usually
|
|
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.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
### Claude Code
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
claude mcp add spacepal -- npx -y spacepal-mcp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Claude Desktop
|
|
61
|
+
|
|
62
|
+
Add this to `~/Library/Application Support/Claude/claude_desktop_config.json`, then restart
|
|
63
|
+
Claude:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"spacepal": { "command": "npx", "args": ["-y", "spacepal-mcp"] }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Cursor
|
|
74
|
+
|
|
75
|
+
Add the same block to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"spacepal": { "command": "npx", "args": ["-y", "spacepal-mcp"] }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### From source
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
cd mcp && npm install
|
|
89
|
+
claude mcp add spacepal -- node "$PWD/index.js"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Troubleshooting
|
|
93
|
+
|
|
94
|
+
- **"Spacepal is not running"**: open Spacepal from Applications.
|
|
95
|
+
- **Names are out of date**: Spacepal rewrites `state.json` when Spaces change. Open its
|
|
96
|
+
menu once to refresh it.
|
|
97
|
+
- **Switching does nothing**: check Spacepal > Settings > Setup. It needs Accessibility
|
|
98
|
+
permission.
|
|
99
|
+
|
|
100
|
+
MIT © Halberd LLC
|
package/index.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
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.2.0";
|
|
17
|
+
|
|
18
|
+
// Direct build writes to Application Support; the App Store build is sandboxed.
|
|
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
|
+
];
|
|
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"];
|
|
27
|
+
|
|
28
|
+
const NOT_RUNNING = "Spacepal is not running. Open Spacepal (getspacepal.com) and try again.";
|
|
29
|
+
|
|
30
|
+
async function isRunning() {
|
|
31
|
+
try {
|
|
32
|
+
await run("/usr/bin/pgrep", ["-x", "Spacepal"]);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function newestFile(paths) {
|
|
40
|
+
let newest = null;
|
|
41
|
+
for (const path of paths) {
|
|
42
|
+
try {
|
|
43
|
+
const { mtimeMs } = await stat(path);
|
|
44
|
+
if (!newest || mtimeMs > newest.mtimeMs) newest = { path, mtimeMs };
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
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
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function send(path, query = {}) {
|
|
66
|
+
if (!(await isRunning())) throw new Error(NOT_RUNNING);
|
|
67
|
+
// Encode with %20, not "+": the app reads query values with URLComponents, which keeps "+".
|
|
68
|
+
const qs = Object.entries(query)
|
|
69
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
70
|
+
.join("&");
|
|
71
|
+
const url = `spacepal://${path}${qs ? `?${qs}` : ""}`;
|
|
72
|
+
// -g keeps Spacepal from stealing focus from the client.
|
|
73
|
+
await run("/usr/bin/open", ["-g", url]);
|
|
74
|
+
return url;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A Space can be addressed by desktop number or by name.
|
|
78
|
+
function ref(space) {
|
|
79
|
+
if (space === undefined || space === null || space === "") return "current";
|
|
80
|
+
if (typeof space === "number" || /^\d+$/.test(String(space))) return String(space);
|
|
81
|
+
return encodeURIComponent(String(space));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function findSpace(space) {
|
|
85
|
+
const state = await readState();
|
|
86
|
+
if (!state) return null;
|
|
87
|
+
const key = String(space).toLowerCase();
|
|
88
|
+
return state.spaces.find((s) => String(s.number) === key || s.name.toLowerCase() === key) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
92
|
+
const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
93
|
+
const settle = () => new Promise((r) => setTimeout(r, 600));
|
|
94
|
+
|
|
95
|
+
// state.json is rewritten after the switch animation; poll until "current" changes.
|
|
96
|
+
async function waitForSwitch(before, timeoutMs = 2500) {
|
|
97
|
+
const end = Date.now() + timeoutMs;
|
|
98
|
+
while (Date.now() < end) {
|
|
99
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
100
|
+
const current = (await readState())?.current;
|
|
101
|
+
if (current && current !== before) return current;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function guarded(fn) {
|
|
107
|
+
return async (args) => {
|
|
108
|
+
try {
|
|
109
|
+
return await fn(args ?? {});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return fail(e.message ?? String(e));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const server = new McpServer({ name: "spacepal", version: VERSION });
|
|
117
|
+
const spaceArg = z
|
|
118
|
+
.union([z.number().int().positive(), z.string().min(1)])
|
|
119
|
+
.describe("Desktop number (1, 2, ...) or Space name, e.g. \"Mail\".");
|
|
120
|
+
|
|
121
|
+
server.registerTool(
|
|
122
|
+
"list_spaces",
|
|
123
|
+
{
|
|
124
|
+
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.",
|
|
126
|
+
inputSchema: {},
|
|
127
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
128
|
+
},
|
|
129
|
+
guarded(async () => {
|
|
130
|
+
if (!(await isRunning())) return fail(NOT_RUNNING);
|
|
131
|
+
const state = await readState();
|
|
132
|
+
if (!state) return fail("Spacepal has not written its Space list yet. Open the Spacepal menu once and try again.");
|
|
133
|
+
const lines = state.spaces.map((s) => {
|
|
134
|
+
const label = s.isFullscreen ? "full screen" : `Desktop ${s.number ?? "?"}`;
|
|
135
|
+
const apps = s.apps?.length ? ` — ${s.apps.join(", ")}` : "";
|
|
136
|
+
const note = s.note ? ` (note: ${s.note})` : "";
|
|
137
|
+
return `${s.isActive ? "*" : " "} ${label}: ${s.name}${apps}${note}`;
|
|
138
|
+
});
|
|
139
|
+
const displays = new Set(state.spaces.map((s) => s.display)).size;
|
|
140
|
+
return {
|
|
141
|
+
content: [
|
|
142
|
+
{ type: "text", text: `Current: ${state.current}\n${displays} display(s), ${state.spaces.length} Spaces (* = active)\n${lines.join("\n")}` },
|
|
143
|
+
],
|
|
144
|
+
structuredContent: state,
|
|
145
|
+
};
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
server.registerTool(
|
|
150
|
+
"current_space",
|
|
151
|
+
{
|
|
152
|
+
title: "Current Space",
|
|
153
|
+
description: "Name and number of the Space you are on.",
|
|
154
|
+
inputSchema: {},
|
|
155
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
156
|
+
},
|
|
157
|
+
guarded(async () => {
|
|
158
|
+
if (!(await isRunning())) return fail(NOT_RUNNING);
|
|
159
|
+
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"));
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
server.registerTool(
|
|
167
|
+
"switch_space",
|
|
168
|
+
{
|
|
169
|
+
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 },
|
|
173
|
+
},
|
|
174
|
+
guarded(async ({ space }) => {
|
|
175
|
+
const before = (await readState())?.current;
|
|
176
|
+
const s = String(space).toLowerCase();
|
|
177
|
+
if (["next", "right"].includes(s)) await send("next");
|
|
178
|
+
else if (["previous", "prev", "left"].includes(s)) await send("previous");
|
|
179
|
+
else if (["back", "last"].includes(s)) await send("back");
|
|
180
|
+
else {
|
|
181
|
+
const target = await findSpace(space);
|
|
182
|
+
if (!target) return fail(`No Space called "${space}". Call list_spaces to see the names.`);
|
|
183
|
+
// Spacepal treats "switch to the Space you are on" as back-and-forth, so skip it.
|
|
184
|
+
if (target.isActive) return text(`Already on ${target.name}.`);
|
|
185
|
+
await send(`switch/${ref(space)}`);
|
|
186
|
+
}
|
|
187
|
+
const now = await waitForSwitch(before);
|
|
188
|
+
return text(now ? `Switched. Now on: ${now}` : "Switch sent; Spacepal has not confirmed it yet.");
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
server.registerTool(
|
|
193
|
+
"move_window_to_space",
|
|
194
|
+
{
|
|
195
|
+
title: "Move window to Space",
|
|
196
|
+
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 },
|
|
203
|
+
},
|
|
204
|
+
guarded(async ({ space }) => {
|
|
205
|
+
const s = String(space).toLowerCase();
|
|
206
|
+
if (["next", "right"].includes(s)) await send("move-window/next");
|
|
207
|
+
else if (["previous", "prev", "left"].includes(s)) await send("move-window/previous");
|
|
208
|
+
else {
|
|
209
|
+
if (!(await findSpace(space))) return fail(`No Space called "${space}". Call list_spaces to see the names.`);
|
|
210
|
+
await send(`move-window/${ref(space)}`);
|
|
211
|
+
}
|
|
212
|
+
return text(`Asked Spacepal to move the front window to ${space}.`);
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
server.registerTool(
|
|
217
|
+
"rename_space",
|
|
218
|
+
{
|
|
219
|
+
title: "Rename Space",
|
|
220
|
+
description: "Give a Space a name. Leave space empty for the current one; an empty name restores the default.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
space: spaceArg.optional(),
|
|
223
|
+
name: z.string().max(60).describe("New name. Empty text restores \"Desktop N\"."),
|
|
224
|
+
},
|
|
225
|
+
annotations: { openWorldHint: false },
|
|
226
|
+
},
|
|
227
|
+
guarded(async ({ space, name }) => {
|
|
228
|
+
if (space !== undefined && !(await findSpace(space))) return fail(`No Space called "${space}".`);
|
|
229
|
+
await send(`rename/${ref(space)}`, { name });
|
|
230
|
+
await settle();
|
|
231
|
+
return text(name ? `Renamed to "${name}".` : "Name reset to default.");
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
server.registerTool(
|
|
236
|
+
"open_overview",
|
|
237
|
+
{
|
|
238
|
+
title: "Open overview",
|
|
239
|
+
description: "Open Spacepal's overview grid of all Spaces on screen.",
|
|
240
|
+
inputSchema: {},
|
|
241
|
+
annotations: { openWorldHint: false },
|
|
242
|
+
},
|
|
243
|
+
guarded(async () => {
|
|
244
|
+
await send("overview");
|
|
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.");
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
server.registerTool(
|
|
250
|
+
"set_space_note",
|
|
251
|
+
{
|
|
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 },
|
|
256
|
+
},
|
|
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.");
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
server.registerTool(
|
|
265
|
+
"apply_profile",
|
|
266
|
+
{
|
|
267
|
+
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 },
|
|
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 },
|
|
306
|
+
},
|
|
307
|
+
guarded(async ({ 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.`);
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spacepal-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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": {
|
|
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
|
+
],
|
|
31
|
+
"homepage": "https://getspacepal.com/integrations/mcp/",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/TanKucukhas/spacepal-mcp.git"
|
|
35
|
+
},
|
|
36
|
+
"author": "Halberd LLC",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.17.0",
|
|
40
|
+
"zod": "^3.23.8"
|
|
41
|
+
}
|
|
42
|
+
}
|
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.2.0",
|
|
7
|
+
"websiteUrl": "https://getspacepal.com/integrations/mcp/",
|
|
8
|
+
"packages": [
|
|
9
|
+
{
|
|
10
|
+
"registryType": "npm",
|
|
11
|
+
"identifier": "spacepal-mcp",
|
|
12
|
+
"version": "0.2.0",
|
|
13
|
+
"transport": { "type": "stdio" }
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|