plonk-mcp 0.0.3 → 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 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
@@ -2,19 +2,38 @@ 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
4
  // Stamped on every request so the app can attribute it to a client and, in
5
- // exclusive mode, gate on it. Set once the MCP handshake reveals who we serve.
6
- let agentHeaders = {};
7
- let identityName = "";
8
- export function setAgentIdentity(name, version) {
9
- identityName = name;
10
- agentHeaders = {
11
- "x-plonk-agent": version ? `${name}/${version}` : name,
12
- "x-plonk-agent-pid": String(process.pid),
13
- };
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;
14
24
  }
15
- /** The client name this server registered with, e.g. "claude-code". */
25
+ /** The client name this session registered with, e.g. "claude-code". */
16
26
  export function agentIdentityName() {
17
- return identityName;
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
+ };
18
37
  }
19
38
  export async function call(path, options = {}) {
20
39
  const { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
@@ -23,7 +42,7 @@ export async function call(path, options = {}) {
23
42
  try {
24
43
  res = await fetch(BASE + path, {
25
44
  method,
26
- headers: { "content-type": "application/json", ...agentHeaders },
45
+ headers: { "content-type": "application/json", ...agentHeaders() },
27
46
  body: body !== undefined ? JSON.stringify(body) : undefined,
28
47
  signal: timeout,
29
48
  });
@@ -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,55 +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
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
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 { createRequire } from "node:module";
6
- import { BASE, call, isAppReachable, setAgentIdentity } from "./api.js";
7
- import { register as registerState } from "./tools/state.js";
8
- import { register as registerLayouts } from "./tools/layouts.js";
9
- import { register as registerWorkspaces } from "./tools/workspaces.js";
10
- import { register as registerZones } from "./tools/zones.js";
11
- import { register as registerAwake } from "./tools/awake.js";
12
- import { register as registerScreenshot } from "./tools/screenshot.js";
13
- import { register as registerAnnotate } from "./tools/annotate.js";
14
- import { register as registerAgents } from "./tools/agents.js";
15
- const { version } = createRequire(import.meta.url)("../package.json");
16
- const server = new McpServer({ name: "plonk", version });
17
- registerState(server);
18
- registerWorkspaces(server);
19
- registerLayouts(server);
20
- registerZones(server);
21
- registerAwake(server);
22
- registerScreenshot(server);
23
- registerAnnotate(server);
24
- registerAgents(server);
25
- // The handshake tells us which client we serve; PLONK_AGENT_NAME lets the user
26
- // name a session by hand ("work", "pet-project"). Registering keeps this
27
- // client on the app's agent list; the heartbeat keeps it marked online.
28
- // The initialized notification can outrun the initialize handler's bookkeeping
29
- // in the SDK, leaving clientInfo briefly unset, so poll instead of trusting
30
- // the callback's timing.
31
- function identify(attempt = 0) {
32
- const client = server.server.getClientVersion();
33
- if (!client && attempt < 50) {
34
- setTimeout(() => identify(attempt + 1), 100).unref();
35
- return;
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);
36
18
  }
37
- const agentName = (process.env.PLONK_AGENT_NAME || client?.name || "mcp-client").replaceAll("/", "-");
38
- const agentVersion = client?.version ?? "";
39
- setAgentIdentity(agentName, agentVersion);
40
- const hello = () => call("/agents/hello", {
41
- method: "POST",
42
- body: { name: agentName, version: agentVersion, pid: process.pid },
43
- timeoutMs: 3_000,
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);
44
29
  });
45
- void hello();
46
- setInterval(hello, 30_000).unref();
30
+ await server.connect(new StdioServerTransport());
47
31
  }
48
- server.server.oninitialized = () => identify();
49
- const transport = new StdioServerTransport();
50
- await server.connect(transport);
51
- // stdout carries the protocol, so this goes to stderr. Not fatal: the app may
52
- // still be starting, and every tool reports the same thing on its own.
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.
53
34
  if (!(await isAppReachable())) {
54
35
  console.error(`plonk-mcp: nothing is answering on ${BASE} — launch Plonk.app or its tools will fail.`);
55
36
  }
@@ -11,6 +11,14 @@ export function register(server) {
11
11
  .optional()
12
12
  .describe("Also turn 'only the active agent controls' on or off"),
13
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
+ }
14
22
  const target = name === undefined ? agentIdentityName() : name;
15
23
  const selected = await call("/agents/select", { method: "POST", body: { name: target } });
16
24
  if ("error" in selected || exclusive === undefined)
@@ -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
+ }
@@ -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. Monitors using it fall back to the default set. Built-in sets cannot be deleted.", { name: z.string() }, async ({ name }) => text(await call("/zones/delete", { method: "POST", body: { name } })));
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",
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",