Skip to content

Commit 1080fe9

Browse files
committed
feat(mcp): edit_design applies an op list to the open label in place
1 parent f8e8d2d commit 1080fe9

24 files changed

Lines changed: 630 additions & 106 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { MAX_SOURCE_PAGES } from "./zplSourceEdit";
2+
3+
/** Mirrors the raw-ZPL tools' input cap so preflight and emit stay bounded. The page cap is the source editor's. */
4+
const MAX_PAGES = MAX_SOURCE_PAGES;
5+
const MAX_TOTAL_OBJECTS = 10000;
6+
const MAX_GROUP_DEPTH = 64;
7+
8+
/** Counts NODES, descending into group children: a top-level count would let
9+
* one group carry an unbounded subtree past the cap into preflight. */
10+
export function designSizeIssue(pages: readonly { objects: readonly unknown[] }[]): string | null {
11+
if (pages.length > MAX_PAGES) return `design exceeds the ${MAX_PAGES}-page limit`;
12+
let total = 0;
13+
const stack: [unknown, number][] = [];
14+
for (const p of pages) for (const o of p.objects) stack.push([o, 1]);
15+
for (let next = stack.pop(); next !== undefined; next = stack.pop()) {
16+
const [node, depth] = next;
17+
total++;
18+
if (total > MAX_TOTAL_OBJECTS) return `design exceeds the ${MAX_TOTAL_OBJECTS}-object limit`;
19+
// This iterative walk is the gate for every recursive one after it, which
20+
// would otherwise answer a deep chain with a bare stack overflow.
21+
if (depth > MAX_GROUP_DEPTH) return `design exceeds the ${MAX_GROUP_DEPTH}-level group nesting limit`;
22+
const children = (node as { children?: unknown })?.children;
23+
if (Array.isArray(children)) for (const c of children) stack.push([c, depth + 1]);
24+
}
25+
return null;
26+
}

‎packages/core/src/lib/designOps.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,22 @@ export function applyDesignOps(doc: DesignDoc, ops: readonly DesignOp[], probe:
170170
return { ok: true, doc: { label, pages: nextPages, variables, columnMapping }, touched, edited, assignedIds };
171171
}
172172

173+
/** Pages whose capture the ops dropped, and pages whose capture export can no longer replay around an edit. */
174+
export function captureLoss(
175+
before: readonly Page[],
176+
touched: ReadonlySet<number>,
177+
edited: ReadonlySet<number>,
178+
): { lost: number[]; atRisk: number[] } {
179+
const lost: number[] = [];
180+
const atRisk: number[] = [];
181+
before.forEach((p, i) => {
182+
if (!p.overlay) return;
183+
if (touched.has(i)) lost.push(i);
184+
else if (!p.overlay.regenSafe && edited.has(i)) atRisk.push(i);
185+
});
186+
return { lost, atRisk };
187+
}
188+
173189
type VariableOp = Extract<DesignOp, { op: `${string}Variable` }>;
174190

