plonk-mcp 0.2.5 → 0.3.1
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 +1 -0
- package/dist/api.js +4 -2
- package/dist/cli.js +32 -12
- package/dist/factory.js +23 -3
- package/dist/http.js +3 -9
- package/dist/messages.js +14 -0
- package/dist/schemas.js +4 -0
- package/dist/server.js +2 -8
- package/dist/tools/active.js +22 -0
- package/dist/tools/annotate.js +1 -4
- package/dist/tools/ruler.js +38 -0
- package/dist/tools/screenshot.js +2 -3
- package/dist/tools/text.js +1 -3
- package/dist/tools/workspaces.js +2 -2
- package/dist/tools/zones.js +8 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ config to tell two sessions of the same client apart.
|
|
|
58
58
|
| `save_workspace` · `launch_workspace` · `delete_workspace` | Named desktops that reopen their apps and restore every window |
|
|
59
59
|
| `save_zone_set` · `assign_zone_set` · `delete_zone_set` | Snap zones, assigned per monitor |
|
|
60
60
|
| `set_awake` | Keep-awake, optionally time-limited |
|
|
61
|
+
| `set_active` | Stay active, so chat apps do not show you as Away |
|
|
61
62
|
| `take_screenshot` · `annotate_screenshot` | Capture, mark up, hand the image back — `mode: "app"` photographs one named window even when it is buried, without raising it |
|
|
62
63
|
| `select_agent` | Make an agent the active one, optionally the only one allowed to control |
|
|
63
64
|
| `check_for_update` · `install_update` | Ask GitHub for a newer release and install it |
|
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
|
-
|
|
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:
|
|
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
|
|
@@ -18,6 +19,7 @@ const USAGE = `plonk — drive the Plonk menu bar app from a shell
|
|
|
18
19
|
plonk awake off
|
|
19
20
|
plonk awake on [--minutes N] [--until HH:MM] [--pid N]
|
|
20
21
|
plonk awake while <command...> stay awake until that command exits
|
|
22
|
+
plonk measure [X Y] [--screen N] [--tolerance N] measure at that point
|
|
21
23
|
plonk text [--mode region|window|screen] [--path FILE]
|
|
22
24
|
plonk shot [--mode region|window|screen] [--path FILE]
|
|
23
25
|
|
|
@@ -44,6 +46,15 @@ function number(raw, what) {
|
|
|
44
46
|
fail(`${what} must be a whole number, got "${raw}"`);
|
|
45
47
|
return value;
|
|
46
48
|
}
|
|
49
|
+
/** Points arrive as fractions of a screen, the way every frame in this API
|
|
50
|
+
* does, so a measurement can be pasted straight back into a layout. */
|
|
51
|
+
function fraction(raw, what) {
|
|
52
|
+
const value = Number(raw);
|
|
53
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
54
|
+
fail(`${what} must be a fraction between 0 and 1, got "${raw}"`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
47
58
|
/** Prints the reply and exits non-zero when the app refused. */
|
|
48
59
|
function report(result) {
|
|
49
60
|
if ("error" in result) {
|
|
@@ -124,12 +135,10 @@ async function main() {
|
|
|
124
135
|
return;
|
|
125
136
|
case "ping":
|
|
126
137
|
report(await call("/ping"));
|
|
127
|
-
break;
|
|
128
138
|
case "state":
|
|
129
139
|
if (flags.json)
|
|
130
140
|
report(await call("/state"));
|
|
131
141
|
await summarize();
|
|
132
|
-
break;
|
|
133
142
|
case "snap": {
|
|
134
143
|
const [app, zone] = args;
|
|
135
144
|
if (!app || zone === undefined)
|
|
@@ -138,7 +147,6 @@ async function main() {
|
|
|
138
147
|
method: "POST",
|
|
139
148
|
body: { app, zone: number(zone, "zone"), screen },
|
|
140
149
|
}));
|
|
141
|
-
break;
|
|
142
150
|
}
|
|
143
151
|
case "workspaces": {
|
|
144
152
|
const state = await call("/state");
|
|
@@ -155,17 +163,14 @@ async function main() {
|
|
|
155
163
|
body: { name: args[0], screen },
|
|
156
164
|
timeoutMs: 90_000,
|
|
157
165
|
}));
|
|
158
|
-
break;
|
|
159
166
|
case "save":
|
|
160
167
|
if (!args[0])
|
|
161
168
|
fail("save needs a name for the workspace");
|
|
162
169
|
report(await call("/workspaces/save", { method: "POST", body: { name: args[0] } }));
|
|
163
|
-
break;
|
|
164
170
|
case "zones":
|
|
165
171
|
if (!args[0])
|
|
166
172
|
fail("zones needs a set name, or 'edge' for edge snapping");
|
|
167
173
|
report(await call("/zones/assign", { method: "POST", body: { screen: screen ?? 0, name: args[0] } }));
|
|
168
|
-
break;
|
|
169
174
|
case "awake": {
|
|
170
175
|
// `while` is handled before flags are parsed; anything else must be on/off.
|
|
171
176
|
if (args[0] !== "on" && args[0] !== "off")
|
|
@@ -179,13 +184,29 @@ async function main() {
|
|
|
179
184
|
pid: number(flags.pid, "--pid"),
|
|
180
185
|
},
|
|
181
186
|
}));
|
|
182
|
-
|
|
187
|
+
}
|
|
188
|
+
case "measure": {
|
|
189
|
+
// With no point named there is nobody to ask but the user, so the ruler
|
|
190
|
+
// goes on screen and this waits for them.
|
|
191
|
+
const [x, y] = args;
|
|
192
|
+
const interactive = x === undefined || y === undefined;
|
|
193
|
+
report(await call("/ruler/measure", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
body: interactive
|
|
196
|
+
? { interactive: true }
|
|
197
|
+
: {
|
|
198
|
+
screen,
|
|
199
|
+
point: { x: fraction(x, "x"), y: fraction(y, "y") },
|
|
200
|
+
tolerance: number(flags.tolerance, "--tolerance"),
|
|
201
|
+
},
|
|
202
|
+
timeoutMs: interactive ? INTERACTIVE_TIMEOUT_MS : 30_000,
|
|
203
|
+
}));
|
|
183
204
|
}
|
|
184
205
|
case "text": {
|
|
185
206
|
const result = await call("/shot/text", {
|
|
186
207
|
method: "POST",
|
|
187
208
|
body: { mode: flags.mode ?? "region", path: flags.path },
|
|
188
|
-
timeoutMs:
|
|
209
|
+
timeoutMs: INTERACTIVE_TIMEOUT_MS,
|
|
189
210
|
});
|
|
190
211
|
if ("error" in result)
|
|
191
212
|
report(result);
|
|
@@ -197,16 +218,15 @@ async function main() {
|
|
|
197
218
|
report(await call("/shot/capture", {
|
|
198
219
|
method: "POST",
|
|
199
220
|
body: { mode: flags.mode ?? "region", path: flags.path },
|
|
200
|
-
timeoutMs:
|
|
221
|
+
timeoutMs: INTERACTIVE_TIMEOUT_MS,
|
|
201
222
|
}));
|
|
202
|
-
break;
|
|
203
223
|
default:
|
|
204
224
|
fail(`unknown command "${command}"\n\n${USAGE}`);
|
|
205
225
|
}
|
|
206
226
|
}
|
|
207
227
|
// Named so the app can attribute the calls, and so "only the active agent
|
|
208
228
|
// controls" can be pointed at the shell like anything else.
|
|
209
|
-
processIdentityHolder().identity = { name:
|
|
229
|
+
processIdentityHolder().identity = { name: CLI_NAME, version: "", pid: process.pid };
|
|
210
230
|
try {
|
|
211
231
|
await main();
|
|
212
232
|
}
|
package/dist/factory.js
CHANGED
|
@@ -7,10 +7,12 @@ import { register as registerState } from "./tools/state.js";
|
|
|
7
7
|
import { register as registerLayouts } from "./tools/layouts.js";
|
|
8
8
|
import { register as registerWorkspaces } from "./tools/workspaces.js";
|
|
9
9
|
import { register as registerZones } from "./tools/zones.js";
|
|
10
|
+
import { register as registerActive } from "./tools/active.js";
|
|
10
11
|
import { register as registerAwake } from "./tools/awake.js";
|
|
11
12
|
import { register as registerScreenshot } from "./tools/screenshot.js";
|
|
12
13
|
import { register as registerAnnotate } from "./tools/annotate.js";
|
|
13
14
|
import { register as registerText } from "./tools/text.js";
|
|
15
|
+
import { register as registerRuler } from "./tools/ruler.js";
|
|
14
16
|
import { register as registerAgents } from "./tools/agents.js";
|
|
15
17
|
import { register as registerUpdate } from "./tools/update.js";
|
|
16
18
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
@@ -21,9 +23,11 @@ export function createPlonkServer() {
|
|
|
21
23
|
registerLayouts(server);
|
|
22
24
|
registerZones(server);
|
|
23
25
|
registerAwake(server);
|
|
26
|
+
registerActive(server);
|
|
24
27
|
registerScreenshot(server);
|
|
25
28
|
registerAnnotate(server);
|
|
26
29
|
registerText(server);
|
|
30
|
+
registerRuler(server);
|
|
27
31
|
registerAgents(server);
|
|
28
32
|
registerUpdate(server);
|
|
29
33
|
return server;
|
|
@@ -33,7 +37,7 @@ export function createPlonkServer() {
|
|
|
33
37
|
* "pet-project"). The initialized notification can outrun the initialize
|
|
34
38
|
* handler's bookkeeping in the SDK, leaving clientInfo briefly unset, so this
|
|
35
39
|
* polls instead of trusting the callback's timing. */
|
|
36
|
-
|
|
40
|
+
function watchClientInfo(server, onKnown) {
|
|
37
41
|
const poll = (attempt = 0) => {
|
|
38
42
|
const client = server.server.getClientVersion();
|
|
39
43
|
if (!client && attempt < 50) {
|
|
@@ -45,9 +49,25 @@ export function watchClientInfo(server, onKnown) {
|
|
|
45
49
|
};
|
|
46
50
|
server.server.oninitialized = () => poll();
|
|
47
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
|
+
}
|
|
48
68
|
/** Registers the identity with the app and keeps it marked online with a
|
|
49
69
|
* heartbeat. Returns a stop function for when the session ends. */
|
|
50
|
-
|
|
70
|
+
function startHello(identity) {
|
|
51
71
|
const hello = () => call("/agents/hello", {
|
|
52
72
|
method: "POST",
|
|
53
73
|
body: { name: identity.name, version: identity.version, pid: identity.pid },
|
|
@@ -66,7 +86,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
|
66
86
|
* queue, and draining what this client cannot act on would silently throw the
|
|
67
87
|
* user's words away instead of leaving them for a CLI adapter.
|
|
68
88
|
* Returns a stop function; it is a no-op when the loop never started. */
|
|
69
|
-
|
|
89
|
+
function startInboxLoop(server, identity) {
|
|
70
90
|
if (!server.server.getClientCapabilities()?.sampling) {
|
|
71
91
|
console.error(`plonk-mcp: ${identity.name} does not support MCP sampling, so Plonk cannot hand it spoken ` +
|
|
72
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
};
|
package/dist/messages.js
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { call, text } from "../api.js";
|
|
3
|
+
export function register(server) {
|
|
4
|
+
server.tool("set_active", "Turn stay-active on or off, so chat apps go on showing the user as available instead of Away. " +
|
|
5
|
+
"Pick this over 'set_awake' by what is being protected: set_awake holds a power assertion and stops the Mac sleeping, which does nothing for a Slack or Teams status; set_active resets the system idle timer by posting a Shift keypress every two minutes, which is what those apps actually read. Resetting the idle timer also postpones sleep, so stay-active implies keep-awake and there is no need to turn both on. " +
|
|
6
|
+
"Two ways to end the session: 'until' ends it at a wall-clock time ('17:00', or an ISO-8601 timestamp); 'minutes' ends it after a countdown. Give neither and it runs until switched off, or until the user's configured default timeout expires. " +
|
|
7
|
+
"This only starts and ends sessions. The recurring schedule (hours and weekdays) and the list of apps that arm it automatically are settings on the Stay active page, not parameters here; get_state reports both under 'active_details'. " +
|
|
8
|
+
"Switching it by hand overrides the schedule until the schedule itself next changes, so turning it off during scheduled hours lasts until those hours end rather than being undone on the next tick. " +
|
|
9
|
+
"Returns 'active', whether a keypress is actually being posted right now, and 'status', what happened in words. Those differ when Plonk has no Accessibility permission (nothing can be posted) or when the user disallowed running on battery and the Mac is unplugged; neither is reported as an error, since the request was understood. An 'until' that has already passed is an error.", {
|
|
10
|
+
on: z.boolean(),
|
|
11
|
+
minutes: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.min(1)
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("End the session after this many minutes"),
|
|
17
|
+
until: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("End at a time of day, e.g. '17:00' (the next such moment — tomorrow if today's has passed), or an ISO-8601 timestamp like '2026-08-08T17:00:00Z'"),
|
|
21
|
+
}, async ({ on, minutes, until }) => text(await call("/active", { method: "POST", body: { on, minutes, until } })));
|
|
22
|
+
}
|
package/dist/tools/annotate.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { call, text } from "../api.js";
|
|
3
|
-
|
|
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"),
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { call, INTERACTIVE_TIMEOUT_MS, text } from "../api.js";
|
|
3
|
+
import { pointSchema } from "../schemas.js";
|
|
4
|
+
export function register(server) {
|
|
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. " +
|
|
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'. " +
|
|
7
|
+
"Read the result honestly: these are two independent runs through one point, not the outline of an element. Inside a plain rectangle they are its width and height; inside a gap they are the gap; on a large flat background they run until something else is in the way, which may be most of the screen. When the exact bounds of a specific element matter, pass 'interactive' and let the user point at it. " +
|
|
8
|
+
"Three ways to ask. Pass 'point' for the runs through one place. Pass 'from' and 'to' for the straight-line distance between two places, which needs no capture at all. Pass 'interactive' to hand the user the ruler and wait up to five minutes while they measure it themselves. " +
|
|
9
|
+
"Points are fractions 0..1 of the screen's visible area with origin at TOP-LEFT, the same space apply_layout and save_zone_set use, so {x:0.5,y:0.5} is the middle of the screen. " +
|
|
10
|
+
"Returns 'points' {x,y,w,h} in screen points (absolute, origin top-left of the primary display) where w is the run across and h the run down, 'pixels' {w,h} in the display's own pixels — twice the points on a Retina screen, which is the difference that matters when checking an asset — 'fraction' {x,y,w,h} of that screen's visible area ready to hand to apply_layout, 'scale', and 'text', the same line Plonk shows the user. A distance also carries 'distance' and 'distance_pixels'. " +
|
|
11
|
+
"Needs macOS Screen Recording permission, the same as a screenshot; without it the call fails rather than guessing. What is measured is a still taken when the call started, so a screen that is animating measures as it was at that moment.", {
|
|
12
|
+
point: pointSchema
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Where to measure from, as fractions 0..1 of the screen's visible area, origin TOP-LEFT. {x:0.5,y:0.5} is the middle of the screen"),
|
|
15
|
+
from: pointSchema.optional().describe("One end of a distance, in the same fractions as 'point'"),
|
|
16
|
+
to: pointSchema.optional().describe("The other end of a distance, in the same fractions as 'point'"),
|
|
17
|
+
screen: z
|
|
18
|
+
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Monitor index from get_state (0 = primary, the default). Every point is a fraction of this screen"),
|
|
22
|
+
tolerance: z
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(80)
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("How different one pixel must be from the pixel beside it, on a scale of 255, to count as an edge. Omit to use the user's setting (10 by default). Lower stops at fainter borders and finds smaller things; raise it for a photograph or video, where every pixel differs a little from the last"),
|
|
29
|
+
interactive: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Hand the user the ruler instead of measuring a given point: they hover, drag, click to copy and press Escape, and the last measurement comes back. The one to use when it is their screen and their judgement of what to measure. Waits up to five minutes"),
|
|
33
|
+
}, async ({ point, from, to, screen, tolerance, interactive }) => text(await call("/ruler/measure", {
|
|
34
|
+
method: "POST",
|
|
35
|
+
body: { point, from, to, screen, tolerance, interactive },
|
|
36
|
+
timeoutMs: interactive ? INTERACTIVE_TIMEOUT_MS : 30_000,
|
|
37
|
+
})));
|
|
38
|
+
}
|
package/dist/tools/screenshot.js
CHANGED
|
@@ -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
|
|
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"
|
package/dist/tools/text.js
CHANGED
|
@@ -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. " +
|
package/dist/tools/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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.", {
|
package/dist/tools/zones.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
47
|
-
"zod": "^
|
|
47
|
+
"zod": "^4.4.3"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^18.19.0",
|
|
51
|
-
"typescript": "^
|
|
51
|
+
"typescript": "^7.0.2"
|
|
52
52
|
}
|
|
53
53
|
}
|