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 +4 -2
- package/dist/cli.js +6 -14
- package/dist/factory.js +19 -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/annotate.js +1 -4
- package/dist/tools/ruler.js +2 -7
- 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 +1 -1
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
|
|
@@ -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 ?
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
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"),
|
package/dist/tools/ruler.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { call, text } from "../api.js";
|
|
3
|
-
|
|
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'. " +
|
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.
|
|
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",
|