175191
interface VariableDoc {

‎packages/mcp-server/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ An MCP server that lets an assistant build ZPLab label drafts and turn them into
1010
- `export_zpl`: parse a design file and return its generated ZPL.
1111
- `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.
1212
- `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.
13+
- `patch_design`: apply update, add, remove and variable-edit operations to a design file without rebuilding it.
1314
- `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.
1415
- `get_current_design`: read back the design currently open in the desktop app, with render-measured bounds (nothing `approx`). App-spawned HTTP mode only.
16+
- `edit_design`: apply the operations `patch_design` takes to the design open in the desktop app. The app edits in place and the whole call is one undo step. App-spawned HTTP mode only.
17+
- `raster_image`: turn a data URL image into a placeable 1-bit graphic object. App-spawned HTTP mode only.
1518

1619
Bounds are dots (visual top-left); `approx` marks headless estimates (barcode
1720
footprints and single-line text), not render-measured. Overlaps are raw bbox

‎packages/mcp-server/src/appBridge.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
resolveDesignResponse,
1212
resolveDraftReceipt,
1313
resolveRasterResponse,
14+
resolveEditReceipt,
15+
requestEditDesign,
16+
markAppAttached,
17+
markAppDetached,
1418
} from "./appBridge";
1519
import { designFile } from "./testFixtures";
1620

@@ -111,8 +115,9 @@ describe("a reply on the wrong route", () => {
111115
try {
112116
const pending = requestRaster("data:image/png;base64,AA", 200, 128);
113117
const { id } = JSON.parse(writes[0] ?? "{}") as { id: number };
114-
// Both schemas are satisfied by {id, ok}, so only the route tells them apart.
118+
// The schemas are satisfied by {id, ok}, so only the route tells them apart.
115119
expect(resolveDraftReceipt({ id, ok: true })).toBe(false);
120+
expect(resolveEditReceipt({ id, ok: true })).toBe(false);
116121
expect(resolveRasterResponse({ id, ok: true, gfa: "^GFA,1,1,1,00" })).toBe(true);
117122
expect((await pending)?.gfa).toBe("^GFA,1,1,1,00");
118123
} finally {
@@ -121,6 +126,20 @@ describe("a reply on the wrong route", () => {
121126
});
122127
});
123128

129+
describe("a window going away", () => {
130+
it("settles a pending edit request with null instead of its timeout", async () => {
131+
const { restore } = spyStdout();
132+
try {
133+
markAppAttached("s-edit");
134+
const pending = requestEditDesign([{ op: "remove", id: "a" }]);
135+
expect(markAppDetached("s-edit")).toBe(true);
136+
expect(await pending).toBeNull();
137+
} finally {
138+
restore();
139+
}
140+
});
141+
});
142+
124143
describe("request ids across sidecar generations", () => {
125144
it("does not start at one, so a reply in flight cannot hit a fresh request", async () => {
126145
// The webview outlives a sidecar restart; a reply carrying id 1 must not

‎packages/mcp-server/src/appBridge.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ export const rasterResponseSchema = z.object({
5151
});
5252
export type RasterResponse = z.infer<typeof rasterResponseSchema>;
5353

54+
/** The app applied an op list, or names the op it refused. */
55+
export const editReceiptSchema = z.object({
56+
id: z.number().int(),
57+
ok: z.boolean(),
58+
errors: z.array(z.string()).optional(),
59+
opIndex: z.number().int().optional(),
60+
designFile: z.record(z.string(), z.unknown()).optional(),
61+
measured: z.record(z.string(), measuredFootprintSchema).optional(),
62+
assignedIds: z.record(z.string(), z.string()).optional(),
63+
capturesLost: z.array(z.number().int()).optional(),
64+
capturesAtRisk: z.array(z.number().int()).optional(),
65+
});
66+
export type EditReceipt = z.infer<typeof editReceiptSchema>;
67+
5468
/** The app answers via its Tauri event loop plus one local fetch; anything
5569
* slower means no app, no listener yet (boot), or no desktop at all. Only for
5670
* receipts; a design read-back does real work first (see below). */
@@ -71,10 +85,13 @@ export const RASTER_TIMEOUT_MS = 20_000;
7185
* would apply it twice). Sized like the sibling 20s budgets above. */
7286
export const OPEN_DRAFT_TIMEOUT_MS = 20_000;
7387

88+
/** Waits for the canvas to settle like a design read-back. */
89+
export const EDIT_DESIGN_TIMEOUT_MS = DESIGN_RESPONSE_TIMEOUT_MS;
90+
7491
/** The event a pending entry is waiting on. Kept per request because the
75-
* reply schemas are mutually satisfiable: a draft receipt and a raster
76-
* response both parse as `{id, ok}`, so an id alone cannot tell them apart. */
77-
type RequestKind = "designRequest" | "openDraft" | "rasterRequest";
92+
* reply schemas are mutually satisfiable: a draft receipt, a raster response
93+
* and an edit receipt all parse as `{id, ok}`, so an id alone cannot tell them apart. */
94+
type RequestKind = "designRequest" | "openDraft" | "rasterRequest" | "editRequest";
7895

7996
interface Pending {
8097
kind: RequestKind;
@@ -189,3 +206,12 @@ export function resolveDraftReceipt(payload: unknown): boolean {
189206
const parsed = draftReceiptSchema.safeParse(payload);
190207
return parsed.success && deliver("openDraft", parsed.data.id, parsed.data);
191208
}
209+
210+
export function requestEditDesign(operations: readonly unknown[]): Promise<EditReceipt | null> {
211+
return ask<EditReceipt>("editRequest", { operations }, EDIT_DESIGN_TIMEOUT_MS);
212+
}
213+
214+
export function resolveEditReceipt(payload: unknown): boolean {
215+
const parsed = editReceiptSchema.safeParse(payload);
216+
return parsed.success && deliver("editRequest", parsed.data.id, parsed.data);
217+
}

‎packages/mcp-server/src/boundary.ts‎

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "@zplab/core/lib/designInput";
2222
import { getEntry } from "@zplab/core/registry";
2323
import type { PropSpec } from "@zplab/core/types/propSpec";
24-
import { MAX_SOURCE_PAGES } from "@zplab/core/lib/zplSourceEdit";
24+
import { designSizeIssue } from "@zplab/core/lib/designLimits";
2525
import { getAllLeaves, walkObjects, type LabelObject } from "@zplab/core/types/Group";
2626
import { errorMessage } from "@zplab/core/lib/errorMessage";
2727
import { DPMM_VALUES, isDpmm, type DeviceFontLabel, type Dpmm, type LabelConfig } from "@zplab/core/types/LabelConfig";
@@ -103,38 +103,9 @@ function labelConfigIssues(label: LabelConfig): string[] {
103103
return issues;
104104
}
105105

106-
/** Envelope size limits: the raw-ZPL tools cap the input string, so the
107-
* design-file tools cap the parsed shape symmetrically to bound preflight and
108-
* emit on an oversized envelope. Well beyond any real label. The page cap is
109-
* the source editor's, one number for one question. */
110-
const MAX_PAGES = MAX_SOURCE_PAGES;
111-
const MAX_TOTAL_OBJECTS = 10000;
112-
const MAX_GROUP_DEPTH = 64;
113-
114-
/** Counts NODES, descending into group children: a top-level count would let
115-
* one group carry an unbounded subtree past the cap into preflight. */
116106
export function pagesSizeError(pages: readonly { objects: readonly unknown[] }[]): ToolError | null {
117-
if (pages.length > MAX_PAGES) {
118-
return { ok: false, errors: [`design exceeds the ${MAX_PAGES}-page limit`] };
119-
}
120-
let total = 0;
121-
const stack: [unknown, number][] = [];
122-
for (const p of pages) for (const o of p.objects) stack.push([o, 1]);
123-
for (let next = stack.pop(); next !== undefined; next = stack.pop()) {
124-
const [node, depth] = next;
125-
total++;
126-
if (total > MAX_TOTAL_OBJECTS) {
127-
return { ok: false, errors: [`design exceeds the ${MAX_TOTAL_OBJECTS}-object limit`] };
128-
}
129-
// This iterative walk is the gate for every recursive one after it, which
130-
// would otherwise answer a deep chain with a bare stack overflow.
131-
if (depth > MAX_GROUP_DEPTH) {
132-
return { ok: false, errors: [`design exceeds the ${MAX_GROUP_DEPTH}-level group nesting limit`] };
133-
}
134-
const children = (node as { children?: unknown })?.children;
135-
if (Array.isArray(children)) for (const c of children) stack.push([c, depth + 1]);
136-
}
137-
return null;
107+
const issue = designSizeIssue(pages);
108+
return issue === null ? null : { ok: false, errors: [issue] };
138109
}
139110

140111
/** Every producer keeps ids design-unique (editor/parser: UUIDs; the draft
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { buildServer } from "./server";
3+
import { buildEditDesignResult, createDraft } from "./tools";
4+
import { markAppAttached, resolveEditReceipt } from "./appBridge";
5+
import { connect, spyStdout } from "./testFixtures";
6+
7+
const designAfter = () => {
8+
const created = createDraft({
9+
widthMm: 60, heightMm: 40, dpmm: 8,
10+
objects: [{ type: "text", id: "t1", x: 10, y: 10, props: { content: "edited" } }, { type: "text", x: 10, y: 60, props: { content: "new" } }],
11+
});
12+
if (!created.ok) throw new Error("fixture");
13+
return created.designFile as unknown as Record<string, unknown>;
14+
};
15+
16+
type ToolReply = Promise<{ content: { type: string; text: string }[] }>;
17+
18+
describe("edit_design", () => {
19+
it("is absent from the stdio build's tool list", async () => {
20+
const client = await connect(buildServer());
21+
const { tools } = await client.listTools();
22+
expect(tools.map((t) => t.name)).not.toContain("edit_design");
23+
});
24+
25+
it("sends the ops only and answers with the report the app measured, never an envelope", async () => {
26+
markAppAttached("session-test");
27+
const { writes, restore } = spyStdout();
28+
try {
29+
const client = await connect(buildServer({ hosted: true }));
30+
const operations = [
31+
{ op: "update", id: "t1", props: { content: "edited" } },
32+
{ op: "add", object: { type: "text", x: 10, y: 60, props: { content: "new" } } },
33+
];
34+
const call = client.callTool({ name: "edit_design", arguments: { operations } }) as ToolReply;
35+
const line = await vi.waitUntil(() => writes[0]);
36+
const parsed = JSON.parse(line.trim()) as { zplabEvent: string; id: number; operations: unknown[]; designFile?: unknown };
37+
expect(parsed.zplabEvent).toBe("editRequest");
38+
expect(parsed.operations).toEqual(operations);
39+
expect(parsed.designFile).toBeUndefined();
40+
expect(resolveEditReceipt({ id: parsed.id, ok: true, designFile: designAfter(), assignedIds: { 1: "text-2" }, capturesLost: [], capturesAtRisk: [] })).toBe(true);
41+
42+
const res = JSON.parse((await call).content[0]?.text ?? "{}") as Record<string, unknown>;
43+
expect(res).toMatchObject({ ok: true, applied: 2, added: [{ opIndex: 1, id: "text-2" }] });
44+
expect(res.designFile).toBeUndefined();
45+
expect(Array.isArray(res.bounds)).toBe(true);
46+
} finally {
47+
restore();
48+
}
49+
});
50+
51+
it("hands the app's refusal back with the op it named", async () => {
52+
markAppAttached("session-test");
53+
const { writes, restore } = spyStdout();
54+
try {
55+
const client = await connect(buildServer({ hosted: true }));
56+
const call = client.callTool({ name: "edit_design", arguments: { operations: [{ op: "remove", id: "ghost" }] } }) as ToolReply;
57+
const line = await vi.waitUntil(() => writes[0]);
58+
const { id } = JSON.parse(line.trim()) as { id: number };
59+
resolveEditReceipt({ id, ok: false, errors: ["No object with id ghost"], opIndex: 0 });
60+
expect(JSON.parse((await call).content[0]?.text ?? "{}")).toEqual({ ok: false, errors: ["No object with id ghost"], opIndex: 0 });
61+
} finally {
62+
restore();
63+
}
64+
});
65+
});
66+
67+
describe("buildEditDesignResult", () => {
68+
it("names the captures an edit costs", () => {
69+
const result = buildEditDesignResult(
70+
{ id: 1, ok: true, designFile: designAfter(), capturesLost: [0], capturesAtRisk: [] },
71+
[{ op: "update", id: "t1", props: { content: "edited" } }],
72+
);
73+
expect(result.ok).toBe(true);
74+
if (!result.ok) return;
75+
expect(result.notes?.some((n) => n.startsWith("page 1:") && n.includes("structural edit"))).toBe(true);
76+
const atRisk = buildEditDesignResult({ id: 2, ok: true, designFile: designAfter(), capturesLost: [], capturesAtRisk: [0] }, []);
77+
expect(atRisk.ok && atRisk.notes?.some((n) => n.startsWith("page 1:") && n.includes("around an edit"))).toBe(true);
78+
});
79+
80+
it("tells the agent not to retry when the applied edit could not be reported", () => {
81+
const result = buildEditDesignResult({ id: 1, ok: true, designFile: { not: "a design" } }, []);
82+
expect(result.ok).toBe(false);
83+
if (result.ok) return;
84+
expect(result.errors[0]).toMatch(/Do not retry/);
85+
});
86+
});

‎packages/mcp-server/src/http.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
resolveDesignResponse,
88
resolveDraftReceipt,
99
resolveRasterResponse,
10+
resolveEditReceipt,
1011
} from "./appBridge.js";
1112
import { buildServer } from "./server.js";
1213

@@ -95,6 +96,7 @@ const APP_ROUTES: Record<string, (payload: unknown) => boolean> = {
9596
"/design-response": resolveDesignResponse,
9697
"/draft-receipt": resolveDraftReceipt,
9798
"/raster-response": resolveRasterResponse,
99+
"/edit-receipt": resolveEditReceipt,
98100
"/app-attach": (payload) => {
99101
const session = sessionOf(payload);
100102
// No session, no attach: markAppDetached's ownership guard is only as good

0 commit comments

Comments
 (0)