From b0125193d57e5eaf2e13925aa5ff580c5e9f0ac2 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 19 Jul 2026 12:42:13 +0200 Subject: [PATCH] feat(mcp): get_current_design reads the app design back with measured bounds --- packages/core/src/lib/objectBounds.ts | 13 ++-- packages/core/src/lib/objectOverlap.ts | 2 +- packages/mcp-server/README.md | 1 + packages/mcp-server/src/appBridge.test.ts | 61 +++++++++++++++++ packages/mcp-server/src/appBridge.ts | 61 +++++++++++++++++ packages/mcp-server/src/http.test.ts | 80 ++++++++++++++++++++++- packages/mcp-server/src/http.ts | 45 ++++++++++++- packages/mcp-server/src/openInApp.test.ts | 2 +- packages/mcp-server/src/server.ts | 38 +++++++++-- packages/mcp-server/src/tools.test.ts | 39 ++++++++++- packages/mcp-server/src/tools.ts | 40 ++++++++++-- src-tauri/src/mcp.rs | 37 +++++++++-- src/components/AppShell.tsx | 2 + src/hooks/useMcpDesignRequest.test.ts | 52 +++++++++++++++ src/hooks/useMcpDesignRequest.ts | 43 ++++++++++++ src/lib/mcpServer.ts | 18 +++++ src/lib/objectOverlap.test.ts | 15 +++++ 17 files changed, 524 insertions(+), 25 deletions(-) create mode 100644 packages/mcp-server/src/appBridge.test.ts create mode 100644 packages/mcp-server/src/appBridge.ts create mode 100644 src/hooks/useMcpDesignRequest.test.ts create mode 100644 src/hooks/useMcpDesignRequest.ts diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index 7894e351..ce23c9b3 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -175,10 +175,15 @@ export function isBarcode(obj: { type: string }): boolean { return BARCODE_TYPES.has(obj.type); } -/** True when objectBoundsDots estimates this leaf headlessly (no measured - * footprint): barcode registry footprint, single-line-text font estimate, - * image-without-heightDots square guess. Everything else is exact. */ -export function boundsAreApprox(obj: LabelObject): boolean { +/** True when objectBoundsDots estimates this leaf headlessly: barcode registry + * footprint, single-line-text font estimate, image-without-heightDots square + * guess. Everything else, and any leaf with a `measured` footprint (render + * read-back), is exact. */ +export function boundsAreApprox( + obj: LabelObject, + measured?: ObjectBoundsCtx["measured"], +): boolean { + if (measured?.has(obj.id)) return false; if (isBarcode(obj)) return true; if (obj.type === "image") return obj.props.heightDots === undefined; if (obj.type !== "text") return false; diff --git a/packages/core/src/lib/objectOverlap.ts b/packages/core/src/lib/objectOverlap.ts index 5a436a31..4c8bf156 100644 --- a/packages/core/src/lib/objectOverlap.ts +++ b/packages/core/src/lib/objectOverlap.ts @@ -27,7 +27,7 @@ export function leafBoxesDots( return leaves.map((l) => ({ id: l.id, box: objectBoundsDots(l, ctx), - approx: boundsAreApprox(l), + approx: boundsAreApprox(l, ctx.measured), })); } diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index da5b4ba2..8f1ada88 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -11,6 +11,7 @@ An MCP server that lets an assistant build ZPLab label drafts and turn them into - `validate_zpl`: parse raw ZPL (one page per `^XA` block) and report object/page count, detected label, parser findings, preflight warnings, per-object bounds, and bbox overlaps. - `import_zpl`: parse raw ZPL into an editable design file (one page per `^XA` block, overlays preserved for verbatim re-export) plus parser findings, per-object bounds, and bbox overlaps. - `open_in_app`: push a design file into the running ZPLab desktop app. Only registered when the app spawned the server (HTTP mode), so it is absent over stdio. +- `get_current_design`: read back the design currently open in the desktop app, with render-measured bounds (nothing `approx`). App-spawned HTTP mode only. Bounds are dots (visual top-left); `approx` marks headless estimates (barcode footprints and single-line text), not render-measured. Overlaps are raw bbox diff --git a/packages/mcp-server/src/appBridge.test.ts b/packages/mcp-server/src/appBridge.test.ts new file mode 100644 index 00000000..ccbf47ae --- /dev/null +++ b/packages/mcp-server/src/appBridge.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + APP_RESPONSE_TIMEOUT_MS, + requestCurrentDesign, + resolveDesignResponse, +} from "./appBridge"; +import { designFile } from "./testFixtures"; + +function spyStdout(): { writes: string[]; restore: () => void } { + const writes: string[] = []; + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); + return { writes, restore: () => spy.mockRestore() }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("appBridge", () => { + it("writes a designRequest line and resolves with the app's reply", async () => { + const { writes, restore } = spyStdout(); + try { + const pending = requestCurrentDesign(); + expect(writes).toHaveLength(1); + const event = JSON.parse(writes[0] ?? "{}") as { zplabEvent: string; id: number }; + expect(event.zplabEvent).toBe("designRequest"); + + const delivered = resolveDesignResponse({ id: event.id, designFile }); + expect(delivered).toBe(true); + const response = await pending; + expect(response?.designFile).toEqual(designFile); + } finally { + restore(); + } + }); + + it("rejects an unknown id and a malformed payload", () => { + expect(resolveDesignResponse({ id: 999999, designFile })).toBe(false); + expect(resolveDesignResponse({ designFile })).toBe(false); + expect(resolveDesignResponse("garbage")).toBe(false); + }); + + it("resolves null on timeout and ignores the late reply", async () => { + vi.useFakeTimers(); + const { writes, restore } = spyStdout(); + try { + const pending = requestCurrentDesign(); + const event = JSON.parse(writes[0] ?? "{}") as { id: number }; + vi.advanceTimersByTime(APP_RESPONSE_TIMEOUT_MS + 1); + expect(await pending).toBeNull(); + expect(resolveDesignResponse({ id: event.id, designFile })).toBe(false); + } finally { + restore(); + } + }); +}); diff --git a/packages/mcp-server/src/appBridge.ts b/packages/mcp-server/src/appBridge.ts new file mode 100644 index 00000000..5bfec76c --- /dev/null +++ b/packages/mcp-server/src/appBridge.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +// Request/response bridge to the hosting desktop app, reusing the two existing +// channels: requests leave as zplabEvent lines on stdout (the pipe the app +// reads, like openDraft), responses arrive on the HTTP design-response route. + +const measuredFootprintSchema = z.object({ + width: z.number(), + height: z.number(), + barHeightDots: z.number().optional(), + barLeftDots: z.number().optional(), + barTopDots: z.number().optional(), + uprightBarWDots: z.number().optional(), + uprightBarHDots: z.number().optional(), +}); + +export const designResponseSchema = z.object({ + id: z.number().int(), + designFile: z.record(z.string(), z.unknown()), + /** Render-measured footprints (dots) keyed by object id; see ObjectBoundsCtx. */ + measured: z.record(z.string(), measuredFootprintSchema).optional(), +}); +export type DesignResponse = z.infer; + +/** The app answers via its Tauri event loop plus one local fetch; anything + * slower means no app, no listener yet (boot), or no desktop at all. */ +export const APP_RESPONSE_TIMEOUT_MS = 4000; + +interface Pending { + resolve: (value: DesignResponse | null) => void; + timer: ReturnType; +} + +const pending = new Map(); +let nextId = 1; + +/** Ask the hosting app for its current design. Resolves null on timeout. */ +export function requestCurrentDesign(): Promise { + const id = nextId++; + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(id); + resolve(null); + }, APP_RESPONSE_TIMEOUT_MS); + pending.set(id, { resolve, timer }); + process.stdout.write(JSON.stringify({ zplabEvent: "designRequest", id }) + "\n"); + }); +} + +/** Deliver the app's reply to the waiting request. False for an unknown or + * already timed-out id (a late or stray reply is a no-op). */ +export function resolveDesignResponse(payload: unknown): boolean { + const parsed = designResponseSchema.safeParse(payload); + if (!parsed.success) return false; + const entry = pending.get(parsed.data.id); + if (!entry) return false; + pending.delete(parsed.data.id); + clearTimeout(entry.timer); + entry.resolve(parsed.data); + return true; +} diff --git a/packages/mcp-server/src/http.test.ts b/packages/mcp-server/src/http.test.ts index b7b7e3cb..a2d36c16 100644 --- a/packages/mcp-server/src/http.test.ts +++ b/packages/mcp-server/src/http.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { startHttpServer, type RunningHttpServer } from "./http"; import { designFile } from "./testFixtures"; @@ -83,6 +83,84 @@ describe("mcp-server http transport", () => { expect(res.status).toBe(403); }); + it("answers get_current_design with the simulated app's reply (render-exact bounds)", async () => { + // Intercept the designRequest event line the tool writes to stdout and + // play the app: POST the design plus a measured footprint back. + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + const event = JSON.parse(String(chunk)) as { zplabEvent: string; id: number }; + expect(event.zplabEvent).toBe("designRequest"); + void fetch(`http://127.0.0.1:${server.port}/design-response`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ + id: event.id, + designFile, + measured: { t1: { width: 222, height: 33 } }, + }), + }); + return true; + }); + try { + const res = await post( + { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "get_current_design", arguments: {} } }, + { token: TOKEN }, + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { result?: { content?: { text: string }[] } }; + const result = JSON.parse(json.result?.content?.[0]?.text ?? "{}") as { + ok: boolean; + designFile: unknown; + bounds: { objectId: string; width: number; height: number; approx: boolean }[]; + }; + expect(result.ok).toBe(true); + expect(result.designFile).toEqual(designFile); + const t1 = result.bounds.find((b) => b.objectId === "t1"); + expect(t1?.width).toBe(222); + expect(t1?.height).toBe(33); + expect(t1?.approx).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it("rejects an unauthenticated design-response with 401", async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/design-response`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: 1, designFile }), + }); + expect(res.status).toBe(401); + }); + + it("rejects a design-response for an unknown id with 400", async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/design-response`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ id: 424242, designFile }), + }); + expect(res.status).toBe(400); + }); + + it("rejects an oversized design-response body with 413", async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/design-response`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: `{"id":1,"pad":"${"x".repeat(17 * 1024 * 1024)}"}`, + }).catch(() => null); + // Node may reset the socket mid-upload after the 413; both prove the cap. + if (res) expect(res.status).toBe(413); + else expect(res).toBeNull(); + }); + + it("rejects a non-POST design-response with 403", async () => { + const res = await fetch(`http://127.0.0.1:${server.port}/design-response`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(403); + }); + it("round-trips tools/call export_zpl over HTTP and returns ZPL", async () => { const res = await post( { diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index eba0d9e7..b8a49039 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -1,10 +1,15 @@ -import { createServer, type IncomingMessage } from "node:http"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createHash, timingSafeEqual } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { resolveDesignResponse } from "./appBridge.js"; import { buildServer } from "./server.js"; const HOST = "127.0.0.1"; +/** Cap for the app's design-response body. A design file with embedded + * graphics runs to a few MB; anything beyond this is not a real label. */ +const MAX_DESIGN_RESPONSE_BYTES = 16 * 1024 * 1024; + export interface HttpServerOptions { port: number; token: string; @@ -27,6 +32,37 @@ function hasValidToken(req: IncomingMessage, expected: string): boolean { return timingSafeEqual(provided, wanted); } +/** The app's reply to a designRequest event. Bypasses the SDK transport (it is + * not MCP JSON-RPC), so it re-checks the Host header for loopback itself; the + * bearer token was already verified by the shared gate. */ +function handleDesignResponse(req: IncomingMessage, res: ServerResponse): void { + const host = (req.headers.host ?? "").replace(/:\d+$/, ""); + if (req.method !== "POST" || (host !== "127.0.0.1" && host !== "localhost")) { + res.writeHead(403).end(); + return; + } + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_DESIGN_RESPONSE_BYTES) { + res.writeHead(413).end(); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + let delivered = false; + try { + delivered = resolveDesignResponse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + // Malformed JSON falls through to the 400 below. + } + if (!res.writableEnded) res.writeHead(delivered ? 204 : 400).end(); + }); +} + /** Loopback-only Streamable HTTP server with mandatory bearer auth. A fresh * McpServer + transport is built per request (stateless: our tools are pure * request/response); Origin/Host are checked by the SDK's DNS-rebinding protection. */ @@ -43,6 +79,11 @@ export async function startHttpServer(options: HttpServerOptions): Promise { void transport.close(); void server.close(); diff --git a/packages/mcp-server/src/openInApp.test.ts b/packages/mcp-server/src/openInApp.test.ts index cc45583b..238f35ce 100644 --- a/packages/mcp-server/src/openInApp.test.ts +++ b/packages/mcp-server/src/openInApp.test.ts @@ -28,7 +28,7 @@ describe("open_in_app gating", () => { return true; }); try { - const client = await connect(buildServer({ openInApp: true })); + const client = await connect(buildServer({ hosted: true })); const { tools } = await client.listTools(); expect(tools.map((t) => t.name)).toContain("open_in_app"); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index ce9c5505..d67a76ee 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,5 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { requestCurrentDesign } from "./appBridge.js"; import { + buildCurrentDesignResult, createDraft, createDraftShape, designFileEnvelopeSchema, @@ -13,9 +15,10 @@ import { } from "./tools.js"; export interface BuildServerOptions { - /** Registers open_in_app, whose handler prints an event line to stdout. Set - * only in HTTP mode, where stdout is free (in stdio mode it is JSON-RPC). */ - openInApp?: boolean; + /** ZPLab spawned this server: registers open_in_app and get_current_design, + * which talk to the app over stdout event lines. Set only in HTTP mode, + * where stdout is free (in stdio mode it is JSON-RPC). */ + hosted?: boolean; } // Compact on purpose: pretty-printing inflates every tool result by ~45% @@ -38,7 +41,9 @@ export const SERVER_INSTRUCTIONS = "import_zpl (editable design file) or validate_zpl (lint only); both split " + "multi-label streams into one page per ^XA block. export_zpl returns the " + "final ZPL; open_in_app (when present) replaces the design in the running " + - "ZPLab editor, so confirm with the user before calling it."; + "ZPLab editor, so confirm with the user before calling it. " + + "get_current_design (when present) reads back the design open in the editor " + + "with render-exact bounds (no approx), including any edits the user made."; /** Single tool definition shared by the stdio and HTTP entry points. */ export function buildServer(options: BuildServerOptions = {}): McpServer { @@ -120,9 +125,9 @@ export function buildServer(options: BuildServerOptions = {}): McpServer { async () => json(getSchema()), ); - // Only when ZPLab spawned the server (HTTP mode): the handler prints the - // draft to stdout, which the app pipes and forwards to its editor. - if (options.openInApp) { + // Only when ZPLab spawned the server (HTTP mode): these talk to the app + // over the piped stdout (and, for the read-back, its HTTP reply). + if (options.hosted) { server.registerTool( "open_in_app", { @@ -139,6 +144,25 @@ export function buildServer(options: BuildServerOptions = {}): McpServer { return json({ ok: true }); }, ); + + server.registerTool( + "get_current_design", + { + title: "Get current ZPLab design", + description: + "Read the design currently open in the ZPLab desktop app: the design file " + + "plus render-exact bounds and overlaps (the app reports measured barcode/text " + + "sizes, so nothing is approx). Only available when ZPLab launched this server.", + inputSchema: {}, + }, + async () => { + const response = await requestCurrentDesign(); + if (response === null) { + return json({ ok: false, errors: ["The ZPLab app did not respond."] }); + } + return json(buildCurrentDesignResult(response)); + }, + ); } return server; diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index 19a8d843..f11c155b 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { z } from "zod"; -import { createDraft, createDraftShape, validateDraft, exportZpl, getSchema, importZpl, validateZpl } from "./tools"; +import { buildCurrentDesignResult, createDraft, createDraftShape, validateDraft, exportZpl, getSchema, importZpl, validateZpl } from "./tools"; import { ObjectRegistry } from "@zplab/core/registry"; import { textObject } from "./testFixtures"; @@ -375,3 +375,40 @@ describe("mcp-server tools", () => { expect(created.errors[0]).toMatch(/object limit/); }); }); + +describe("buildCurrentDesignResult", () => { + const barcode = { + id: "bc1", + type: "code128", + x: 20, + y: 20, + rotation: 0, + props: { content: "12345678", height: 100, moduleWidth: 2, rotation: "N" }, + }; + const design = { + schemaVersion: 3, + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: [{ objects: [barcode] }], + }; + + it("upgrades a measured barcode to render-exact bounds", () => { + const result = ok(buildCurrentDesignResult({ + id: 1, + designFile: design, + measured: { bc1: { width: 321, height: 118 } }, + })); + const b = result.bounds.find((x) => x.objectId === "bc1"); + expect(b?.width).toBe(321); + expect(b?.height).toBe(118); + expect(b?.approx).toBe(false); + }); + + it("keeps an unmeasured barcode approx", () => { + const result = ok(buildCurrentDesignResult({ id: 2, designFile: design })); + expect(result.bounds.find((x) => x.objectId === "bc1")?.approx).toBe(true); + }); + + it("maps a malformed design to the ToolError shape", () => { + expect(buildCurrentDesignResult({ id: 3, designFile: { schemaVersion: 3 } }).ok).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index a22e2c17..a63c9d57 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -10,7 +10,8 @@ import { generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; import { importZplText, type ZplImportResult } from "@zplab/core/lib/zplImportService"; import type { ImportReport } from "@zplab/core/lib/zplParser"; import { computePreflight } from "@zplab/core/lib/preflight"; -import type { BoundingBoxDots } from "@zplab/core/lib/objectBounds"; +import type { BoundingBoxDots, ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; +import type { DesignResponse } from "./appBridge.js"; import { computeOverlaps, leafBoxesDots, MAX_OVERLAPS, type OverlapDots } from "@zplab/core/lib/objectOverlap"; import { getEntry, ObjectRegistry } from "@zplab/core/registry"; import { exportableLeaves, type LabelObject } from "@zplab/core/types/Group"; @@ -242,8 +243,13 @@ const roundRect = (r: BoundingBoxDots): BoundingBoxDots => ({ /** One box pass per page feeds both reports, so they cannot diverge. Bounded: * dense pages skip geometry, and overlaps are capped, to keep the payload and - * the O(n²) scan finite on adversarial input. */ -function geometryFor(pages: PageLike[], label: LabelConfig): Geometry { + * the O(n²) scan finite on adversarial input. `measured` (app read-back) + * upgrades the affected boxes from estimate to render-exact. */ +function geometryFor( + pages: PageLike[], + label: LabelConfig, + measured?: ObjectBoundsCtx["measured"], +): Geometry { const bounds: ObjectBounds[] = []; const overlaps: ObjectOverlap[] = []; let truncated = false; @@ -253,7 +259,7 @@ function geometryFor(pages: PageLike[], label: LabelConfig): Geometry { truncated = true; return; } - const boxes = leafBoxesDots(leaves, { label }); + const boxes = leafBoxesDots(leaves, { label, measured }); for (const b of boxes) { bounds.push({ pageIndex, objectId: b.id, ...roundRect(b.box), approx: b.approx }); } @@ -322,6 +328,32 @@ export function validateDraft(designFile: unknown): ValidateDraftResult { }; } +export type GetCurrentDesignResult = + | { + ok: true; + designFile: DesignFileJson; + warnings: PreflightWarning[]; + bounds: ObjectBounds[]; + overlaps: ObjectOverlap[]; + geometryTruncated?: boolean; + } + | ToolError; + +/** Turn the app's read-back (design + render-measured footprints) into the + * standard tool report; measured footprints make the bounds render-exact. */ +export function buildCurrentDesignResult(response: DesignResponse): GetCurrentDesignResult { + const parsed = parseEnvelope(response.designFile); + if (!parsed.ok) return parsed; + const { label, pages } = parsed.value; + const measured = response.measured ? new Map(Object.entries(response.measured)) : undefined; + return { + ok: true, + designFile: response.designFile as unknown as DesignFileJson, + warnings: perPage(pages, label, preflightOf), + ...geometryFor(pages, label, measured), + }; +} + export type OpenInAppResult = { ok: true; line: string } | ToolError; /** Validate the design file and, on success, build the newline-delimited event diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index c6320b25..65a8ebe9 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -11,6 +11,10 @@ use tokio::sync::oneshot; /// Tauri event carrying an openDraft design file to the editor. const OPEN_DRAFT_EVENT: &str = "mcp://open-draft"; +/// Tauri event carrying a designRequest id; the webview answers the sidecar +/// directly over its HTTP design-response route. +const DESIGN_REQUEST_EVENT: &str = "mcp://design-request"; + /// Windows: kill-on-close Job Object that ties the child's cmd/pnpm/node tree /// to the app. Closing the handle (explicitly, or when the process dies and the /// OS closes it) terminates the whole tree, so grandchildren never orphan. @@ -218,10 +222,19 @@ fn is_listening_event(line: &str) -> bool { .unwrap_or(false) } +/// Extract the request id from a child `designRequest` line, or None. +fn design_request_id(line: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + if value.get("zplabEvent")?.as_str()? != "designRequest" { + return None; + } + value.get("id")?.as_u64() +} + /// Signal readiness on the child's first `listening` stdout line, then forward -/// openDraft events. Ends when stdout closes; dropping an unused `ready` sender -/// then makes wait_until_ready observe the child as gone. -fn forward_open_draft( +/// openDraft and designRequest events. Ends when stdout closes; dropping an +/// unused `ready` sender then makes wait_until_ready observe the child as gone. +fn forward_child_events( stdout: std::process::ChildStdout, app: AppHandle, ready: oneshot::Sender<()>, @@ -238,6 +251,8 @@ fn forward_open_draft( } if let Some(payload) = open_draft_payload(&line) { let _ = app.emit(OPEN_DRAFT_EVENT, payload); + } else if let Some(id) = design_request_id(&line) { + let _ = app.emit(DESIGN_REQUEST_EVENT, id); } } } @@ -290,7 +305,7 @@ pub async fn mcp_start( } let (ready_tx, ready_rx) = oneshot::channel(); if let Some(stdout) = child.stdout.take() { - std::thread::spawn(move || forward_open_draft(stdout, app, ready_tx)); + std::thread::spawn(move || forward_child_events(stdout, app, ready_tx)); } // Store before the wait so a teardown mid-startup still reaps the child. *state.child.lock().unwrap() = Some(child); @@ -381,6 +396,20 @@ mod tests { assert!(!is_listening_event("plain dev log line")); } + #[test] + fn design_request_id_extracts_the_id() { + assert_eq!( + design_request_id(r#"{"zplabEvent":"designRequest","id":7}"#), + Some(7) + ); + assert_eq!(design_request_id(r#"{"zplabEvent":"designRequest"}"#), None); + assert_eq!( + design_request_id(r#"{"zplabEvent":"openDraft","id":7}"#), + None + ); + assert_eq!(design_request_id("plain dev log line"), None); + } + #[test] fn wait_until_ready_succeeds_on_signal() { let rt = tokio::runtime::Builder::new_current_thread() diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 036b5f39..228ad06a 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -58,6 +58,7 @@ import { useT } from "../hooks/useT"; import { kbd } from "../lib/kbd"; import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts"; import { useDesignFileActions } from "../hooks/useDesignFileActions"; +import { useMcpDesignRequest } from "../hooks/useMcpDesignRequest"; import { useCsvImportActions } from "../hooks/useCsvImportActions"; import { useZplImportExport } from "../hooks/useZplImportExport"; import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel"; @@ -161,6 +162,7 @@ export function AppShell() { const collisionDetection = makePaletteCollision(paletteEditing); useGlobalShortcuts(); + useMcpDesignRequest(); const { handleNew, handleSave, handleOpen, handleLoad, loadInputRef } = useDesignFileActions(); const { csvInputRef, diff --git a/src/hooks/useMcpDesignRequest.test.ts b/src/hooks/useMcpDesignRequest.test.ts new file mode 100644 index 00000000..236e0bec --- /dev/null +++ b/src/hooks/useMcpDesignRequest.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { respondToDesignRequest } from "./useMcpDesignRequest"; +import { useLabelStore } from "../store/labelStore"; +import { setMeasuredBounds, clearMeasuredBounds } from "../components/Canvas/measuredBoundsCache"; + +afterEach(() => { + vi.restoreAllMocks(); + clearMeasuredBounds("bc1"); +}); + +describe("respondToDesignRequest", () => { + it("POSTs the current design plus measured footprints to the sidecar", async () => { + useLabelStore.setState({ + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: [ + { + objects: [ + { + id: "bc1", + type: "code128", + x: 10, + y: 10, + rotation: 0, + props: { content: "12345", height: 80 }, + } as never, + ], + }, + ], + mcpServerPort: 4923, + mcpServerToken: "tok", + }); + setMeasuredBounds("bc1", { width: 321, height: 88 }); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 204 })); + + await respondToDesignRequest(7); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://127.0.0.1:4923/design-response"); + expect((init.headers as Record).authorization).toBe("Bearer tok"); + const body = JSON.parse(String(init.body)) as { + id: number; + designFile: { label: { widthMm: number } }; + measured: Record; + }; + expect(body.id).toBe(7); + expect(body.designFile.label.widthMm).toBe(100); + expect(body.measured.bc1).toEqual({ width: 321, height: 88 }); + }); +}); diff --git a/src/hooks/useMcpDesignRequest.ts b/src/hooks/useMcpDesignRequest.ts new file mode 100644 index 00000000..5ad6bcec --- /dev/null +++ b/src/hooks/useMcpDesignRequest.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { serializeDesign } from "@zplab/core/lib/designFile"; +import { measuredBoundsMap } from "../components/Canvas/measuredBoundsCache"; +import { postDesignResponse } from "../lib/mcpServer"; +import { isDesktopShell } from "../lib/platform"; +import { useLabelStore } from "../store/labelStore"; + +/** Snapshot the store design plus the render-measured footprints and POST + * them back to the sidecar. A failed reply is not actionable here; the + * sidecar's request timeout turns it into a tool error for the caller. */ +export async function respondToDesignRequest(id: number): Promise { + const { label, pages, variables, csvMapping, mcpServerPort, mcpServerToken } = + useLabelStore.getState(); + const designFile: unknown = JSON.parse(serializeDesign(label, pages, variables, csvMapping)); + const measured = Object.fromEntries(measuredBoundsMap()); + await postDesignResponse(mcpServerPort, mcpServerToken, { id, designFile, measured }); +} + +/** Answer the sidecar's get_current_design requests. */ +export function useMcpDesignRequest(): void { + useEffect(() => { + if (!isDesktopShell) return; + let unlisten: (() => void) | undefined; + let cancelled = false; + void import("@tauri-apps/api/event") + .then(({ listen }) => + listen("mcp://design-request", (e) => { + void respondToDesignRequest(e.payload).catch(() => undefined); + }), + ) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + // Bridge setup is desktop-only and non-actionable if it fails; swallow so + // it is not an unhandled rejection (matches the open-draft listener). + .catch(() => undefined); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); +} diff --git a/src/lib/mcpServer.ts b/src/lib/mcpServer.ts index 90514ddf..fbaa8aa7 100644 --- a/src/lib/mcpServer.ts +++ b/src/lib/mcpServer.ts @@ -32,6 +32,24 @@ export function mcpConfigSnippet(port: number, token: string): string { ); } +/** The webview's reply to a sidecar designRequest event: one POST to the + * sidecar's design-response route, authed with the same bearer token external + * MCP clients use. On failure the sidecar's request timeout reports instead. */ +export async function postDesignResponse( + port: number, + token: string, + body: { id: number; designFile: unknown; measured: Record }, +): Promise { + await fetch(`http://127.0.0.1:${port}/design-response`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + export async function startMcpServer(opts: { port: number; token: string }): Promise { if (!isDesktopShell) throw new Error("The MCP server requires the desktop app"); const { invoke } = await import("@tauri-apps/api/core"); diff --git a/src/lib/objectOverlap.test.ts b/src/lib/objectOverlap.test.ts index c1ec36e4..3c939658 100644 --- a/src/lib/objectOverlap.test.ts +++ b/src/lib/objectOverlap.test.ts @@ -46,6 +46,21 @@ describe("computeOverlaps", () => { expect(overlap?.approx).toBe(true); }); + it("a measured footprint makes a barcode exact (bounds + approx)", () => { + const bc = { + id: "bc", type: "code128", x: 10, y: 10, rotation: 0, + props: { + content: "12345", height: 80, moduleWidth: 2, + printInterpretation: false, checkDigit: false, rotation: "N", + }, + } as LeafObject; + const measured = new Map([["bc", { width: 321, height: 88 }]]); + const [entry] = leafBoxesDots([bc], { label, measured }); + expect(entry?.approx).toBe(false); + expect(entry?.box.width).toBe(321); + expect(entry?.box.height).toBe(88); + }); + it("flags single-line text as approx (headless font estimate), block text exact", () => { const single = { id: "t", type: "text", x: 0, y: 0, rotation: 0,