plonk-mcp 0.0.5 → 0.2.0
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 +9 -1
- package/dist/api.js +77 -4
- package/dist/args.js +32 -0
- package/dist/cli.js +217 -0
- package/dist/factory.js +2 -0
- package/dist/http.js +25 -2
- package/dist/tools/awake.js +16 -3
- package/dist/tools/text.js +27 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -40,7 +40,10 @@ server. One-pagers for
|
|
|
40
40
|
|
|
41
41
|
A client that cannot spawn a process connects over HTTP instead:
|
|
42
42
|
`npx -y plonk-mcp --http` serves Streamable HTTP at
|
|
43
|
-
`http://127.0.0.1:43918/mcp` (loopback only, `--port` to change).
|
|
43
|
+
`http://127.0.0.1:43918/mcp` (loopback only, `--port` to change). Requests to
|
|
44
|
+
it carry the same token as the app's own API — send the contents of
|
|
45
|
+
`~/Library/Application Support/Plonk/token` as an `X-Plonk-Token` header. The
|
|
46
|
+
stdio transport reads that file itself and needs no configuration.
|
|
44
47
|
|
|
45
48
|
Several clients may be connected at once. Set `PLONK_AGENT_NAME` in a client's
|
|
46
49
|
config to tell two sessions of the same client apart.
|
|
@@ -70,5 +73,10 @@ The server talks to the app over loopback HTTP on `127.0.0.1:43917` and nowhere
|
|
|
70
73
|
else. No account, no cloud, no telemetry. It depends only on the official MCP
|
|
71
74
|
SDK and zod.
|
|
72
75
|
|
|
76
|
+
The app gates that API on a token it writes to
|
|
77
|
+
`~/Library/Application Support/Plonk/token`. This server reads the file itself,
|
|
78
|
+
so there is nothing to configure — but it does have to run as the same user
|
|
79
|
+
Plonk is running as.
|
|
80
|
+
|
|
73
81
|
MIT. Source, screenshots and the rest of the documentation are in the
|
|
74
82
|
[repository](https://github.com/ostapondo/plonk).
|
package/dist/api.js
CHANGED
|
@@ -35,17 +35,90 @@ function agentHeaders() {
|
|
|
35
35
|
"x-plonk-agent-pid": String(id.pid),
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
// The app gates its API on a secret it writes to a file only this user can
|
|
39
|
+
// read, because binding to loopback keeps the network out and does nothing
|
|
40
|
+
// about the machine. Reading it is the whole handshake: anything that can read
|
|
41
|
+
// the file could ask macOS for the screen directly.
|
|
42
|
+
import { readFileSync } from "node:fs";
|
|
43
|
+
import { homedir } from "node:os";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
const TOKEN_PATH = join(homedir(), "Library", "Application Support", "Plonk", "token");
|
|
46
|
+
let cachedToken;
|
|
47
|
+
function readTokenFile() {
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync(TOKEN_PATH, "utf8").trim() || undefined;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// No app yet, or a file this user cannot read. The request goes without a
|
|
53
|
+
// token and the app's own 401 explains it better than a guess here would.
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Only a successful read is cached: a client started before the app has ever
|
|
58
|
+
* run would otherwise never see the token the app writes on first launch. */
|
|
59
|
+
function apiToken() {
|
|
60
|
+
if (cachedToken)
|
|
61
|
+
return cachedToken;
|
|
62
|
+
cachedToken = readTokenFile();
|
|
63
|
+
return cachedToken;
|
|
64
|
+
}
|
|
65
|
+
/** The token the HTTP transport gates its own callers on, read from disk every
|
|
66
|
+
* time rather than from the cache above.
|
|
67
|
+
*
|
|
68
|
+
* A cache here would be a gate on a secret the app may already have replaced:
|
|
69
|
+
* it would keep accepting the token a restored backup leaked — the exact one
|
|
70
|
+
* the app rotated away from — and keep refusing the current one, until this
|
|
71
|
+
* process was restarted. Nothing else could fix it, because the retry that
|
|
72
|
+
* refreshes the cache lives on the far side of this check and never runs when
|
|
73
|
+
* no session gets in. It is one small read per request on a transport that
|
|
74
|
+
* handles few. */
|
|
75
|
+
export function localApiToken() {
|
|
76
|
+
const fresh = readTokenFile();
|
|
77
|
+
// Outgoing calls may as well learn about a rotation from the same read.
|
|
78
|
+
if (fresh !== undefined && fresh !== cachedToken)
|
|
79
|
+
cachedToken = fresh;
|
|
80
|
+
return fresh;
|
|
81
|
+
}
|
|
82
|
+
/** Drops the cache and reads again, true only when the file now holds
|
|
83
|
+
* something other than what this request actually sent — the one case where
|
|
84
|
+
* repeating a refused request can help.
|
|
85
|
+
*
|
|
86
|
+
* `sent` rather than the cache, because the cache is shared: the hello
|
|
87
|
+
* heartbeat and the inbox long-poll are always in flight beside a tool call,
|
|
88
|
+
* so after a rotation the first 401 refreshes the cache and every other
|
|
89
|
+
* request in the air would find it already fresh and give up, handing the
|
|
90
|
+
* model a token error for a token that had just been fixed. */
|
|
91
|
+
function refreshToken(sent) {
|
|
92
|
+
cachedToken = undefined;
|
|
93
|
+
const fresh = apiToken();
|
|
94
|
+
return fresh !== undefined && fresh !== sent;
|
|
95
|
+
}
|
|
38
96
|
export async function call(path, options = {}) {
|
|
39
97
|
const { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
40
98
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
99
|
+
// Captured per attempt, so the retry can tell "the token changed" from
|
|
100
|
+
// "somebody else refreshed the cache while this request was in the air".
|
|
101
|
+
let sent;
|
|
102
|
+
const send = () => {
|
|
103
|
+
sent = apiToken();
|
|
104
|
+
return fetch(BASE + path, {
|
|
44
105
|
method,
|
|
45
|
-
headers: {
|
|
106
|
+
headers: {
|
|
107
|
+
"content-type": "application/json",
|
|
108
|
+
...agentHeaders(),
|
|
109
|
+
...(sent ? { "x-plonk-token": sent } : {}),
|
|
110
|
+
},
|
|
46
111
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
47
112
|
signal: timeout,
|
|
48
113
|
});
|
|
114
|
+
};
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await send();
|
|
118
|
+
// A long-lived client outlives the token if the file is ever replaced.
|
|
119
|
+
// One re-read makes that a hiccup instead of a session to restart.
|
|
120
|
+
if (res.status === 401 && refreshToken(sent))
|
|
121
|
+
res = await send();
|
|
49
122
|
}
|
|
50
123
|
catch (err) {
|
|
51
124
|
if (timeout.aborted) {
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Argument parsing for the `plonk` command, kept out of cli.ts so it can be
|
|
2
|
+
// tested without running the CLI: importing cli.ts runs it.
|
|
3
|
+
/**
|
|
4
|
+
* Pulls "--name value" out of argv and returns what is left, in order.
|
|
5
|
+
*
|
|
6
|
+
* A flag with nothing usable after it — the end of argv, or another flag — is
|
|
7
|
+
* a switch and reads as "true", so `--json` and `--screen 1` parse the same
|
|
8
|
+
* way. Values are not converted here; the caller knows which of its flags are
|
|
9
|
+
* numbers and says so in the error when one is not.
|
|
10
|
+
*/
|
|
11
|
+
export function options(argv) {
|
|
12
|
+
const flags = {};
|
|
13
|
+
const rest = [];
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const arg = argv[i];
|
|
16
|
+
if (arg.startsWith("--")) {
|
|
17
|
+
const key = arg.slice(2);
|
|
18
|
+
const next = argv[i + 1];
|
|
19
|
+
if (next === undefined || next.startsWith("--")) {
|
|
20
|
+
flags[key] = "true";
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
flags[key] = next;
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
rest.push(arg);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { flags, rest };
|
|
32
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `plonk` — the same loopback API the MCP tools use, from a shell.
|
|
3
|
+
//
|
|
4
|
+
// Agents reach Plonk over MCP, and the settings window covers the rest, but
|
|
5
|
+
// neither helps a script, a Raycast command or a Makefile. This is that gap:
|
|
6
|
+
// every subcommand is one HTTP call to 127.0.0.1, and nothing here holds state.
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { BASE, call, processIdentityHolder } from "./api.js";
|
|
9
|
+
import { options } from "./args.js";
|
|
10
|
+
const USAGE = `plonk — drive the Plonk menu bar app from a shell
|
|
11
|
+
|
|
12
|
+
plonk state [--json] screens, windows, zone sets, workspaces
|
|
13
|
+
plonk snap <app> <zone> drop a window into a numbered zone
|
|
14
|
+
plonk workspaces list saved workspaces
|
|
15
|
+
plonk launch <name> [--screen N] launch one
|
|
16
|
+
plonk save <name> save the desktop as one
|
|
17
|
+
plonk zones [--screen N] <set> assign a zone set to a monitor
|
|
18
|
+
plonk awake off
|
|
19
|
+
plonk awake on [--minutes N] [--until HH:MM] [--pid N]
|
|
20
|
+
plonk awake while <command...> stay awake until that command exits
|
|
21
|
+
plonk text [--mode region|window|screen] [--path FILE]
|
|
22
|
+
plonk shot [--mode region|window|screen] [--path FILE]
|
|
23
|
+
|
|
24
|
+
Everything talks to ${BASE}; Plonk.app has to be running.`;
|
|
25
|
+
/** Unwinds to the top instead of calling process.exit, which on a pipe cuts
|
|
26
|
+
* stdout off mid-write: `plonk state --json | jq` would get invalid JSON.
|
|
27
|
+
* Letting the process end on its own flushes first. */
|
|
28
|
+
class Exit extends Error {
|
|
29
|
+
code;
|
|
30
|
+
constructor(code) {
|
|
31
|
+
super(`exit ${code}`);
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function fail(message) {
|
|
36
|
+
console.error(`plonk: ${message}`);
|
|
37
|
+
throw new Exit(1);
|
|
38
|
+
}
|
|
39
|
+
function number(raw, what) {
|
|
40
|
+
if (raw === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
const value = Number(raw);
|
|
43
|
+
if (!Number.isInteger(value))
|
|
44
|
+
fail(`${what} must be a whole number, got "${raw}"`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
/** Prints the reply and exits non-zero when the app refused. */
|
|
48
|
+
function report(result) {
|
|
49
|
+
if ("error" in result) {
|
|
50
|
+
console.error(`plonk: ${result.error}`);
|
|
51
|
+
throw new Exit(1);
|
|
52
|
+
}
|
|
53
|
+
console.log(JSON.stringify(result, null, 2));
|
|
54
|
+
throw new Exit(0);
|
|
55
|
+
}
|
|
56
|
+
async function summarize() {
|
|
57
|
+
const state = await call("/state");
|
|
58
|
+
if ("error" in state)
|
|
59
|
+
report(state);
|
|
60
|
+
const lines = [];
|
|
61
|
+
lines.push(`awake ${state.awake_details.status}`);
|
|
62
|
+
lines.push(`screens ${state.screens.length}`);
|
|
63
|
+
for (const screen of state.screens) {
|
|
64
|
+
const set = state.screen_zone_sets[String(screen.index)];
|
|
65
|
+
const zones = set === "" ? "edge snapping" : (set ?? "Halves");
|
|
66
|
+
lines.push(` ${screen.index}: ${Math.round(screen.frame.w)}x${Math.round(screen.frame.h)} ${zones}`);
|
|
67
|
+
}
|
|
68
|
+
lines.push(`workspaces ${state.saved_layouts.join(", ") || "none"}`);
|
|
69
|
+
lines.push(`zone sets ${Object.keys(state.zone_sets).sort().join(", ")}`);
|
|
70
|
+
if (state.excluded_apps?.length)
|
|
71
|
+
lines.push(`excluded ${state.excluded_apps.join(", ")}`);
|
|
72
|
+
lines.push(`windows ${state.windows.length}`);
|
|
73
|
+
for (const window of state.windows) {
|
|
74
|
+
lines.push(` ${window.app}${window.title ? ` — ${window.title}` : ""} [screen ${window.screen}]`);
|
|
75
|
+
}
|
|
76
|
+
console.log(lines.join("\n"));
|
|
77
|
+
throw new Exit(0);
|
|
78
|
+
}
|
|
79
|
+
/** Runs a command with its output passed through, holding keep-awake for
|
|
80
|
+
* exactly as long as it lives. The exit status is the command's own, so this
|
|
81
|
+
* drops into a Makefile without changing what a failure means. */
|
|
82
|
+
async function awakeWhile(argv) {
|
|
83
|
+
if (argv.length === 0)
|
|
84
|
+
fail("awake while needs a command to run");
|
|
85
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: "inherit" });
|
|
86
|
+
// Both listeners go on before the round trip below: a command that finishes
|
|
87
|
+
// inside it — `plonk awake while echo hi` — would otherwise fire exit into
|
|
88
|
+
// an empty room and hang here forever.
|
|
89
|
+
const finished = new Promise((resolve) => {
|
|
90
|
+
child.on("error", (err) => {
|
|
91
|
+
console.error(`plonk: could not run ${argv[0]}: ${err.message}`);
|
|
92
|
+
resolve(1);
|
|
93
|
+
});
|
|
94
|
+
child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 0)));
|
|
95
|
+
});
|
|
96
|
+
// No pid means the spawn failed outright. Asking for keep-awake without one
|
|
97
|
+
// would hold an assertion nothing ever releases.
|
|
98
|
+
if (child.pid !== undefined) {
|
|
99
|
+
const started = await call("/awake", { method: "POST", body: { on: true, pid: child.pid } });
|
|
100
|
+
if ("error" in started) {
|
|
101
|
+
console.error(`plonk: keep-awake not held — ${started.error}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Plonk drops the assertion itself when the process goes; waiting here only
|
|
105
|
+
// makes the shell finish at the same moment.
|
|
106
|
+
throw new Exit(await finished);
|
|
107
|
+
}
|
|
108
|
+
async function main() {
|
|
109
|
+
const argv = process.argv.slice(2);
|
|
110
|
+
// Everything after `awake while` belongs to the command being run, flags
|
|
111
|
+
// included, so it is taken verbatim rather than parsed for plonk's own.
|
|
112
|
+
// Otherwise `plonk awake while cargo build --release` builds in debug.
|
|
113
|
+
if (argv[0] === "awake" && argv[1] === "while")
|
|
114
|
+
await awakeWhile(argv.slice(2));
|
|
115
|
+
const { flags, rest } = options(argv);
|
|
116
|
+
const [command, ...args] = rest;
|
|
117
|
+
const screen = number(flags.screen, "--screen");
|
|
118
|
+
switch (command) {
|
|
119
|
+
case undefined:
|
|
120
|
+
case "help":
|
|
121
|
+
case "-h":
|
|
122
|
+
case "--help":
|
|
123
|
+
console.log(USAGE);
|
|
124
|
+
return;
|
|
125
|
+
case "ping":
|
|
126
|
+
report(await call("/ping"));
|
|
127
|
+
break;
|
|
128
|
+
case "state":
|
|
129
|
+
if (flags.json)
|
|
130
|
+
report(await call("/state"));
|
|
131
|
+
await summarize();
|
|
132
|
+
break;
|
|
133
|
+
case "snap": {
|
|
134
|
+
const [app, zone] = args;
|
|
135
|
+
if (!app || zone === undefined)
|
|
136
|
+
fail("snap needs an app and a zone number, e.g. plonk snap Safari 1");
|
|
137
|
+
report(await call("/layout/zone", {
|
|
138
|
+
method: "POST",
|
|
139
|
+
body: { app, zone: number(zone, "zone"), screen },
|
|
140
|
+
}));
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "workspaces": {
|
|
144
|
+
const state = await call("/state");
|
|
145
|
+
if ("error" in state)
|
|
146
|
+
report(state);
|
|
147
|
+
console.log(state.saved_layouts.join("\n"));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case "launch":
|
|
151
|
+
if (!args[0])
|
|
152
|
+
fail("launch needs a workspace name");
|
|
153
|
+
report(await call("/workspaces/launch", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
body: { name: args[0], screen },
|
|
156
|
+
timeoutMs: 90_000,
|
|
157
|
+
}));
|
|
158
|
+
break;
|
|
159
|
+
case "save":
|
|
160
|
+
if (!args[0])
|
|
161
|
+
fail("save needs a name for the workspace");
|
|
162
|
+
report(await call("/workspaces/save", { method: "POST", body: { name: args[0] } }));
|
|
163
|
+
break;
|
|
164
|
+
case "zones":
|
|
165
|
+
if (!args[0])
|
|
166
|
+
fail("zones needs a set name, or 'edge' for edge snapping");
|
|
167
|
+
report(await call("/zones/assign", { method: "POST", body: { screen: screen ?? 0, name: args[0] } }));
|
|
168
|
+
break;
|
|
169
|
+
case "awake": {
|
|
170
|
+
// `while` is handled before flags are parsed; anything else must be on/off.
|
|
171
|
+
if (args[0] !== "on" && args[0] !== "off")
|
|
172
|
+
fail("awake needs 'on', 'off' or 'while <command>'");
|
|
173
|
+
report(await call("/awake", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
body: {
|
|
176
|
+
on: args[0] === "on",
|
|
177
|
+
minutes: number(flags.minutes, "--minutes"),
|
|
178
|
+
until: flags.until,
|
|
179
|
+
pid: number(flags.pid, "--pid"),
|
|
180
|
+
},
|
|
181
|
+
}));
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "text": {
|
|
185
|
+
const result = await call("/shot/text", {
|
|
186
|
+
method: "POST",
|
|
187
|
+
body: { mode: flags.mode ?? "region", path: flags.path },
|
|
188
|
+
timeoutMs: 5 * 60_000,
|
|
189
|
+
});
|
|
190
|
+
if ("error" in result)
|
|
191
|
+
report(result);
|
|
192
|
+
// Bare text, so it can be piped.
|
|
193
|
+
console.log(result.text ?? "");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case "shot":
|
|
197
|
+
report(await call("/shot/capture", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
body: { mode: flags.mode ?? "region", path: flags.path },
|
|
200
|
+
timeoutMs: 5 * 60_000,
|
|
201
|
+
}));
|
|
202
|
+
break;
|
|
203
|
+
default:
|
|
204
|
+
fail(`unknown command "${command}"\n\n${USAGE}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Named so the app can attribute the calls, and so "only the active agent
|
|
208
|
+
// controls" can be pointed at the shell like anything else.
|
|
209
|
+
processIdentityHolder().identity = { name: "plonk-cli", version: "", pid: process.pid };
|
|
210
|
+
try {
|
|
211
|
+
await main();
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (!(err instanceof Exit))
|
|
215
|
+
throw err;
|
|
216
|
+
process.exitCode = err.code;
|
|
217
|
+
}
|
package/dist/factory.js
CHANGED
|
@@ -10,6 +10,7 @@ import { register as registerZones } from "./tools/zones.js";
|
|
|
10
10
|
import { register as registerAwake } from "./tools/awake.js";
|
|
11
11
|
import { register as registerScreenshot } from "./tools/screenshot.js";
|
|
12
12
|
import { register as registerAnnotate } from "./tools/annotate.js";
|
|
13
|
+
import { register as registerText } from "./tools/text.js";
|
|
13
14
|
import { register as registerAgents } from "./tools/agents.js";
|
|
14
15
|
import { register as registerUpdate } from "./tools/update.js";
|
|
15
16
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
@@ -22,6 +23,7 @@ export function createPlonkServer() {
|
|
|
22
23
|
registerAwake(server);
|
|
23
24
|
registerScreenshot(server);
|
|
24
25
|
registerAnnotate(server);
|
|
26
|
+
registerText(server);
|
|
25
27
|
registerAgents(server);
|
|
26
28
|
registerUpdate(server);
|
|
27
29
|
return server;
|
package/dist/http.js
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
// threat model as the app's own API: a web page must never be able to drive
|
|
4
4
|
// the desktop, and a DNS-rebinding page must not reach the port by Host games.
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
|
-
import { runWithIdentity } from "./api.js";
|
|
8
|
+
import { localApiToken, runWithIdentity } from "./api.js";
|
|
9
9
|
import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } 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;
|
|
13
|
+
function headerToken(req) {
|
|
14
|
+
const value = req.headers["x-plonk-token"];
|
|
15
|
+
return (Array.isArray(value) ? value[0] : value) ?? "";
|
|
16
|
+
}
|
|
17
|
+
/** Length is not the secret; which byte differed would be. */
|
|
18
|
+
function timingSafeEqualString(presented, token) {
|
|
19
|
+
const a = Buffer.from(presented, "utf8"), b = Buffer.from(token, "utf8");
|
|
20
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
21
|
+
}
|
|
13
22
|
function reject(res, status, error) {
|
|
14
23
|
res.writeHead(status, { "content-type": "application/json" });
|
|
15
24
|
res.end(JSON.stringify({ error }));
|
|
@@ -32,6 +41,20 @@ export async function serveHttp(port) {
|
|
|
32
41
|
reject(res, 404, "the MCP endpoint is /mcp");
|
|
33
42
|
return;
|
|
34
43
|
}
|
|
44
|
+
// This process holds the app's token, so without a gate of its own it is a
|
|
45
|
+
// way around the app's: anything local could call take_screenshot through
|
|
46
|
+
// it and borrow Screen Recording. Same secret, same header, same file —
|
|
47
|
+
// there is nothing extra for a client to be given.
|
|
48
|
+
const token = localApiToken();
|
|
49
|
+
if (!token) {
|
|
50
|
+
reject(res, 503, "no Plonk API token could be read, so this transport is answering nothing");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!timingSafeEqualString(headerToken(req), token)) {
|
|
54
|
+
reject(res, 401, "this request carried no valid token; send the contents of " +
|
|
55
|
+
"~/Library/Application Support/Plonk/token as the X-Plonk-Token header");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
35
58
|
const sessionId = req.headers["mcp-session-id"];
|
|
36
59
|
const existing = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
|
|
37
60
|
if (existing) {
|
package/dist/tools/awake.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { call, text } from "../api.js";
|
|
3
3
|
export function register(server) {
|
|
4
|
-
server.tool("set_awake", "Turn keep-awake on or off
|
|
4
|
+
server.tool("set_awake", "Turn keep-awake on or off, so the Mac does not sleep part-way through something. " +
|
|
5
|
+
"Three ways to end the session, in order of preference: 'pid' ends it the moment that process exits — best by far when something is running, because a build or a render knows when it is finished and nothing is left holding the machine awake afterwards; 'until' ends it at a wall-clock time ('17:00', or an ISO-8601 timestamp); 'minutes' ends it after a countdown. Give none of them and it runs until switched off. " +
|
|
6
|
+
"Behavior also follows the user's settings: keep-awake may pause on battery or engage automatically while charging, so the returned 'status' is what actually happened and 'awake' is whether an assertion is held right now. The menu bar icon glows while it is. " +
|
|
7
|
+
"A process-bound session is deliberately not restored if Plonk restarts, since the pid would mean nothing by then. Errors come back for a pid that is not running or a time that has already passed.", {
|
|
5
8
|
on: z.boolean(),
|
|
6
|
-
minutes: z.number().int().min(1).optional().describe("
|
|
7
|
-
|
|
9
|
+
minutes: z.number().int().min(1).optional().describe("End the session after this many minutes"),
|
|
10
|
+
until: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.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'"),
|
|
14
|
+
pid: z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.min(1)
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("End when this process exits. Use the pid of the long job being waited on; get_state lists a pid for every open window"),
|
|
20
|
+
}, async ({ on, minutes, until, pid }) => text(await call("/awake", { method: "POST", body: { on, minutes, until, pid } })));
|
|
8
21
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
export function register(server) {
|
|
6
|
+
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
|
+
"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. " +
|
|
8
|
+
"Use take_screenshot instead when layout, colour or 'what does this look like' is the question. " +
|
|
9
|
+
"mode 'screen' captures everything with no user interaction; 'region' and 'window' hand the user the native crosshair or window picker and wait for them, up to five minutes. Pass 'path' instead of a mode to read an image already on disk, including one take_screenshot just wrote. " +
|
|
10
|
+
"Returns 'text' (every line in reading order, top to bottom) and 'lines' — each with the recognized string, Vision's 0..1 'confidence', and 'box' {x,y,w,h} as fractions 0..1 of the image with origin at TOP-LEFT. Those boxes share the coordinate space annotate_screenshot draws in, so a line can be circled where it was found by passing the same path to that tool. The text is also copied to the clipboard unless 'clipboard' is false. " +
|
|
11
|
+
"An area with no readable text returns ok with an empty 'text' and a 'note' rather than an error.", {
|
|
12
|
+
mode: z
|
|
13
|
+
.enum(["screen", "region", "window"])
|
|
14
|
+
.default("region")
|
|
15
|
+
.describe("What to capture; ignored when 'path' is given"),
|
|
16
|
+
path: z.string().optional().describe("Read this image file instead of capturing (.png, .jpg)"),
|
|
17
|
+
clipboard: z.boolean().optional().describe("Copy the recognized text to the clipboard (default true)"),
|
|
18
|
+
languages: z
|
|
19
|
+
.array(z.string())
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("BCP-47 tags to recognize, most likely first, e.g. ['uk-UA','en-US']. Omit to use the user's configured choice. Which are available depends on the macOS version; get_state lists the current setting under 'text_languages'"),
|
|
22
|
+
}, async ({ mode, path, clipboard, languages }) => text(await call("/shot/text", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body: { mode, path, clipboard, languages },
|
|
25
|
+
timeoutMs: path || mode === "screen" ? 60_000 : INTERACTIVE_TIMEOUT_MS,
|
|
26
|
+
})));
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plonk-mcp",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -28,13 +28,15 @@
|
|
|
28
28
|
"dist"
|
|
29
29
|
],
|
|
30
30
|
"bin": {
|
|
31
|
-
"plonk-mcp": "dist/server.js"
|
|
31
|
+
"plonk-mcp": "dist/server.js",
|
|
32
|
+
"plonk": "dist/cli.js"
|
|
32
33
|
},
|
|
33
34
|
"main": "dist/server.js",
|
|
34
35
|
"scripts": {
|
|
35
36
|
"build": "tsc",
|
|
36
37
|
"prepublishOnly": "tsc",
|
|
37
|
-
"typecheck": "tsc --noEmit"
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "tsc && node --test test/*.test.js"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
|
40
42
|
"@modelcontextprotocol/sdk": "^1.12.0",
|