@estebanivar/vqa-mcp 0.35.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/dist/client.js ADDED
@@ -0,0 +1,145 @@
1
+ // Cherry backend client. Talks to the existing REST API
2
+ // (estebanivar-vqa-backend/src/app/api/{pins,threads}) with a minted
3
+ // extension JWT read from disk PER REQUEST — rotatable without a server
4
+ // restart. 401s surface as a friendly re-mint message, never a stack trace.
5
+ import { readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ const DEFAULT_TOKEN_FILE = join(homedir(), ".config", "estebanivar-vqa-keys", "agent-token.txt");
9
+ export class TokenError extends Error {
10
+ friendly = true;
11
+ }
12
+ export class CherryClient {
13
+ base;
14
+ tokenFile;
15
+ workspace;
16
+ constructor(opts) {
17
+ this.base = (opts?.base ?? process.env.CHERRY_BACKEND_URL ?? "http://localhost:3001").replace(/\/$/, "");
18
+ this.tokenFile = opts?.tokenFile ?? process.env.CHERRY_TOKEN_FILE ?? DEFAULT_TOKEN_FILE;
19
+ this.workspace = opts?.workspace ?? process.env.CHERRY_WORKSPACE ?? undefined;
20
+ }
21
+ readToken() {
22
+ // Direct env token wins (the CONNECT AGENT one-liner embeds it); the
23
+ // token FILE is the rotatable power-user path (re-read per request).
24
+ const envToken = process.env.CHERRY_TOKEN?.trim();
25
+ if (envToken)
26
+ return envToken;
27
+ let raw;
28
+ try {
29
+ raw = readFileSync(this.tokenFile, "utf-8");
30
+ }
31
+ catch {
32
+ throw new TokenError(`No Cherry agent token: set CHERRY_TOKEN, or write one to ${this.tokenFile}. ` +
33
+ `Get a fresh token from the Cherry extension popup → CONNECT AGENT.`);
34
+ }
35
+ const token = raw.trim();
36
+ if (!token) {
37
+ throw new TokenError(`Cherry agent token file ${this.tokenFile} is empty — re-mint via the popup's CONNECT AGENT.`);
38
+ }
39
+ return token;
40
+ }
41
+ url(path, params) {
42
+ const u = new URL(this.base + path);
43
+ if (this.workspace)
44
+ u.searchParams.set("workspace", this.workspace);
45
+ for (const [k, v] of Object.entries(params ?? {})) {
46
+ if (v !== undefined)
47
+ u.searchParams.set(k, v);
48
+ }
49
+ return u.toString();
50
+ }
51
+ async request(path, init, params) {
52
+ const token = this.readToken();
53
+ const res = await fetch(this.url(path, params), {
54
+ ...init,
55
+ headers: {
56
+ Authorization: `Bearer ${token}`,
57
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
58
+ ...(init?.headers ?? {}),
59
+ },
60
+ });
61
+ if (res.status === 401) {
62
+ const viaEnv = Boolean(process.env.CHERRY_TOKEN?.trim());
63
+ throw new TokenError(viaEnv
64
+ ? "Cherry backend returned 401 — the CHERRY_TOKEN in your MCP config expired or is invalid. " +
65
+ "Re-copy the setup from the Cherry extension popup → CONNECT AGENT and re-run it."
66
+ : `Cherry backend returned 401 — the token at ${this.tokenFile} expired or is invalid. ` +
67
+ "Get a fresh one from the Cherry extension popup → CONNECT AGENT and write it there.");
68
+ }
69
+ if (!res.ok) {
70
+ const body = await res.text().catch(() => "");
71
+ throw new Error(`Cherry backend ${init?.method ?? "GET"} ${path} failed: ${res.status} ${body.slice(0, 300)}`);
72
+ }
73
+ return (await res.json());
74
+ }
75
+ /** Presence heartbeat. The backend stamps `agentLastSeen` ONLY on
76
+ * GET /api/pins?kind=prompt for an agent actor (backend pins/route.ts:80 —
77
+ * `kind === "prompt"` is required; kind=pin/all do NOT stamp). That stamp is
78
+ * what keeps the extension's INSPECT panel "agent connected" (5-min window).
79
+ * So ping MUST use kind:"prompt". The return value is ignored — listing the
80
+ * prompt queue is read-only (no drain), so this is a safe idempotent poke.
81
+ * Swallows transient errors so a blip never kills the keepalive loop. */
82
+ async ping() {
83
+ try {
84
+ await this.listPins({ kind: "prompt" });
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ /** All pins in the workspace (optionally origin-scoped). Backend GET /api/pins is workspace-wide
92
+ * already; `kind` opts into prompt rows (agent mail from INSPECT SEND) or "all" — the backend's
93
+ * default listing returns regular pins only, which is also what keeps prompts invisible to the
94
+ * overlay UI. */
95
+ async listPins(opts) {
96
+ const data = await this.request("/api/pins", undefined, {
97
+ origin: opts?.origin,
98
+ kind: opts?.kind,
99
+ });
100
+ return data.pins ?? [];
101
+ }
102
+ /** Single pin by id. The backend has no GET /api/pins/:id — list + find across BOTH kinds
103
+ * (workspace-scoped, fine at Phase-1 volume). */
104
+ async getPin(pinId) {
105
+ const pins = await this.listPins({ kind: "all" });
106
+ return pins.find((p) => p.id === pinId) ?? null;
107
+ }
108
+ /** Create a REGION-anchored pin (agent-authored comment). The backend
109
+ * wants anchor.rect as a [x, y, w, h] tuple in viewport coords; origin
110
+ * and route arrive pre-split (origin = scheme+host+port, route = path).
111
+ * kind defaults to "pin" server-side — i.e. VISIBLE in the overlay,
112
+ * which is the point of an agent comment. */
113
+ async createPin(opts) {
114
+ const data = await this.request("/api/pins", {
115
+ method: "POST",
116
+ body: JSON.stringify({
117
+ anchor: { kind: "region", rect: opts.rect },
118
+ text: opts.text,
119
+ origin: opts.origin,
120
+ route: opts.route,
121
+ viewport: opts.viewport,
122
+ }),
123
+ });
124
+ return data.pin;
125
+ }
126
+ /** Append a thread reply. Author = the token's user; the thread shows who wrote it. */
127
+ async reply(pinId, text) {
128
+ const data = await this.request(`/api/pins/${encodeURIComponent(pinId)}/thread`, { method: "POST", body: JSON.stringify({ text }) });
129
+ return data.pin;
130
+ }
131
+ /** Status-only PATCH — triage is allowed for any workspace member. */
132
+ async setStatus(pinId, status) {
133
+ const data = await this.request(`/api/pins/${encodeURIComponent(pinId)}`, { method: "PATCH", body: JSON.stringify({ status }) });
134
+ return data.pin;
135
+ }
136
+ /** Fetch a screenshot (Vercel Blob URL — public) as base64 for MCP image content. */
137
+ async fetchImage(url) {
138
+ const res = await fetch(url);
139
+ if (!res.ok)
140
+ throw new Error(`screenshot fetch failed: ${res.status} for ${url}`);
141
+ const mimeType = res.headers.get("content-type")?.split(";")[0] || "image/png";
142
+ const buf = Buffer.from(await res.arrayBuffer());
143
+ return { data: buf.toString("base64"), mimeType };
144
+ }
145
+ }
@@ -0,0 +1,53 @@
1
+ // The drain protocol, shipped INSIDE the server as an MCP prompt so agents
2
+ // get it with zero skill/config installs (Claude Code surfaces MCP prompts
3
+ // as slash commands — /cherry:drain). Version-locks the protocol to the
4
+ // package. Mirrors Esteban's local /cherry skill minus the machine-specific
5
+ // repos.json map (unknown origins are handled conversationally instead).
6
+ export function drainPromptText(origin) {
7
+ const repos = process.env.CHERRY_REPOS;
8
+ const repoMap = repos
9
+ ? `\nKnown origin → repo map (from CHERRY_REPOS):\n${repos}\n`
10
+ : "";
11
+ return `Drain the Cherry change-request queue ONCE, then stop.
12
+
13
+ Cherry is a visual-QA browser extension: the user hovers an element on their
14
+ page, types a change request ("wrong icon", "don't round this border"), and
15
+ hits SEND. Those requests are waiting in the queue for you now.
16
+
17
+ ## Protocol (drain-once — never wait, poll, or re-check afterward)
18
+
19
+ 1. Call cherry_pending${origin ? ` with origin "${origin}"` : ""} ONCE. It
20
+ returns kind="prompt" items (invisible agent mail from INSPECT SEND).
21
+ Empty → say "queue empty". If the user asked you to also handle their
22
+ VISIBLE annotation pins (or the empty result surprises them), call it one
23
+ more time with kind: "all" — that is the only permitted second call.
24
+ 2. Per request, oldest first:
25
+ - cherry_get for the full packet (the user's words, the component's
26
+ identity, file:line when the page was a dev build, a durable selector,
27
+ and computed specs in design-token vocabulary).
28
+ - cherry_screenshot ONLY if the text is ambiguous without seeing it.
29
+ - Work out which LOCAL REPO the page belongs to.${repoMap}
30
+ If the origin isn't known to you, ASK the user ("which local repo is
31
+ http://localhost:3000?") and REMEMBER the answer somewhere durable you
32
+ own (CLAUDE.md, AGENTS.md, your memory) so you never ask twice.
33
+ - Locate the code: file:line hint first; otherwise hunt by component
34
+ name / design-system identity / selector / the specs' class names.
35
+ - Apply the change.
36
+ - cherry_reply with a 1-3 line summary of the ACTUAL diff (file + what
37
+ changed). If the app's dev server does NOT hot-reload (plain node/tsx
38
+ servers), say so in the reply ("restart the dev server to see it") —
39
+ a correct but invisible fix reads as a failed fix.
40
+ - cherry_resolve.
41
+ - Ambiguous or out-of-scope request → cherry_reply asking the clarifying
42
+ question, do NOT resolve, move on.
43
+ 3. Final report: N resolved, M replied-pending, files touched per repo.
44
+ 4. STOP. No waiting, no polling, no scheduling. The queue is drained when
45
+ this list is empty; the user will invoke you again when there's more.
46
+
47
+ Failure rules: a 401/token error from any cherry tool → surface its message
48
+ to the user and STOP (never retry in a loop). A file:line that doesn't match
49
+ current code means a stale dev-build hint — fall back to component/selector
50
+ hunting rather than trusting the line. If your change would collide with
51
+ uncommitted local edits in that file, reply on the request describing the
52
+ conflict and leave it open instead of resolving.`;
53
+ }
package/dist/index.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // cherry-qa MCP server — stdio, drain-once. The queue is the pin store
3
+ // (Cherry backend / Neon); a Claude session connects, drains, disconnects.
4
+ // No HTTP listener, no sessions, no watching (Phase-1 spec: async only).
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { z } from "zod";
8
+ import { CherryClient } from "./client.js";
9
+ import { registerTools } from "./tools.js";
10
+ import { drainPromptText } from "./drain-prompt.js";
11
+ const server = new McpServer({ name: "cherry-qa", version: "0.35.0" });
12
+ const client = new CherryClient();
13
+ registerTools(server, client);
14
+ // Presence heartbeat. This stdio server lives for the whole Claude session, so
15
+ // while it's up we keep the extension's INSPECT panel showing "agent connected"
16
+ // (backend's 5-min agentLastSeen window). Without this, presence only refreshes
17
+ // when a cherry_* tool happens to run, so the panel blinks to "NO AGENT" whenever
18
+ // the session sits idle. Every 2 min beats the 5-min window with margin. The
19
+ // first beat fires immediately so the panel flips within one of its 60s polls.
20
+ // unref() so the loop never keeps the process alive on its own.
21
+ const HEARTBEAT_MS = 2 * 60 * 1000;
22
+ void client.ping();
23
+ const heartbeat = setInterval(() => void client.ping(), HEARTBEAT_MS);
24
+ if (typeof heartbeat.unref === "function")
25
+ heartbeat.unref();
26
+ // The drain protocol ships with the server: agents surface MCP prompts as
27
+ // commands (Claude Code → /cherry:drain), so users need no skill installs.
28
+ server.prompt("drain", "Drain the Cherry change-request queue once — apply each described change to source, reply with the diff, resolve. Stops when empty; never polls.", { origin: z.string().optional().describe("Scope to one page origin, e.g. http://localhost:3000") }, ({ origin }) => ({
29
+ messages: [
30
+ { role: "user", content: { type: "text", text: drainPromptText(origin) } },
31
+ ],
32
+ }));
33
+ const transport = new StdioServerTransport();
34
+ await server.connect(transport);
35
+ console.error("[cherry-qa] stdio server ready");
package/dist/packet.js ADDED
@@ -0,0 +1,69 @@
1
+ // The agent packet — one markdown block per pin (spec §2), optimized so the
2
+ // receiving session needs near-zero exploration: request thread, element
3
+ // identity + file:line, durable selectors, page URL, token-first specs, and
4
+ // the screenshot pointer.
5
+ import { formatSpecs } from "./specs-format.js";
6
+ function pageUrl(pin) {
7
+ const origin = pin.origin ?? "";
8
+ return origin ? `${origin}${pin.route}` : pin.route;
9
+ }
10
+ // SEND-created pins carry the typed text as the pin's own `text` and/or the
11
+ // first thread comment. Render pin.text first (when set), then the thread
12
+ // oldest→newest, skipping a first comment that merely echoes pin.text.
13
+ function requestLines(pin) {
14
+ const lines = [];
15
+ const pinText = (pin.text ?? "").trim();
16
+ if (pinText)
17
+ lines.push(`"${pinText}" — ${pin.author}, ${pin.createdAt}`);
18
+ (pin.thread ?? []).forEach((entry, i) => {
19
+ const text = (entry.text ?? "").trim();
20
+ if (!text)
21
+ return;
22
+ if (i === 0 && pinText && text === pinText)
23
+ return; // SEND echo
24
+ lines.push(`"${text}" — ${entry.by}, ${entry.at}`);
25
+ });
26
+ if (lines.length === 0)
27
+ lines.push("(no request text — see screenshot)");
28
+ return lines;
29
+ }
30
+ export function buildPacket(pin) {
31
+ const lines = [];
32
+ lines.push(`## Change request ${pin.id}`);
33
+ lines.push("");
34
+ lines.push(...requestLines(pin));
35
+ lines.push("");
36
+ const c = pin.component;
37
+ if (c) {
38
+ const src = c.source ? ` (${c.source})` : "";
39
+ const loc = c.file ? ` — ${c.file}${c.line ? `:${c.line}` : ""}` : "";
40
+ const variant = c.variant ? ` variant="${c.variant}"` : "";
41
+ lines.push(`**Element:** ${c.dsLabel ?? c.name}${variant}${src}${loc}`);
42
+ }
43
+ else {
44
+ lines.push(`**Element:** (no component binding)`);
45
+ }
46
+ const selector = c?.selector ?? pin.anchor?.selector;
47
+ const fallback = c?.fallbackSelector ?? pin.anchor?.fallbackSelector;
48
+ if (selector) {
49
+ lines.push(`**Selector:** ${selector}${fallback && fallback !== selector ? ` (fallback: ${fallback})` : ""}`);
50
+ }
51
+ else if (pin.anchor?.kind === "region" && pin.anchor.rect) {
52
+ lines.push(`**Region:** page rect [${pin.anchor.rect.join(", ")}]`);
53
+ }
54
+ lines.push(`**Page:** ${pageUrl(pin)} (route ${pin.route})`);
55
+ lines.push(`**Status:** ${pin.status}${pin.priority ? ` · ${pin.priority}` : ""} · created ${pin.createdAt}`);
56
+ lines.push("");
57
+ const specsText = formatSpecs(c?.specs);
58
+ if (specsText) {
59
+ lines.push(`**Specs (token-first):**`);
60
+ lines.push(specsText);
61
+ }
62
+ else {
63
+ lines.push(`**Specs:** none captured (older pin — inspect the live element if needed)`);
64
+ }
65
+ lines.push("");
66
+ const hasImage = Boolean(pin.screenshot) || Boolean(pin.shots && pin.shots.length > 0);
67
+ lines.push(hasImage ? `**Screenshot:** cherry_screenshot ${pin.id}` : `**Screenshot:** none`);
68
+ return lines.join("\n");
69
+ }
@@ -0,0 +1,126 @@
1
+ // Token-first ComponentSpecs serialization for the agent packet.
2
+ // Mirrors the InspectOverlay value grammar (packages/vqa-react/src/
3
+ // InspectOverlay.tsx: colorText / fmtPaddingHud): a value that matches a
4
+ // page-exposed custom property leads with the raw value and IDENTIFIES as
5
+ // the token — `8px = var(--radius-mm-md)` — because "what is this in my
6
+ // system's vocabulary" is the question. Near-misses whisper drift:
7
+ // `7px (≈ --radius-mm-md 8px)`. Off-token values stay plain. Pure —
8
+ // no DOM, no IO.
9
+ /** `8px = var(--radius-mm-md)` exact · `7px (≈ --radius-mm-md 8px)` near-miss · `7px` plain */
10
+ export function lengthValue(l) {
11
+ const px = `${l.px}px`;
12
+ if (l.token)
13
+ return `${px} = var(${l.token})`;
14
+ if (l.nearest)
15
+ return `${px} (≈ ${l.nearest.token} ${l.nearest.px}px)`;
16
+ return px;
17
+ }
18
+ /** `#fafafa = var(--color-mm-fg)` exact · `#fafafa` plain · alpha noted as `@0.5` */
19
+ export function colorValue(c) {
20
+ const alpha = c.alpha !== undefined && c.alpha < 1 ? ` @${c.alpha}` : "";
21
+ if (c.token)
22
+ return `${c.hex}${alpha} = var(${c.token})`;
23
+ return `${c.hex}${alpha}`;
24
+ }
25
+ // Collapsed box edges, InspectOverlay's fmtPaddingHud rule: "12" uniform,
26
+ // "8·16" v/h pairs, else all four.
27
+ function edgesText([t, r, b, l]) {
28
+ if (t === r && r === b && b === l)
29
+ return String(t);
30
+ if (t === b && r === l)
31
+ return `${t}·${r}`;
32
+ return `${t}·${r}·${b}·${l}`;
33
+ }
34
+ function radiusValue(radius) {
35
+ // Uniform px radii carry token identification; per-corner stays text.
36
+ if (radius.px !== undefined) {
37
+ return lengthValue({ px: radius.px, token: radius.token, nearest: radius.nearest });
38
+ }
39
+ return radius.text;
40
+ }
41
+ /**
42
+ * Serialize a pin's stored ComponentSpecs into compact token-first lines.
43
+ * Absent specs (older pins) → null; callers render a graceful placeholder.
44
+ */
45
+ export function formatSpecs(specs) {
46
+ if (!specs || typeof specs !== "object")
47
+ return null;
48
+ const lines = [];
49
+ const box = specs.box;
50
+ if (box) {
51
+ // Every field individually guarded — shapes are intentionally loose
52
+ // (older pins predate several fields) and a partial box must degrade,
53
+ // not crash cherry_get (review 2026-07-06 #2).
54
+ const parts = [];
55
+ if (typeof box.width === "number" && typeof box.height === "number") {
56
+ parts.push(`size ${box.width}×${box.height}`);
57
+ }
58
+ if (box.radius)
59
+ parts.push(`radius ${radiusValue(box.radius)}`);
60
+ if (Array.isArray(box.padding))
61
+ parts.push(`padding ${edgesText(box.padding)}`);
62
+ if (Array.isArray(box.margin)) {
63
+ const margin = edgesText(box.margin);
64
+ if (margin !== "0")
65
+ parts.push(`margin ${margin}`);
66
+ }
67
+ if (box.border)
68
+ parts.push(`border ${box.border}`);
69
+ if (parts.length)
70
+ lines.push(parts.join(" · "));
71
+ }
72
+ const t = specs.typography;
73
+ if (t) {
74
+ const parts = [`font ${t.family} ${t.sizeText || `${t.sizePx}px`}/${t.weight}`, `line-height ${t.lineHeight}`];
75
+ if (t.letterSpacing)
76
+ parts.push(`tracking ${t.letterSpacing}`);
77
+ lines.push(parts.join(" · "));
78
+ }
79
+ const c = specs.color;
80
+ if (c && (c.text || c.background)) {
81
+ const parts = [];
82
+ if (c.text)
83
+ parts.push(`text ${colorValue(c.text)}`);
84
+ if (c.background)
85
+ parts.push(`${c.text ? "on " : "bg "}${colorValue(c.background)}`);
86
+ let line = parts.join(" ");
87
+ if (specs.contrast) {
88
+ line += ` · contrast ${specs.contrast.ratio}:1 ${specs.contrast.aa ? "AA ✓" : specs.contrast.aaLarge ? "AA-large only" : "AA ✗"}`;
89
+ }
90
+ lines.push(line);
91
+ }
92
+ const l = specs.layout;
93
+ if (l) {
94
+ const parts = [l.display];
95
+ if (l.direction)
96
+ parts.push(l.direction);
97
+ if (l.justify && l.justify !== "normal")
98
+ parts.push(`justify ${l.justify}`);
99
+ if (l.align && l.align !== "normal")
100
+ parts.push(`align ${l.align}`);
101
+ if (l.gap)
102
+ parts.push(`gap ${lengthValue(l.gap)}`);
103
+ lines.push(parts.join(" · "));
104
+ }
105
+ const e = specs.effects;
106
+ if (e) {
107
+ const parts = [];
108
+ if (e.opacity !== undefined && e.opacity !== 1)
109
+ parts.push(`opacity ${e.opacity}`);
110
+ if (e.shadow)
111
+ parts.push(`shadow ${e.shadow}`);
112
+ if (e.transform)
113
+ parts.push(`transform ${e.transform}`);
114
+ if (e.transition)
115
+ parts.push(`transition ${e.transition}`);
116
+ if (e.backgroundImage)
117
+ parts.push(`bg-image ${e.backgroundImage}`);
118
+ if (parts.length)
119
+ lines.push(parts.join(" · "));
120
+ }
121
+ const img = specs.img;
122
+ if (img) {
123
+ lines.push(`img ${img.src} · natural ${img.naturalW}×${img.naturalH} · rendered ${img.renderedW}×${img.renderedH}${img.alt ? ` · alt "${img.alt}"` : ""}`);
124
+ }
125
+ return lines.length ? lines.join("\n") : null;
126
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,154 @@
1
+ // The six cherry-qa tools — drain-once bridge over the pin store. Every
2
+ // tool is a single request/response against the live backend; nothing here
3
+ // watches, polls, or schedules anything.
4
+ import { z } from "zod";
5
+ import { TokenError } from "./client.js";
6
+ import { buildPacket } from "./packet.js";
7
+ function text(s) {
8
+ return { content: [{ type: "text", text: s }] };
9
+ }
10
+ // Friendly error surface: token problems return their message verbatim
11
+ // (never a stack trace); everything else keeps enough detail to debug.
12
+ function errText(e) {
13
+ if (e instanceof TokenError)
14
+ return text(e.message);
15
+ return text(`cherry-qa error: ${e instanceof Error ? e.message : String(e)}`);
16
+ }
17
+ function firstCommentText(p) {
18
+ const own = (p.text ?? "").trim();
19
+ if (own)
20
+ return own;
21
+ return (p.thread?.[0]?.text ?? "").trim();
22
+ }
23
+ export function registerTools(server, client) {
24
+ server.tool("cherry_pending", "List OPEN Cherry change requests (oldest first). Defaults to kind=prompt — the invisible agent-mail queue from INSPECT SEND (no UI pins). Pass kind=pin for regular annotation pins or kind=all for both. Optionally scope to one origin (e.g. http://localhost:6010).", {
25
+ origin: z.string().optional().describe("Scheme+host+port to scope to; omit for the whole workspace"),
26
+ kind: z
27
+ .enum(["prompt", "pin", "all"])
28
+ .optional()
29
+ .describe("prompt (default) = agent mail from INSPECT SEND; pin = visible annotation pins; all = both"),
30
+ }, async (args) => {
31
+ try {
32
+ const kind = args.kind ?? "prompt";
33
+ const pins = await client.listPins({ origin: args.origin, kind });
34
+ const open = pins
35
+ .filter((p) => p.status === "open")
36
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
37
+ if (open.length === 0)
38
+ return text(kind === "prompt" ? "Queue empty — no pending prompts." : "Queue empty — no open Cherry pins.");
39
+ const items = open.map((p) => ({
40
+ id: p.id,
41
+ text: firstCommentText(p) || "(no text)",
42
+ component: p.component
43
+ ? `${p.component.dsLabel ?? p.component.name}${p.component.file ? ` — ${p.component.file}${p.component.line ? `:${p.component.line}` : ""}` : ""}`
44
+ : undefined,
45
+ page: `${p.origin ?? ""}${p.route}`,
46
+ route: p.route,
47
+ createdAt: p.createdAt,
48
+ hasSpecs: Boolean(p.component?.specs),
49
+ }));
50
+ return text(JSON.stringify({ count: items.length, pins: items }, null, 2));
51
+ }
52
+ catch (e) {
53
+ return errText(e);
54
+ }
55
+ });
56
+ server.tool("cherry_get", "Get the full agent packet for one pin: request thread, element identity + file:line, selectors, page URL, token-first specs, screenshot pointer.", { pinId: z.string() }, async ({ pinId }) => {
57
+ try {
58
+ const pin = await client.getPin(pinId);
59
+ if (!pin)
60
+ return text(`No pin ${pinId} in this workspace.`);
61
+ return text(buildPacket(pin));
62
+ }
63
+ catch (e) {
64
+ return errText(e);
65
+ }
66
+ });
67
+ server.tool("cherry_screenshot", "Fetch a pin's screenshot as an image (element auto-capture first, region shots after).", { pinId: z.string() }, async ({ pinId }) => {
68
+ try {
69
+ const pin = await client.getPin(pinId);
70
+ if (!pin)
71
+ return text(`No pin ${pinId} in this workspace.`);
72
+ const urls = [];
73
+ if (pin.screenshot)
74
+ urls.push(pin.screenshot);
75
+ for (const s of pin.shots ?? [])
76
+ urls.push(s.url);
77
+ if (urls.length === 0)
78
+ return text(`Pin ${pinId} has no screenshot.`);
79
+ const images = await Promise.all(urls.map((u) => client.fetchImage(u)));
80
+ return {
81
+ content: images.map((img) => ({
82
+ type: "image",
83
+ data: img.data,
84
+ mimeType: img.mimeType,
85
+ })),
86
+ };
87
+ }
88
+ catch (e) {
89
+ return errText(e);
90
+ }
91
+ });
92
+ server.tool("cherry_comment", "CREATE a comment pin on a page (agent-authored, region-anchored) — it appears in the Cherry overlay as a numbered marker + thread, like a teammate's annotation. Coordinates are viewport px on that page; open the page in a browser first to measure. Author = the token's user.", {
93
+ url: z.string().url().describe("Full page URL the comment belongs to (origin+path are derived from it)"),
94
+ rect: z
95
+ .object({
96
+ x: z.number().finite(),
97
+ y: z.number().finite(),
98
+ width: z.number().finite().positive(),
99
+ height: z.number().finite().positive(),
100
+ })
101
+ .describe("Viewport-px region the comment points at"),
102
+ text: z.string().min(1).describe("The comment body"),
103
+ viewport: z
104
+ .object({ w: z.number().int().positive(), h: z.number().int().positive() })
105
+ .optional()
106
+ .describe("Viewport size the rect was measured in (defaults to 1440×820 if omitted)"),
107
+ }, async (args) => {
108
+ try {
109
+ const u = new URL(args.url);
110
+ const { x, y, width, height } = args.rect;
111
+ if (x < 0 || y < 0)
112
+ return text("cherry_comment: rect x/y must be ≥ 0 (viewport coordinates).");
113
+ const pin = await client.createPin({
114
+ origin: u.origin,
115
+ route: u.pathname + u.search,
116
+ rect: [x, y, width, height],
117
+ text: args.text,
118
+ viewport: args.viewport ?? { w: 1440, h: 820 },
119
+ });
120
+ return text(`Comment pin ${pin.id} created on ${u.origin}${u.pathname} (region ${Math.round(width)}×${Math.round(height)} @ ${Math.round(x)},${Math.round(y)}). ` +
121
+ `It's live in the Cherry overlay; thread replies land via cherry_reply.`);
122
+ }
123
+ catch (e) {
124
+ return errText(e);
125
+ }
126
+ });
127
+ server.tool("cherry_reply", "Reply in a pin's thread (plain text — the thread shows the author, no bot prefix). Use for 'here is what changed' summaries or clarifying questions.", { pinId: z.string(), text: z.string().min(1) }, async (args) => {
128
+ try {
129
+ const pin = await client.reply(args.pinId, args.text);
130
+ return text(`Replied on ${args.pinId}. Thread length: ${pin.thread?.length ?? "?"}.`);
131
+ }
132
+ catch (e) {
133
+ return errText(e);
134
+ }
135
+ });
136
+ server.tool("cherry_resolve", "Mark a pin resolved (only after the change is actually applied and summarized via cherry_reply).", { pinId: z.string() }, async ({ pinId }) => {
137
+ try {
138
+ const pin = await client.setStatus(pinId, "resolved");
139
+ return text(`Pin ${pinId} → ${pin.status}.`);
140
+ }
141
+ catch (e) {
142
+ return errText(e);
143
+ }
144
+ });
145
+ server.tool("cherry_reopen", "Reopen a resolved pin (status back to open).", { pinId: z.string() }, async ({ pinId }) => {
146
+ try {
147
+ const pin = await client.setStatus(pinId, "open");
148
+ return text(`Pin ${pinId} → ${pin.status}.`);
149
+ }
150
+ catch (e) {
151
+ return errText(e);
152
+ }
153
+ });
154
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ // Local mirrors of the wire shapes this bridge consumes. The backend's
2
+ // WirePin (estebanivar-vqa-backend/src/lib/shape.ts) and vqa-react's
3
+ // ComponentSpecs (packages/vqa-react/src/specs.ts) are the sources of
4
+ // truth — these are read-side subsets, intentionally loose (older pins
5
+ // predate several fields).
6
+ export {};
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@estebanivar/vqa-mcp",
3
+ "version": "0.35.0",
4
+ "description": "cherry-qa MCP server — drain-once agent bridge over the Cherry pin store",
5
+ "type": "module",
6
+ "bin": {
7
+ "cherry-qa-mcp": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "dev": "tsx src/index.ts",
12
+ "start": "node dist/index.js",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.0.0",
17
+ "zod": "^3.23.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.0.0",
21
+ "tsx": "^4.19.0",
22
+ "typescript": "^5.6.0"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "license": "UNLICENSED",
31
+ "engines": {
32
+ "node": ">=18"
33
+ }
34
+ }