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 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
- let res;
42
- try {
43
- res = await fetch(BASE + path, {
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: { "content-type": "application/json", ...agentHeaders() },
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.1.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",