plonk-mcp 0.0.2 → 0.0.4
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 +74 -0
- package/dist/api.js +35 -1
- package/dist/factory.js +98 -0
- package/dist/http.js +83 -0
- package/dist/server.js +30 -23
- package/dist/tools/agents.js +29 -0
- package/dist/tools/layouts.js +3 -3
- package/dist/tools/update.js +5 -0
- package/dist/tools/zones.js +2 -2
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# plonk-mcp
|
|
2
|
+
|
|
3
|
+
The MCP server for [Plonk](https://github.com/ostapondo/plonk), a macOS menu bar
|
|
4
|
+
window manager. It lets an agent arrange your desktop: apply layouts across
|
|
5
|
+
monitors, save and relaunch workspaces, snap windows into zones, keep the screen
|
|
6
|
+
awake, and take and annotate screenshots.
|
|
7
|
+
|
|
8
|
+
> browser on the left 60%, terminal top right, notes bottom right
|
|
9
|
+
>
|
|
10
|
+
> save that as a workspace called "review"
|
|
11
|
+
>
|
|
12
|
+
> screenshot the screen and tell me what looks off
|
|
13
|
+
|
|
14
|
+
## This package is only half of it
|
|
15
|
+
|
|
16
|
+
`plonk-mcp` is a thin bridge. The app does the work, and without it every tool
|
|
17
|
+
call returns "Plonk menu bar app is not running". Install it first — macOS 13+:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
brew install --cask ostapondo/plonk/plonk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Launch it, grant Accessibility when asked, and relaunch once so macOS picks the
|
|
24
|
+
grant up. Screen Recording is asked for separately, the first time you capture.
|
|
25
|
+
|
|
26
|
+
## Then point an agent at it
|
|
27
|
+
|
|
28
|
+
Node 18+.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
claude mcp add plonk -- npx -y plonk-mcp # Claude Code
|
|
32
|
+
codex mcp add plonk -- npx -y plonk-mcp # Codex CLI
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Any MCP client works the same way — give it `npx -y plonk-mcp` as a stdio
|
|
36
|
+
server. One-pagers for
|
|
37
|
+
[Cursor](https://github.com/ostapondo/plonk/blob/main/docs/clients/cursor.md),
|
|
38
|
+
[Zed](https://github.com/ostapondo/plonk/blob/main/docs/clients/zed.md) and
|
|
39
|
+
[Cline](https://github.com/ostapondo/plonk/blob/main/docs/clients/cline.md).
|
|
40
|
+
|
|
41
|
+
A client that cannot spawn a process connects over HTTP instead:
|
|
42
|
+
`npx -y plonk-mcp --http` serves Streamable HTTP at
|
|
43
|
+
`http://127.0.0.1:43918/mcp` (loopback only, `--port` to change).
|
|
44
|
+
|
|
45
|
+
Several clients may be connected at once. Set `PLONK_AGENT_NAME` in a client's
|
|
46
|
+
config to tell two sessions of the same client apart.
|
|
47
|
+
|
|
48
|
+
## Tools
|
|
49
|
+
|
|
50
|
+
| | |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `get_state` | Monitors, every open window and where it sits, zone sets, saved workspaces, awake status |
|
|
53
|
+
| `apply_layout` · `snap_window` | Place windows by fraction of a screen, or drop one into a numbered zone |
|
|
54
|
+
| `save_workspace` · `launch_workspace` · `delete_workspace` | Named desktops that reopen their apps and restore every window |
|
|
55
|
+
| `save_zone_set` · `assign_zone_set` · `delete_zone_set` | Snap zones, assigned per monitor |
|
|
56
|
+
| `set_awake` | Keep-awake, optionally time-limited |
|
|
57
|
+
| `take_screenshot` · `annotate_screenshot` | Capture, mark up, hand the image back |
|
|
58
|
+
| `select_agent` | Make an agent the active one, optionally the only one allowed to control |
|
|
59
|
+
| `check_for_update` · `install_update` | Ask GitHub for a newer release and install it |
|
|
60
|
+
|
|
61
|
+
`save_layout`, `apply_saved_layout` and `delete_layout` are older names kept for
|
|
62
|
+
compatibility; they map onto the workspace tools.
|
|
63
|
+
|
|
64
|
+
Frames are fractions `0..1` of a monitor's visible area with the origin at the
|
|
65
|
+
top left, so the left 60% is `{x: 0, y: 0, w: 0.6, h: 1}`.
|
|
66
|
+
|
|
67
|
+
## Where things run
|
|
68
|
+
|
|
69
|
+
The server talks to the app over loopback HTTP on `127.0.0.1:43917` and nowhere
|
|
70
|
+
else. No account, no cloud, no telemetry. It depends only on the official MCP
|
|
71
|
+
SDK and zod.
|
|
72
|
+
|
|
73
|
+
MIT. Source, screenshots and the rest of the documentation are in the
|
|
74
|
+
[repository](https://github.com/ostapondo/plonk).
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
export const BASE = "http://127.0.0.1:43917";
|
|
2
2
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
3
3
|
const NOT_RUNNING = "Plonk menu bar app is not running. Ask the user to launch Plonk.app (its icon should appear in the menu bar).";
|
|
4
|
+
// Stamped on every request so the app can attribute it to a client and, in
|
|
5
|
+
// exclusive mode, gate on it. One mechanism, one shape: the identity always
|
|
6
|
+
// lives in a holder. HTTP runs each request inside its session's holder; stdio
|
|
7
|
+
// fills the process-wide one, because process.stdin exists before any context
|
|
8
|
+
// we could enter and its callbacks never inherit one. HTTP never fills that
|
|
9
|
+
// holder, so a lost context there means no identity — an unidentified client
|
|
10
|
+
// the app can reject — rather than another session's name.
|
|
11
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
|
+
const identityStore = new AsyncLocalStorage();
|
|
13
|
+
const processHolder = {};
|
|
14
|
+
/** Run fn with a per-session identity; every call() inside sees it. */
|
|
15
|
+
export function runWithIdentity(holder, fn) {
|
|
16
|
+
return identityStore.run(holder, fn);
|
|
17
|
+
}
|
|
18
|
+
/** The holder for a transport that serves a single client per process. */
|
|
19
|
+
export function processIdentityHolder() {
|
|
20
|
+
return processHolder;
|
|
21
|
+
}
|
|
22
|
+
function currentIdentity() {
|
|
23
|
+
return (identityStore.getStore() ?? processHolder).identity;
|
|
24
|
+
}
|
|
25
|
+
/** The client name this session registered with, e.g. "claude-code". */
|
|
26
|
+
export function agentIdentityName() {
|
|
27
|
+
return currentIdentity()?.name ?? "";
|
|
28
|
+
}
|
|
29
|
+
function agentHeaders() {
|
|
30
|
+
const id = currentIdentity();
|
|
31
|
+
if (!id)
|
|
32
|
+
return {};
|
|
33
|
+
return {
|
|
34
|
+
"x-plonk-agent": id.version ? `${id.name}/${id.version}` : id.name,
|
|
35
|
+
"x-plonk-agent-pid": String(id.pid),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
4
38
|
export async function call(path, options = {}) {
|
|
5
39
|
const { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
6
40
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
@@ -8,7 +42,7 @@ export async function call(path, options = {}) {
|
|
|
8
42
|
try {
|
|
9
43
|
res = await fetch(BASE + path, {
|
|
10
44
|
method,
|
|
11
|
-
headers: { "content-type": "application/json" },
|
|
45
|
+
headers: { "content-type": "application/json", ...agentHeaders() },
|
|
12
46
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
13
47
|
signal: timeout,
|
|
14
48
|
});
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Builds a fully-registered Plonk MCP server. Each connected client gets its
|
|
2
|
+
// own instance, so per-session state (clientInfo) stays separate.
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { call } from "./api.js";
|
|
6
|
+
import { register as registerState } from "./tools/state.js";
|
|
7
|
+
import { register as registerLayouts } from "./tools/layouts.js";
|
|
8
|
+
import { register as registerWorkspaces } from "./tools/workspaces.js";
|
|
9
|
+
import { register as registerZones } from "./tools/zones.js";
|
|
10
|
+
import { register as registerAwake } from "./tools/awake.js";
|
|
11
|
+
import { register as registerScreenshot } from "./tools/screenshot.js";
|
|
12
|
+
import { register as registerAnnotate } from "./tools/annotate.js";
|
|
13
|
+
import { register as registerAgents } from "./tools/agents.js";
|
|
14
|
+
import { register as registerUpdate } from "./tools/update.js";
|
|
15
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
16
|
+
export function createPlonkServer() {
|
|
17
|
+
const server = new McpServer({ name: "plonk", version });
|
|
18
|
+
registerState(server);
|
|
19
|
+
registerWorkspaces(server);
|
|
20
|
+
registerLayouts(server);
|
|
21
|
+
registerZones(server);
|
|
22
|
+
registerAwake(server);
|
|
23
|
+
registerScreenshot(server);
|
|
24
|
+
registerAnnotate(server);
|
|
25
|
+
registerAgents(server);
|
|
26
|
+
registerUpdate(server);
|
|
27
|
+
return server;
|
|
28
|
+
}
|
|
29
|
+
/** Reports the client's name and version once the MCP handshake lands.
|
|
30
|
+
* PLONK_AGENT_NAME lets the user name a session by hand ("work",
|
|
31
|
+
* "pet-project"). The initialized notification can outrun the initialize
|
|
32
|
+
* handler's bookkeeping in the SDK, leaving clientInfo briefly unset, so this
|
|
33
|
+
* polls instead of trusting the callback's timing. */
|
|
34
|
+
export function watchClientInfo(server, onKnown) {
|
|
35
|
+
const poll = (attempt = 0) => {
|
|
36
|
+
const client = server.server.getClientVersion();
|
|
37
|
+
if (!client && attempt < 50) {
|
|
38
|
+
setTimeout(() => poll(attempt + 1), 100).unref();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const name = (process.env.PLONK_AGENT_NAME || client?.name || "mcp-client").replaceAll("/", "-");
|
|
42
|
+
onKnown({ name, version: client?.version ?? "" });
|
|
43
|
+
};
|
|
44
|
+
server.server.oninitialized = () => poll();
|
|
45
|
+
}
|
|
46
|
+
/** Registers the identity with the app and keeps it marked online with a
|
|
47
|
+
* heartbeat. Returns a stop function for when the session ends. */
|
|
48
|
+
export function startHello(identity) {
|
|
49
|
+
const hello = () => call("/agents/hello", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: { name: identity.name, version: identity.version, pid: identity.pid },
|
|
52
|
+
timeoutMs: 3_000,
|
|
53
|
+
});
|
|
54
|
+
void hello();
|
|
55
|
+
const timer = setInterval(hello, 30_000);
|
|
56
|
+
timer.unref();
|
|
57
|
+
return () => clearInterval(timer);
|
|
58
|
+
}
|
|
59
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
60
|
+
/** Long-polls the app's inbox for tasks addressed to this client — the
|
|
61
|
+
* channel that lets Plonk (voice, hotkeys, other agents) reach the agent.
|
|
62
|
+
* A task is handed to the client's own model through MCP sampling, so the loop
|
|
63
|
+
* only runs for clients that declared that capability: polling drains the
|
|
64
|
+
* queue, and draining what this client cannot act on would silently throw the
|
|
65
|
+
* user's words away instead of leaving them for a CLI adapter.
|
|
66
|
+
* Returns a stop function; it is a no-op when the loop never started. */
|
|
67
|
+
export function startInboxLoop(server, identity) {
|
|
68
|
+
if (!server.server.getClientCapabilities()?.sampling) {
|
|
69
|
+
console.error(`plonk-mcp: ${identity.name} does not support MCP sampling, so Plonk cannot hand it spoken ` +
|
|
70
|
+
`or queued prompts. Configure a CLI adapter for it in Plonk (Settings, AI · MCP) to use voice.`);
|
|
71
|
+
return () => { };
|
|
72
|
+
}
|
|
73
|
+
let stopped = false;
|
|
74
|
+
const loop = async () => {
|
|
75
|
+
while (!stopped) {
|
|
76
|
+
const res = await call(`/agents/inbox?agent=${encodeURIComponent(identity.name)}&wait=25`, { timeoutMs: 30_000 });
|
|
77
|
+
if (stopped)
|
|
78
|
+
return;
|
|
79
|
+
if ("error" in res) {
|
|
80
|
+
await sleep(5_000);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (const task of res.tasks ?? []) {
|
|
84
|
+
server.server
|
|
85
|
+
.createMessage({
|
|
86
|
+
messages: [{ role: "user", content: { type: "text", text: task.prompt } }],
|
|
87
|
+
maxTokens: 4_000,
|
|
88
|
+
systemPrompt: "The user sent this through Plonk, the Mac window manager this agent controls over MCP. Act on it with the plonk tools where they apply.",
|
|
89
|
+
})
|
|
90
|
+
.catch((err) => console.error(`plonk-mcp: sampling failed for task ${task.id}:`, err));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
void loop();
|
|
95
|
+
return () => {
|
|
96
|
+
stopped = true;
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Streamable HTTP transport: one process, many clients — for anything that
|
|
2
|
+
// cannot spawn a stdio process. Binds to loopback only and carries the same
|
|
3
|
+
// threat model as the app's own API: a web page must never be able to drive
|
|
4
|
+
// the desktop, and a DNS-rebinding page must not reach the port by Host games.
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
|
+
import { runWithIdentity } from "./api.js";
|
|
9
|
+
import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } from "./factory.js";
|
|
10
|
+
// The app's registry tells sessions apart by (name, pid). Every HTTP client
|
|
11
|
+
// shares this process, so each session gets a synthetic pid instead.
|
|
12
|
+
let syntheticPid = 100_000 + (process.pid % 1_000) * 100;
|
|
13
|
+
function reject(res, status, error) {
|
|
14
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
15
|
+
res.end(JSON.stringify({ error }));
|
|
16
|
+
}
|
|
17
|
+
export async function serveHttp(port) {
|
|
18
|
+
const sessions = new Map();
|
|
19
|
+
const handle = async (req, res) => {
|
|
20
|
+
// Browsers always attach Origin to cross-site POSTs and Sec-Fetch-Site to
|
|
21
|
+
// every request, and page script cannot suppress either.
|
|
22
|
+
if (req.headers.origin !== undefined || req.headers["sec-fetch-site"] !== undefined) {
|
|
23
|
+
reject(res, 403, "requests from web pages are not accepted");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const host = (req.headers.host ?? "").toLowerCase();
|
|
27
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
28
|
+
reject(res, 403, "unexpected Host header");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (new URL(req.url ?? "/", `http://${host}`).pathname !== "/mcp") {
|
|
32
|
+
reject(res, 404, "the MCP endpoint is /mcp");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
36
|
+
const existing = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
|
|
37
|
+
if (existing) {
|
|
38
|
+
await runWithIdentity(existing.holder, () => existing.transport.handleRequest(req, res));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (req.method !== "POST") {
|
|
42
|
+
reject(res, 400, "start a session with an initialize POST first");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const session = {
|
|
46
|
+
holder: {},
|
|
47
|
+
transport: new StreamableHTTPServerTransport({
|
|
48
|
+
sessionIdGenerator: () => randomUUID(),
|
|
49
|
+
onsessioninitialized: (sid) => {
|
|
50
|
+
sessions.set(sid, session);
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
session.transport.onclose = () => {
|
|
55
|
+
session.stopHello?.();
|
|
56
|
+
session.stopInbox?.();
|
|
57
|
+
const sid = session.transport.sessionId;
|
|
58
|
+
if (sid !== undefined)
|
|
59
|
+
sessions.delete(sid);
|
|
60
|
+
};
|
|
61
|
+
const server = createPlonkServer();
|
|
62
|
+
watchClientInfo(server, ({ name, version }) => {
|
|
63
|
+
const identity = { name, version, pid: syntheticPid++ };
|
|
64
|
+
session.holder.identity = identity;
|
|
65
|
+
session.stopHello = startHello(identity);
|
|
66
|
+
session.stopInbox = startInboxLoop(server, identity);
|
|
67
|
+
});
|
|
68
|
+
await server.connect(session.transport);
|
|
69
|
+
await runWithIdentity(session.holder, () => session.transport.handleRequest(req, res));
|
|
70
|
+
};
|
|
71
|
+
const httpServer = createServer((req, res) => {
|
|
72
|
+
handle(req, res).catch((err) => {
|
|
73
|
+
console.error("plonk-mcp:", err);
|
|
74
|
+
if (!res.headersSent)
|
|
75
|
+
reject(res, 500, "internal error");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
await new Promise((resolve, rejectListen) => {
|
|
79
|
+
httpServer.once("error", rejectListen);
|
|
80
|
+
httpServer.listen(port, "127.0.0.1", resolve);
|
|
81
|
+
});
|
|
82
|
+
console.error(`plonk-mcp: Streamable HTTP at http://127.0.0.1:${port}/mcp`);
|
|
83
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Plonk MCP server — bridges AI agents to the Plonk menu bar app (localhost HTTP).
|
|
3
|
-
|
|
3
|
+
//
|
|
4
|
+
// Default transport is stdio: one process per client, spawned by it.
|
|
5
|
+
// `--http [--port N]` serves Streamable HTTP at http://127.0.0.1:<port>/mcp
|
|
6
|
+
// instead, for clients that cannot spawn a process — several at once.
|
|
4
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
8
|
+
import { BASE, isAppReachable, processIdentityHolder } from "./api.js";
|
|
9
|
+
import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } from "./factory.js";
|
|
10
|
+
import { serveHttp } from "./http.js";
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
if (args.includes("--http")) {
|
|
13
|
+
const portIndex = args.indexOf("--port");
|
|
14
|
+
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 43918;
|
|
15
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
16
|
+
console.error("plonk-mcp: --port needs a number between 1 and 65535");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
await serveHttp(port);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const holder = processIdentityHolder();
|
|
23
|
+
const server = createPlonkServer();
|
|
24
|
+
watchClientInfo(server, ({ name, version }) => {
|
|
25
|
+
const identity = { name, version, pid: process.pid };
|
|
26
|
+
holder.identity = identity;
|
|
27
|
+
startHello(identity);
|
|
28
|
+
startInboxLoop(server, identity);
|
|
29
|
+
});
|
|
30
|
+
await server.connect(new StdioServerTransport());
|
|
31
|
+
}
|
|
32
|
+
// stdout carries the stdio protocol, so this goes to stderr. Not fatal: the
|
|
33
|
+
// app may still be starting, and every tool reports the same thing on its own.
|
|
27
34
|
if (!(await isAppReachable())) {
|
|
28
35
|
console.error(`plonk-mcp: nothing is answering on ${BASE} — launch Plonk.app or its tools will fail.`);
|
|
29
36
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { agentIdentityName, call, text } from "../api.js";
|
|
3
|
+
export function register(server) {
|
|
4
|
+
server.tool("select_agent", "Make an agent the user's active one in Plonk. Omit 'name' to select this client itself; pass \"\" to clear the choice so any agent may drive. The active agent shows in Plonk's menu bar and settings, and is where voice and other outgoing requests will go. With 'exclusive' true the app also rejects window and settings changes from every other agent (they can still read state and take screenshots). Connected agents are listed in get_state under 'agents'.", {
|
|
5
|
+
name: z
|
|
6
|
+
.string()
|
|
7
|
+
.optional()
|
|
8
|
+
.describe("Agent name from get_state's 'agents'; omit for this client, \"\" to clear"),
|
|
9
|
+
exclusive: z
|
|
10
|
+
.boolean()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe("Also turn 'only the active agent controls' on or off"),
|
|
13
|
+
}, async ({ name, exclusive }) => {
|
|
14
|
+
// An unset identity would turn "select myself" into "" — the value that
|
|
15
|
+
// clears the user's choice — so refuse rather than undo their selection.
|
|
16
|
+
if (name === undefined && !agentIdentityName()) {
|
|
17
|
+
return text({
|
|
18
|
+
error: "this client has not finished registering with Plonk yet, so it cannot select itself; " +
|
|
19
|
+
"retry in a moment or pass an explicit name from get_state",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const target = name === undefined ? agentIdentityName() : name;
|
|
23
|
+
const selected = await call("/agents/select", { method: "POST", body: { name: target } });
|
|
24
|
+
if ("error" in selected || exclusive === undefined)
|
|
25
|
+
return text(selected);
|
|
26
|
+
const mode = await call("/agents/exclusive", { method: "POST", body: { on: exclusive } });
|
|
27
|
+
return text({ ...selected, ...mode });
|
|
28
|
+
});
|
|
29
|
+
}
|
package/dist/tools/layouts.js
CHANGED
|
@@ -6,16 +6,16 @@ export function register(server) {
|
|
|
6
6
|
// Saved layouts became workspaces, which also know how to launch their apps.
|
|
7
7
|
// These two stay so older clients keep working; prefer save_workspace and
|
|
8
8
|
// launch_workspace, which can carry an app's bundle id and what it opens.
|
|
9
|
-
server.tool("save_layout", "
|
|
9
|
+
server.tool("save_layout", "Save the named window arrangement as a workspace. Legacy name kept for older clients — new integrations should call save_workspace, which can also record whether running apps get moved into place. Omit 'items' to snapshot the windows exactly as they are on screen right now; pass 'items' to describe the arrangement explicitly. Saving over an existing name replaces it. Saved workspaces are listed in get_state, with their full contents.", {
|
|
10
10
|
name: z.string().describe("Workspace name, e.g. 'work', 'focus'"),
|
|
11
11
|
items: itemsSchema.optional(),
|
|
12
12
|
}, async ({ name, items }) => text(await call("/workspaces/save", { method: "POST", body: { name, items } })));
|
|
13
|
-
server.tool("apply_saved_layout", "
|
|
13
|
+
server.tool("apply_saved_layout", "Launch a saved workspace by name. Legacy name kept for older clients — new integrations should call launch_workspace, which adds a 'screen' option to pull the whole workspace onto one monitor. Opens every app that is not running, waits for its windows, and moves them into the saved positions; macOS cannot open an app straight into a position, so windows appear first and jump into place. Returns per-app success and reports apps that never opened a window. Takes up to a minute for a large workspace.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/launch", { method: "POST", body: { name }, timeoutMs: 90_000 })));
|
|
14
14
|
server.tool("snap_window", "Drop one window into a numbered zone of the snap-zone set assigned to that monitor. The numbers are the ones Plonk draws on the zones while a window is dragged, so 'the middle zone' of a three-zone set is 2. Zone sets and their per-monitor assignment are in get_state; use apply_layout instead when the user describes a size rather than a zone.", {
|
|
15
15
|
app: z.string().describe("App name to match, e.g. 'Visual Studio Code'"),
|
|
16
16
|
zone: z.number().int().min(1).describe("1-based zone number, as shown on the drag overlay"),
|
|
17
17
|
title: z.string().optional().describe("Only windows whose title contains this substring"),
|
|
18
18
|
screen: z.number().int().optional().describe("Monitor index; defaults to the one the window is on"),
|
|
19
19
|
}, async (args) => text(await call("/layout/zone", { method: "POST", body: args })));
|
|
20
|
-
server.tool("delete_layout", "
|
|
20
|
+
server.tool("delete_layout", "Delete the saved workspace with that name, whether it was saved with save_layout or save_workspace. Legacy name kept for older clients — new integrations should call delete_workspace, which does the same. Use it to clean up saved workspaces that are no longer wanted; existing names are listed in get_state.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/delete", { method: "POST", body: { name } })));
|
|
21
21
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { call, text } from "../api.js";
|
|
2
|
+
export function register(server) {
|
|
3
|
+
server.tool("check_for_update", "Ask Plonk whether a newer release exists, and report what is installed. Use this when the user asks what version they run, whether Plonk is up to date, or before calling install_update — which refuses unless a newer release is already on offer. The check is a network round trip to the GitHub releases API, so this returns immediately with the state as it stands and the result lands a moment later: read it back from get_state's 'update' key, or wait for an 'update' event on the change stream. If the user has turned update checks off, this fails with 409 rather than dialling out on their behalf — Plonk promises a process that only listens, and the user can still check by hand on its Updates page; report that back instead of retrying. Returns {installed, latest?, available, phase, status, automatic, notes?, page?}: 'available' is true only when 'latest' is newer than 'installed', 'phase' is idle|checking|available|downloading|verifying|installing|failed, and 'status' is a sentence fit to show the user.", {}, async () => text(await call("/update/check", { method: "POST" })));
|
|
4
|
+
server.tool("install_update", "Install the release that check_for_update found: Plonk downloads the build, checks it is signed with the same certificate as the running copy, swaps the bundle in, and relaunches itself. Prefer this over telling the user to download a build by hand — the signature check is also what preserves their Accessibility and Screen Recording grants, which a hand-installed copy can lose. Ask the user before calling it: it quits the app, so any window arrangement in flight stops and the local API is unreachable for a few seconds until the new copy is up. It fails without touching the installed copy when no newer release is on offer (call check_for_update first), when the user has update checks switched off (409 — installing downloads a build, so it is bound by the same promise as the check; they can install from Plonk's Updates page), when the download does not match the release or its signature, or when Plonk.app sits somewhere the user cannot write. Returns the same shape as check_for_update plus {installing: true} once the swap has started; poll get_state afterwards to confirm the new version came up.", {}, async () => text(await call("/update/install", { method: "POST" })));
|
|
5
|
+
}
|
package/dist/tools/zones.js
CHANGED
|
@@ -7,9 +7,9 @@ export function register(server) {
|
|
|
7
7
|
zones: zonesSchema,
|
|
8
8
|
screen: z.number().int().optional().describe("Monitor index to assign this set to (0 = primary)"),
|
|
9
9
|
}, async ({ name, zones, screen }) => text(await call("/zones/save", { method: "POST", body: { name, zones, screen } })));
|
|
10
|
-
server.tool("assign_zone_set", "Assign a zone set (built-in or saved) to a monitor. Omit 'name' to restore the default set (Halves); pass 'edge' for edge snapping instead of zones. Available set names and current assignments are in get_state.", {
|
|
10
|
+
server.tool("assign_zone_set", "Assign a zone set (built-in or saved) to one monitor, so dragging a window there snaps to that set's zones. Each monitor keeps its own assignment; assigning replaces whatever that monitor used before and takes effect on the next drag. Omit 'name' to restore the default set (Halves); pass 'edge' for plain edge snapping instead of zones. Available set names and current per-monitor assignments are in get_state.", {
|
|
11
11
|
screen: z.number().int().describe("Monitor index (0 = primary)"),
|
|
12
12
|
name: z.string().optional().describe("Zone set name, or 'edge' for edge snapping; omit for the default set"),
|
|
13
13
|
}, async ({ screen, name }) => text(await call("/zones/assign", { method: "POST", body: { screen, name } })));
|
|
14
|
-
server.tool("delete_zone_set", "Delete a saved zone set.
|
|
14
|
+
server.tool("delete_zone_set", "Delete a saved zone set by name. Any monitor currently using it falls back to the default set (Halves), so snapping keeps working. Only sets made with save_zone_set can go: the built-ins (Halves, Thirds, 60 / 40, Quarters, Priority) are refused. Deleting is immediate and cannot be undone — the zones would have to be described again. Saved sets and their per-monitor assignments are listed in get_state; use assign_zone_set instead when a monitor should merely stop using a set that others still need.", { name: z.string().describe("Saved zone set name, as shown in get_state") }, async ({ name }) => text(await call("/zones/delete", { method: "POST", body: { name } })));
|
|
15
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plonk-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"mcpName": "io.github.ostapondo/plonk",
|
|
5
5
|
"description": "MCP server for Plonk — the Mac window manager your AI agent can drive. Layouts, workspaces, snap zones, keep-awake and screenshots.",
|
|
6
6
|
"type": "module",
|