plonk-mcp 0.0.2 → 0.0.3

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 CHANGED
@@ -1,6 +1,21 @@
1
1
  export const BASE = "http://127.0.0.1:43917";
2
2
  const DEFAULT_TIMEOUT_MS = 15_000;
3
3
  const NOT_RUNNING = "Plonk menu bar app is not running. Ask the user to launch Plonk.app (its icon should appear in the menu bar).";
4
+ // Stamped on every request so the app can attribute it to a client and, in
5
+ // exclusive mode, gate on it. Set once the MCP handshake reveals who we serve.
6
+ let agentHeaders = {};
7
+ let identityName = "";
8
+ export function setAgentIdentity(name, version) {
9
+ identityName = name;
10
+ agentHeaders = {
11
+ "x-plonk-agent": version ? `${name}/${version}` : name,
12
+ "x-plonk-agent-pid": String(process.pid),
13
+ };
14
+ }
15
+ /** The client name this server registered with, e.g. "claude-code". */
16
+ export function agentIdentityName() {
17
+ return identityName;
18
+ }
4
19
  export async function call(path, options = {}) {
5
20
  const { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
6
21
  const timeout = AbortSignal.timeout(timeoutMs);
@@ -8,7 +23,7 @@ export async function call(path, options = {}) {
8
23
  try {
9
24
  res = await fetch(BASE + path, {
10
25
  method,
11
- headers: { "content-type": "application/json" },
26
+ headers: { "content-type": "application/json", ...agentHeaders },
12
27
  body: body !== undefined ? JSON.stringify(body) : undefined,
13
28
  signal: timeout,
14
29
  });
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { createRequire } from "node:module";
6
- import { BASE, isAppReachable } from "./api.js";
6
+ import { BASE, call, isAppReachable, setAgentIdentity } from "./api.js";
7
7
  import { register as registerState } from "./tools/state.js";
8
8
  import { register as registerLayouts } from "./tools/layouts.js";
9
9
  import { register as registerWorkspaces } from "./tools/workspaces.js";
@@ -11,6 +11,7 @@ import { register as registerZones } from "./tools/zones.js";
11
11
  import { register as registerAwake } from "./tools/awake.js";
12
12
  import { register as registerScreenshot } from "./tools/screenshot.js";
13
13
  import { register as registerAnnotate } from "./tools/annotate.js";
14
+ import { register as registerAgents } from "./tools/agents.js";
14
15
  const { version } = createRequire(import.meta.url)("../package.json");
15
16
  const server = new McpServer({ name: "plonk", version });
16
17
  registerState(server);
@@ -20,6 +21,31 @@ registerZones(server);
20
21
  registerAwake(server);
21
22
  registerScreenshot(server);
22
23
  registerAnnotate(server);
24
+ registerAgents(server);
25
+ // The handshake tells us which client we serve; PLONK_AGENT_NAME lets the user
26
+ // name a session by hand ("work", "pet-project"). Registering keeps this
27
+ // client on the app's agent list; the heartbeat keeps it marked online.
28
+ // The initialized notification can outrun the initialize handler's bookkeeping
29
+ // in the SDK, leaving clientInfo briefly unset, so poll instead of trusting
30
+ // the callback's timing.
31
+ function identify(attempt = 0) {
32
+ const client = server.server.getClientVersion();
33
+ if (!client && attempt < 50) {
34
+ setTimeout(() => identify(attempt + 1), 100).unref();
35
+ return;
36
+ }
37
+ const agentName = (process.env.PLONK_AGENT_NAME || client?.name || "mcp-client").replaceAll("/", "-");
38
+ const agentVersion = client?.version ?? "";
39
+ setAgentIdentity(agentName, agentVersion);
40
+ const hello = () => call("/agents/hello", {
41
+ method: "POST",
42
+ body: { name: agentName, version: agentVersion, pid: process.pid },
43
+ timeoutMs: 3_000,
44
+ });
45
+ void hello();
46
+ setInterval(hello, 30_000).unref();
47
+ }
48
+ server.server.oninitialized = () => identify();
23
49
  const transport = new StdioServerTransport();
24
50
  await server.connect(transport);
25
51
  // stdout carries the protocol, so this goes to stderr. Not fatal: the app may
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import { agentIdentityName, call, text } from "../api.js";
3
+ export function register(server) {
4
+ server.tool("select_agent", "Make an agent the user's active one in Plonk. Omit 'name' to select this client itself; pass \"\" to clear the choice so any agent may drive. The active agent shows in Plonk's menu bar and settings, and is where voice and other outgoing requests will go. With 'exclusive' true the app also rejects window and settings changes from every other agent (they can still read state and take screenshots). Connected agents are listed in get_state under 'agents'.", {
5
+ name: z
6
+ .string()
7
+ .optional()
8
+ .describe("Agent name from get_state's 'agents'; omit for this client, \"\" to clear"),
9
+ exclusive: z
10
+ .boolean()
11
+ .optional()
12
+ .describe("Also turn 'only the active agent controls' on or off"),
13
+ }, async ({ name, exclusive }) => {
14
+ const target = name === undefined ? agentIdentityName() : name;
15
+ const selected = await call("/agents/select", { method: "POST", body: { name: target } });
16
+ if ("error" in selected || exclusive === undefined)
17
+ return text(selected);
18
+ const mode = await call("/agents/exclusive", { method: "POST", body: { on: exclusive } });
19
+ return text({ ...selected, ...mode });
20
+ });
21
+ }
@@ -6,16 +6,16 @@ export function register(server) {
6
6
  // Saved layouts became workspaces, which also know how to launch their apps.
7
7
  // These two stay so older clients keep working; prefer save_workspace and
8
8
  // launch_workspace, which can carry an app's bundle id and what it opens.
9
- server.tool("save_layout", "Deprecated alias of save_workspace. Saves the named arrangement as a workspace; omit 'items' to snapshot what is on screen right now.", {
9
+ server.tool("save_layout", "Save the named window arrangement as a workspace. Legacy name kept for older clients — new integrations should call save_workspace, which can also record whether running apps get moved into place. Omit 'items' to snapshot the windows exactly as they are on screen right now; pass 'items' to describe the arrangement explicitly. Saving over an existing name replaces it. Saved workspaces are listed in get_state, with their full contents.", {
10
10
  name: z.string().describe("Workspace name, e.g. 'work', 'focus'"),
11
11
  items: itemsSchema.optional(),
12
12
  }, async ({ name, items }) => text(await call("/workspaces/save", { method: "POST", body: { name, items } })));
13
- server.tool("apply_saved_layout", "Deprecated alias of launch_workspace. Launches the saved workspace of that name, opening any app that is not running.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/launch", { method: "POST", body: { name }, timeoutMs: 90_000 })));
13
+ server.tool("apply_saved_layout", "Launch a saved workspace by name. Legacy name kept for older clients — new integrations should call launch_workspace, which adds a 'screen' option to pull the whole workspace onto one monitor. Opens every app that is not running, waits for its windows, and moves them into the saved positions; macOS cannot open an app straight into a position, so windows appear first and jump into place. Returns per-app success and reports apps that never opened a window. Takes up to a minute for a large workspace.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/launch", { method: "POST", body: { name }, timeoutMs: 90_000 })));
14
14
  server.tool("snap_window", "Drop one window into a numbered zone of the snap-zone set assigned to that monitor. The numbers are the ones Plonk draws on the zones while a window is dragged, so 'the middle zone' of a three-zone set is 2. Zone sets and their per-monitor assignment are in get_state; use apply_layout instead when the user describes a size rather than a zone.", {
15
15
  app: z.string().describe("App name to match, e.g. 'Visual Studio Code'"),
16
16
  zone: z.number().int().min(1).describe("1-based zone number, as shown on the drag overlay"),
17
17
  title: z.string().optional().describe("Only windows whose title contains this substring"),
18
18
  screen: z.number().int().optional().describe("Monitor index; defaults to the one the window is on"),
19
19
  }, async (args) => text(await call("/layout/zone", { method: "POST", body: args })));
20
- server.tool("delete_layout", "Deprecated alias of delete_workspace. Deletes the saved workspace of that name.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/delete", { method: "POST", body: { name } })));
20
+ server.tool("delete_layout", "Delete the saved workspace with that name, whether it was saved with save_layout or save_workspace. Legacy name kept for older clients — new integrations should call delete_workspace, which does the same. Use it to clean up saved workspaces that are no longer wanted; existing names are listed in get_state.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/delete", { method: "POST", body: { name } })));
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plonk-mcp",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
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",