plonk-mcp 0.3.0 → 0.3.2

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/dist/api.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { notRunningMessage } from "./messages.js";
1
2
  export const BASE = "http://127.0.0.1:43917";
2
3
  const DEFAULT_TIMEOUT_MS = 15_000;
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
+ /** Interactive captures and the ruler wait on the user, so they get this budget instead. */
5
+ export const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
4
6
  // Stamped on every request so the app can attribute it to a client and, in
5
7
  // exclusive mode, gate on it. One mechanism, one shape: the identity always
6
8
  // lives in a holder. HTTP runs each request inside its session's holder; stdio
@@ -124,7 +126,7 @@ export async function call(path, options = {}) {
124
126
  if (timeout.aborted) {
125
127
  return { error: `Plonk did not answer within ${timeoutMs / 1000}s. It may be waiting on a dialog.` };
126
128
  }
127
- return { error: NOT_RUNNING };
129
+ return { error: notRunningMessage(agentIdentityName()) };
128
130
  }
129
131
  const text = await res.text();
130
132
  try {
package/dist/cli.js CHANGED
@@ -5,8 +5,9 @@
5
5
  // neither helps a script, a Raycast command or a Makefile. This is that gap:
6
6
  // every subcommand is one HTTP call to 127.0.0.1, and nothing here holds state.
7
7
  import { spawn } from "node:child_process";
8
- import { BASE, call, processIdentityHolder } from "./api.js";
8
+ import { BASE, call, INTERACTIVE_TIMEOUT_MS, processIdentityHolder } from "./api.js";
9
9
  import { options } from "./args.js";
10
+ import { CLI_NAME } from "./messages.js";
10
11
  const USAGE = `plonk — drive the Plonk menu bar app from a shell
11
12
 
12
13
  plonk state [--json] screens, windows, zone sets, workspaces
@@ -134,12 +135,10 @@ async function main() {
134
135
  return;
135
136
  case "ping":
136
137
  report(await call("/ping"));
137
- break;
138
138
  case "state":
139
139
  if (flags.json)
140
140
  report(await call("/state"));
141
141
  await summarize();
142
- break;
143
142
  case "snap": {
144
143
  const [app, zone] = args;
145
144
  if (!app || zone === undefined)
@@ -148,7 +147,6 @@ async function main() {
148
147
  method: "POST",
149
148
  body: { app, zone: number(zone, "zone"), screen },
150
149
  }));
151
- break;
152
150
  }
153
151
  case "workspaces": {
154
152
  const state = await call("/state");
@@ -165,17 +163,14 @@ async function main() {
165
163
  body: { name: args[0], screen },
166
164
  timeoutMs: 90_000,
167
165
  }));
168
- break;
169
166
  case "save":
170
167
  if (!args[0])
171
168
  fail("save needs a name for the workspace");
172
169
  report(await call("/workspaces/save", { method: "POST", body: { name: args[0] } }));
173
- break;
174
170
  case "zones":
175
171
  if (!args[0])
176
172
  fail("zones needs a set name, or 'edge' for edge snapping");
177
173
  report(await call("/zones/assign", { method: "POST", body: { screen: screen ?? 0, name: args[0] } }));
178
- break;
179
174
  case "awake": {
180
175
  // `while` is handled before flags are parsed; anything else must be on/off.
181
176
  if (args[0] !== "on" && args[0] !== "off")
@@ -189,7 +184,6 @@ async function main() {
189
184
  pid: number(flags.pid, "--pid"),
190
185
  },
191
186
  }));
192
- break;
193
187
  }
194
188
  case "measure": {
195
189
  // With no point named there is nobody to ask but the user, so the ruler
@@ -205,15 +199,14 @@ async function main() {
205
199
  point: { x: fraction(x, "x"), y: fraction(y, "y") },
206
200
  tolerance: number(flags.tolerance, "--tolerance"),
207
201
  },
208
- timeoutMs: interactive ? 5 * 60_000 : 30_000,
202
+ timeoutMs: interactive ? INTERACTIVE_TIMEOUT_MS : 30_000,
209
203
  }));
210
- break;
211
204
  }
212
205
  case "text": {
213
206
  const result = await call("/shot/text", {
214
207
  method: "POST",
215
208
  body: { mode: flags.mode ?? "region", path: flags.path },
216
- timeoutMs: 5 * 60_000,
209
+ timeoutMs: INTERACTIVE_TIMEOUT_MS,
217
210
  });
218
211
  if ("error" in result)
219
212
  report(result);
@@ -225,16 +218,15 @@ async function main() {
225
218
  report(await call("/shot/capture", {
226
219
  method: "POST",
227
220
  body: { mode: flags.mode ?? "region", path: flags.path },
228
- timeoutMs: 5 * 60_000,
221
+ timeoutMs: INTERACTIVE_TIMEOUT_MS,
229
222
  }));
230
- break;
231
223
  default:
232
224
  fail(`unknown command "${command}"\n\n${USAGE}`);
233
225
  }
234
226
  }
235
227
  // Named so the app can attribute the calls, and so "only the active agent
236
228
  // controls" can be pointed at the shell like anything else.
237
- processIdentityHolder().identity = { name: "plonk-cli", version: "", pid: process.pid };
229
+ processIdentityHolder().identity = { name: CLI_NAME, version: "", pid: process.pid };
238
230
  try {
239
231
  await main();
240
232
  }
package/dist/factory.js CHANGED
@@ -37,7 +37,7 @@ export function createPlonkServer() {
37
37
  * "pet-project"). The initialized notification can outrun the initialize
38
38
  * handler's bookkeeping in the SDK, leaving clientInfo briefly unset, so this
39
39
  * polls instead of trusting the callback's timing. */
40
- export function watchClientInfo(server, onKnown) {
40
+ function watchClientInfo(server, onKnown) {
41
41
  const poll = (attempt = 0) => {
42
42
  const client = server.server.getClientVersion();
43
43
  if (!client && attempt < 50) {
@@ -49,9 +49,25 @@ export function watchClientInfo(server, onKnown) {
49
49
  };
50
50
  server.server.oninitialized = () => poll();
51
51
  }
52
+ /** Once the client is known, names the holder after it and keeps the app told
53
+ * about it. Returns a stop function that ends both once they have started. */
54
+ export function bindClient(server, holder, nextPid) {
55
+ let stop = () => { };
56
+ watchClientInfo(server, ({ name, version }) => {
57
+ const identity = { name, version, pid: nextPid() };
58
+ holder.identity = identity;
59
+ const stopHello = startHello(identity);
60
+ const stopInbox = startInboxLoop(server, identity);
61
+ stop = () => {
62
+ stopHello();
63
+ stopInbox();
64
+ };
65
+ });
66
+ return () => stop();
67
+ }
52
68
  /** Registers the identity with the app and keeps it marked online with a
53
69
  * heartbeat. Returns a stop function for when the session ends. */
54
- export function startHello(identity) {
70
+ function startHello(identity) {
55
71
  const hello = () => call("/agents/hello", {
56
72
  method: "POST",
57
73
  body: { name: identity.name, version: identity.version, pid: identity.pid },
@@ -70,7 +86,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
70
86
  * queue, and draining what this client cannot act on would silently throw the
71
87
  * user's words away instead of leaving them for a CLI adapter.
72
88
  * Returns a stop function; it is a no-op when the loop never started. */
73
- export function startInboxLoop(server, identity) {
89
+ function startInboxLoop(server, identity) {
74
90
  if (!server.server.getClientCapabilities()?.sampling) {
75
91
  console.error(`plonk-mcp: ${identity.name} does not support MCP sampling, so Plonk cannot hand it spoken ` +
76
92
  `or queued prompts. Configure a CLI adapter for it in Plonk (Settings, AI · MCP) to use voice.`);
package/dist/http.js CHANGED
@@ -6,7 +6,7 @@ import { createServer } from "node:http";
6
6
  import { randomUUID, timingSafeEqual } from "node:crypto";
7
7
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
8
  import { localApiToken, runWithIdentity } from "./api.js";
9
- import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } from "./factory.js";
9
+ import { bindClient, createPlonkServer } from "./factory.js";
10
10
  // The app's registry tells sessions apart by (name, pid). Every HTTP client
11
11
  // shares this process, so each session gets a synthetic pid instead.
12
12
  let syntheticPid = 100_000 + (process.pid % 1_000) * 100;
@@ -75,19 +75,13 @@ export async function serveHttp(port) {
75
75
  }),
76
76
  };
77
77
  session.transport.onclose = () => {
78
- session.stopHello?.();
79
- session.stopInbox?.();
78
+ session.stop?.();
80
79
  const sid = session.transport.sessionId;
81
80
  if (sid !== undefined)
82
81
  sessions.delete(sid);
83
82
  };
84
83
  const server = createPlonkServer();
85
- watchClientInfo(server, ({ name, version }) => {
86
- const identity = { name, version, pid: syntheticPid++ };
87
- session.holder.identity = identity;
88
- session.stopHello = startHello(identity);
89
- session.stopInbox = startInboxLoop(server, identity);
90
- });
84
+ session.stop = bindClient(server, session.holder, () => syntheticPid++);
91
85
  await server.connect(session.transport);
92
86
  await runWithIdentity(session.holder, () => session.transport.handleRequest(req, res));
93
87
  };
@@ -0,0 +1,14 @@
1
+ // Errors the client composes itself, worded for whoever will read them.
2
+ /** The identity `plonk` registers before its first call, so a refusal can be
3
+ * addressed to a person at a prompt rather than to a model. */
4
+ export const CLI_NAME = "plonk-cli";
5
+ const NOT_RUNNING_FOR_AGENT = "Plonk menu bar app is not running. Ask the user to launch Plonk.app (its icon should appear in the menu bar).";
6
+ // A person who ran `npm i -g plonk-mcp` first has no reason to know the app is
7
+ // a separate install, so the cask is the useful half of this sentence.
8
+ const NOT_RUNNING_FOR_CLI = "Plonk.app is not running. Launch it (its icon appears in the menu bar), or install it first: brew install --cask ostapondo/plonk/plonk";
9
+ /** The refused-connection message for the client named `agent`. Same cause
10
+ * either way; the CLI's form tells the reader what to do instead of telling
11
+ * them to ask themselves. */
12
+ export function notRunningMessage(agent) {
13
+ return agent === CLI_NAME ? NOT_RUNNING_FOR_CLI : NOT_RUNNING_FOR_AGENT;
14
+ }
package/dist/schemas.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // Shared zod schemas for tool inputs.
2
2
  import { z } from "zod";
3
+ export const pointSchema = z.object({
4
+ x: z.number().min(0).max(1),
5
+ y: z.number().min(0).max(1),
6
+ });
3
7
  export const frameSchema = z.object({
4
8
  x: z.number().min(0).max(1),
5
9
  y: z.number().min(0).max(1),
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // instead, for clients that cannot spawn a process — several at once.
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { BASE, isAppReachable, processIdentityHolder } from "./api.js";
9
- import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } from "./factory.js";
9
+ import { bindClient, createPlonkServer } from "./factory.js";
10
10
  import { serveHttp } from "./http.js";
11
11
  const args = process.argv.slice(2);
12
12
  if (args.includes("--http")) {
@@ -19,14 +19,8 @@ if (args.includes("--http")) {
19
19
  await serveHttp(port);
20
20
  }
21
21
  else {
22
- const holder = processIdentityHolder();
23
22
  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
- });
23
+ bindClient(server, processIdentityHolder(), () => process.pid);
30
24
  await server.connect(new StdioServerTransport());
31
25
  }
32
26
  // stdout carries the stdio protocol, so this goes to stderr. Not fatal: the
@@ -1,9 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { call, text } from "../api.js";
3
- const pointSchema = z.object({
4
- x: z.number().min(0).max(1),
5
- y: z.number().min(0).max(1),
6
- });
3
+ import { pointSchema } from "../schemas.js";
7
4
  export function register(server) {
8
5
  server.tool("annotate_screenshot", "Draw on a screenshot you already took, then copy it to the clipboard and show it to the user. Call take_screenshot first and LOOK at the image: you cannot know where anything is until you have seen it. Points are fractions 0..1 of the image, origin TOP-LEFT, so a rectangle around a left sidebar that is a seventh of the width and starts under the title bar is [{x:0,y:0.05},{x:0.14,y:1}]. Rectangle and ellipse take two opposite corners, arrow takes start then tip, pen and highlight take a run of points. Returns the marked image so you can check what you drew.", {
9
6
  path: z.string().describe("Path returned by take_screenshot"),
@@ -1,11 +1,6 @@
1
1
  import { z } from "zod";
2
- import { call, text } from "../api.js";
3
- // The interactive mode hands the user a crosshair and waits for them.
4
- const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
5
- const pointSchema = z.object({
6
- x: z.number().min(0).max(1),
7
- y: z.number().min(0).max(1),
8
- });
2
+ import { call, INTERACTIVE_TIMEOUT_MS, text } from "../api.js";
3
+ import { pointSchema } from "../schemas.js";
9
4
  export function register(server) {
10
5
  server.tool("measure_screen", "Measure the screen in points and pixels, without taking a picture of it. Plonk photographs the screen once and walks out from the given point in all four directions until one pixel is unlike the one before it, which is where an edge is. What comes back is how far the point could travel each way: the run across and the run down. " +
11
6
  "Prefer this over take_screenshot whenever the answer is a number: how wide that sidebar is, how tall that row is, how big the gap between two things is, is that tap target 44 points. An image costs far more tokens and still has to be eyeballed. Use extract_text when the answer is words, and take_screenshot when it is 'what does this look like'. " +
@@ -1,9 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { z } from "zod";
3
- import { call, text } from "../api.js";
3
+ import { call, INTERACTIVE_TIMEOUT_MS, text } from "../api.js";
4
4
  // The interactive modes hand the user a crosshair and wait for them.
5
5
  const INTERACTIVE_MODES = new Set(["region", "window"]);
6
- const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
7
6
  // Refuse to inline anything larger; a full retina desktop is easily 10 MB,
8
7
  // which is dead weight in the conversation.
9
8
  const MAX_INLINE_BYTES = 4 << 20;
@@ -44,7 +43,7 @@ export function register(server) {
44
43
  body: { mode: wanted, app, title_contains, annotate, path, clipboard, preview: include_image !== false },
45
44
  timeoutMs: INTERACTIVE_MODES.has(wanted) ? INTERACTIVE_TIMEOUT_MS : undefined,
46
45
  });
47
- const savedPath = "path" in result && typeof result.path === "string" ? result.path : undefined;
46
+ const savedPath = "path" in result ? result.path : undefined;
48
47
  if (!savedPath || include_image === false)
49
48
  return text(result);
50
49
  const preview = "preview_path" in result && typeof result.preview_path === "string"
@@ -1,7 +1,5 @@
1
1
  import { z } from "zod";
2
- import { call, text } from "../api.js";
3
- // The interactive mode hands the user a crosshair and waits for them.
4
- const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
2
+ import { call, INTERACTIVE_TIMEOUT_MS, text } from "../api.js";
5
3
  export function register(server) {
6
4
  server.tool("extract_text", "Read the words off the screen, or off a saved image, and return them as text. Recognition runs on the Mac itself and nothing is uploaded. " +
7
5
  "Prefer this over take_screenshot whenever the answer is words rather than a picture — an error dialog, a log, a terminal, a table, text baked into an image or a paused video, a PDF page in a viewer that will not let text be selected. It costs a fraction of the tokens an image does and does not depend on reading pixels correctly. " +
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { call, text } from "../api.js";
3
3
  import { workspaceItemsSchema } from "../schemas.js";
4
- /// Launching waits for every app to open a window, which the app gives up on
5
- /// after 20 seconds per app.
4
+ // Launching waits for every app to open a window, which the app gives up on
5
+ // after 20 seconds per app.
6
6
  const LAUNCH_TIMEOUT_MS = 90_000;
7
7
  export function register(server) {
8
8
  server.tool("save_workspace", "Save a workspace: the apps of a desktop setup, where each window goes, and what each app should open. Pass 'items' to describe the arrangement, or omit them to snapshot the windows exactly as they are on screen right now. Saving over an existing name replaces it. Saved workspaces are listed in get_state, with their full contents.", {
@@ -2,11 +2,17 @@ import { z } from "zod";
2
2
  import { call, text } from "../api.js";
3
3
  import { zonesSchema } from "../schemas.js";
4
4
  export function register(server) {
5
- server.tool("save_zone_set", "Create or replace a named zone set used for drag snapping. Zones are rectangles {x,y,w,h} as fractions 0..1 of a screen's visible area, origin TOP-LEFT; each zone must stay inside the screen, but zones may overlap each other (the smallest one under the cursor wins). Pass 'screen' to also assign the set to that monitor so it becomes active immediately. Built-in sets already exist: Halves, Thirds, 60 / 40, Quarters, Priority.", {
5
+ server.tool("save_zone_set", "Create or replace a named zone set used for drag snapping. Zones are rectangles {x,y,w,h} as fractions 0..1 of a screen's visible area, origin TOP-LEFT; each zone must stay inside the screen, but zones may overlap each other (the smallest one under the cursor wins). Pass 'screen' to also assign the set to that monitor so it becomes active immediately. Pass 'gap' to give this set its own spacing around windows in points, or null to make it follow the default gap again; omitting it keeps whatever the set had. Built-in sets already exist: Halves, Thirds, 60 / 40, Quarters, Priority.", {
6
6
  name: z.string().describe("Zone set name, e.g. 'coding'"),
7
7
  zones: zonesSchema,
8
8
  screen: z.number().int().optional().describe("Monitor index to assign this set to (0 = primary)"),
9
- }, async ({ name, zones, screen }) => text(await call("/zones/save", { method: "POST", body: { name, zones, screen } })));
9
+ gap: z
10
+ .number()
11
+ .min(0)
12
+ .nullable()
13
+ .optional()
14
+ .describe("This set's own gap in points; null follows the default gap (get_state.zone_gap); omit to leave unchanged"),
15
+ }, async ({ name, zones, screen, gap }) => text(await call("/zones/save", { method: "POST", body: { name, zones, screen, gap } })));
10
16
  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
17
  screen: z.number().int().describe("Monitor index (0 = primary)"),
12
18
  name: z.string().optional().describe("Zone set name, or 'edge' for edge snapping; omit for the default set"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plonk-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "mcpName": "io.github.ostapondo/plonk",
5
5
  "description": "MCP server for Plonk, a macOS window manager: zones you draw yourself, workspaces, keep-awake, screenshots and on-device OCR, as tools an agent can call.",
6
6
  "type": "module",