plonk-mcp 0.1.0 → 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 +1 -23
- package/dist/http.js +25 -2
- package/package.json +3 -2
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
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
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
8
|
import { BASE, call, processIdentityHolder } from "./api.js";
|
|
9
|
+
import { options } from "./args.js";
|
|
9
10
|
const USAGE = `plonk — drive the Plonk menu bar app from a shell
|
|
10
11
|
|
|
11
12
|
plonk state [--json] screens, windows, zone sets, workspaces
|
|
@@ -21,29 +22,6 @@ const USAGE = `plonk — drive the Plonk menu bar app from a shell
|
|
|
21
22
|
plonk shot [--mode region|window|screen] [--path FILE]
|
|
22
23
|
|
|
23
24
|
Everything talks to ${BASE}; Plonk.app has to be running.`;
|
|
24
|
-
/** Pulls "--name value" out of argv and returns what is left. */
|
|
25
|
-
function options(argv) {
|
|
26
|
-
const flags = {};
|
|
27
|
-
const rest = [];
|
|
28
|
-
for (let i = 0; i < argv.length; i++) {
|
|
29
|
-
const arg = argv[i];
|
|
30
|
-
if (arg.startsWith("--")) {
|
|
31
|
-
const key = arg.slice(2);
|
|
32
|
-
const next = argv[i + 1];
|
|
33
|
-
if (next === undefined || next.startsWith("--")) {
|
|
34
|
-
flags[key] = "true";
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
flags[key] = next;
|
|
38
|
-
i++;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
rest.push(arg);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return { flags, rest };
|
|
46
|
-
}
|
|
47
25
|
/** Unwinds to the top instead of calling process.exit, which on a pipe cuts
|
|
48
26
|
* stdout off mid-write: `plonk state --json | jq` would get invalid JSON.
|
|
49
27
|
* Letting the process end on its own flushes first. */
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plonk-mcp",
|
|
3
|
-
"version": "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",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsc",
|
|
37
37
|
"prepublishOnly": "tsc",
|
|
38
|
-
"typecheck": "tsc --noEmit"
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "tsc && node --test test/*.test.js"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"@modelcontextprotocol/sdk": "^1.12.0",
|