Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions packages/core/src/lib/objectBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lib/objectOverlap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));
}

Expand Down
1 change: 1 addition & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions packages/mcp-server/src/appBridge.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
61 changes: 61 additions & 0 deletions packages/mcp-server/src/appBridge.ts
Original file line number Diff line number Diff line change
@@ -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<typeof designResponseSchema>;

/** 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<typeof setTimeout>;
}

const pending = new Map<number, Pending>();
let nextId = 1;

/** Ask the hosting app for its current design. Resolves null on timeout. */
export function requestCurrentDesign(): Promise<DesignResponse | null> {
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;
}
80 changes: 79 additions & 1 deletion packages/mcp-server/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(
{
Expand Down
45 changes: 43 additions & 2 deletions packages/mcp-server/src/http.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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. */
Expand All @@ -43,6 +79,11 @@ export async function startHttpServer(options: HttpServerOptions): Promise<Runni
return;
}

if (req.url === "/design-response") {
handleDesignResponse(req, res);
return;
}

const boundPort = (httpServer.address() as { port: number }).port;
const authority = `${HOST}:${boundPort}`;
const transport = new StreamableHTTPServerTransport({
Expand All @@ -52,7 +93,7 @@ export async function startHttpServer(options: HttpServerOptions): Promise<Runni
allowedHosts: [authority, `localhost:${boundPort}`],
allowedOrigins: [`http://${authority}`, `http://localhost:${boundPort}`],
});
const server = buildServer({ openInApp: true });
const server = buildServer({ hosted: true });
res.on("close", () => {
void transport.close();
void server.close();
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/src/openInApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading