opentakeoff-mcp 0.9.38 → 0.9.40

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/README.md CHANGED
@@ -101,13 +101,35 @@ Each tool call writes one JSON line to stderr with the tool name, duration,
101
101
  sheet, result size, and error flag. The trace never writes to stdout and never
102
102
  includes document text, shape vertices, or result payload content.
103
103
 
104
+ ### Staged tool exposure (opt-in)
105
+
106
+ By default every client gets all 40 tool schemas on `tools/list` — the flat
107
+ contract every published client already expects. Forty descriptions is real
108
+ token weight for an agent session that may never touch half of them, so the
109
+ server can instead stage the surface along the workflow it already teaches:
110
+
111
+ ```bash
112
+ OPENTAKEOFF_MCP_STAGED_TOOLS=1 npx -y opentakeoff-mcp
113
+ ```
114
+
115
+ Staged, only the **setup** stage (load, scale, read the set — 10 tools) starts
116
+ enabled, plus one opener: `open_tool_stage`. Calling it with `"measure"`,
117
+ `"revise"`, or `"handoff"` enables that stage's tools and fires
118
+ `tools/list_changed`, so any client that supports dynamic tool lists (Claude
119
+ Code, Claude Desktop, anything built against the current spec) sees the group
120
+ appear the moment the agent asks for it. Opening is idempotent and never
121
+ closes anything — the surface only grows. The initialize instructions state
122
+ the scheme, so an agent knows to open a stage before it needs one. Requires a
123
+ client that honors `tools/list_changed`; leave the flag unset for one that
124
+ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/issues/230))
125
+
104
126
  ## Tools
105
127
 
106
128
  | Tool | What it does |
107
129
  |---|---|
108
130
  | `load_plan` | Open a plan PDF from disk. Default replaces the whole session; **`merge: true` ADDS the document to the working set** (#152) — plans + schedule + addenda as one takeoff, sheet graph spanning the whole set, marked set covering every worked sheet. Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
109
131
  | `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
110
- | `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
132
+ | `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. **Lands unconfirmed** (`confirmed: false`) until a human confirms in the canvas — see Scale rules. |
111
133
  | `one_click` | One-Click Area at (x, y): the sealed flood engine bounded by the plan linework, traced, vertices snapped — the SAME feet-true arguments the canvas passes at a click (gap sealing up to a door width, door-swing wedge inclusion, the half-foot minimum-passage rule), so an MCP trace and a canvas click at one seed measure the same square footage (pinned against the bench corpus goldens in `test/parity.test.ts`). Every trace carries the engine's account of itself: `confidence` 0–1 with `confidence_factors` naming each deduction (`gap_sealed_px`, `door_wedges`, `min_pass_delta`, …) — a review prioritizer, never a verification; a low score is a `view_sheet {overlay: true}` audit prompt, not a fact. On a SCANNED sheet (no usable linework) the flood falls back automatically to the rendered pixels — same engine as the canvas — with `raster_traced` disclosed on the reply and on the shape's origin (#154). Pass `condition` to commit (the full account stamps `origin` centrally at the commit); `role: "deduct"` subtracts. |
112
134
  | `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls, through the SAME sealed engine per room (confidence + the engine account ride each room and its committed origin). Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
113
135
  | `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
@@ -216,6 +238,14 @@ space, which makes them usable directly as click targets.
216
238
 
217
239
  - A detected scale is a **suggestion** — it is never applied automatically.
218
240
  Adopting it is always an explicit `set_scale { use_detected: true }`.
241
+ - **Agent proposes, human confirms.** `set_scale` is the agent surface, so a
242
+ scale set here lands **unconfirmed** (`confirmed: false` in the reply).
243
+ Quantities still flow — the gate is a flag, never a refusal — but
244
+ `takeoff_summary` names the affected sheets in
245
+ `scale_unconfirmed`, and the export/report carry `scale_confirmed` so the
246
+ canvas can ask the estimator to confirm (its scale menu grows a
247
+ **Confirm agent-set scale** row on import). Only a human act in the canvas
248
+ clears the flag.
219
249
  - `measure_polygon` and `measure_line` refuse without a scale:
220
250
  `Set the scale for <sheet> first — use set_scale (detected: <label>).`
221
251
  - `one_click` without a scale returns a **px-only preview**
@@ -1005,12 +1005,12 @@ function circleFitOk(segs, chain, c0, c1) {
1005
1005
  my /= m;
1006
1006
  let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
1007
1007
  for (let i = 0; i < m; i++) {
1008
- const x = xs[i] - mx, y = ys[i] - my, z3 = x * x + y * y;
1008
+ const x = xs[i] - mx, y = ys[i] - my, z4 = x * x + y * y;
1009
1009
  sxx += x * x;
1010
1010
  sxy += x * y;
1011
1011
  syy += y * y;
1012
- sxz += x * z3;
1013
- syz += y * z3;
1012
+ sxz += x * z4;
1013
+ syz += y * z4;
1014
1014
  }
1015
1015
  const det = sxx * syy - sxy * sxy;
1016
1016
  if (Math.abs(det) < 1e-9) return null;
@@ -1993,12 +1993,12 @@ function arcClusterFit(cl, mw, mask) {
1993
1993
  my /= m;
1994
1994
  let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
1995
1995
  for (const i of cl) {
1996
- const x = X(i) - mx, y = Y(i) - my, z3 = x * x + y * y;
1996
+ const x = X(i) - mx, y = Y(i) - my, z4 = x * x + y * y;
1997
1997
  sxx += x * x;
1998
1998
  sxy += x * y;
1999
1999
  syy += y * y;
2000
- sxz += x * z3;
2001
- syz += y * z3;
2000
+ sxz += x * z4;
2001
+ syz += y * z4;
2002
2002
  }
2003
2003
  const tr = sxx + syy, dsc = Math.sqrt(Math.max(0, ((sxx - syy) / 2) ** 2 + sxy * sxy));
2004
2004
  const l1 = tr / 2 + dsc;
@@ -4199,7 +4199,11 @@ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [],
4199
4199
  schema: "opentakeoff.report.v1",
4200
4200
  project_name: projectName || null,
4201
4201
  generated_with: "OpenTakeoff",
4202
- sheets: scaleInfo.map((si) => ({ sheet_id: si.sheet_id, sheet: label(si.sheet_id), scale_source: si.scale_source ?? si.source ?? "unknown" })),
4202
+ // scale_confirmed (scale gate): false = an agent set this sheet's scale and
4203
+ // no human confirmed it — the report's consumer should treat those sheets'
4204
+ // quantities as standing on an unverified number. Absent input = true
4205
+ // (human-era payloads predate the flag).
4206
+ sheets: scaleInfo.map((si) => ({ sheet_id: si.sheet_id, sheet: label(si.sheet_id), scale_source: si.scale_source ?? si.source ?? "unknown", scale_confirmed: si.scale_confirmed !== false })),
4203
4207
  // custom-column values APPEND after materials (row key order otherwise
4204
4208
  // untouched). Iterating the DEFINED columns — never raw attrs — naturally
4205
4209
  // drops orphaned colIds; attrValue (the shared assigned-value rule) keeps
@@ -4856,7 +4860,7 @@ function drawShapes(ctx, toCanvas, shapes, sheetW, sheetH, longEdge) {
4856
4860
  var SNAP_CELL = 24;
4857
4861
  var SNAP_TOL = 7;
4858
4862
  var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
4859
- var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo"];
4863
+ var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo", "woodgrain", "chevron", "pinwheel", "harlequin", "hexagon", "penny", "octagondot", "fleur", "concrete"];
4860
4864
  var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
4861
4865
  var uid = (p) => `${p}-${mintUuid2()}`;
4862
4866
  var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
@@ -5399,11 +5403,13 @@ var Session = class _Session {
5399
5403
  }
5400
5404
  s.upp = upp;
5401
5405
  s.scaleSource = source === "label" ? "standard" : source === "calibrate" ? "calibrated" : source;
5406
+ s.scaleConfirmed = false;
5402
5407
  return {
5403
5408
  sheet: s.key,
5404
5409
  upp,
5405
5410
  ...label ? { label } : {},
5406
5411
  source,
5412
+ confirmed: false,
5407
5413
  // #153 — several DISTINCT scale notes on one sheet means enlarged plans
5408
5414
  // or details are likely; region measurements will warn when a
5409
5415
  // disagreeing note sits inside them, but say it up front too
@@ -6679,7 +6685,8 @@ var Session = class _Session {
6679
6685
  summary() {
6680
6686
  const rows = conditionTotals(this.conditions, this.shapes, this.seamCtx());
6681
6687
  const lean = rows.map(({ color, fill, hatch, materials, ...rest }) => rest);
6682
- return { conditions: lean, totals: grandTotals(rows) };
6688
+ const unconfirmed = [...this.sheets.values()].filter((s) => s.upp != null && s.scaleConfirmed === false).map((s) => s.key);
6689
+ return { conditions: lean, totals: grandTotals(rows), ...unconfirmed.length ? { scale_unconfirmed: unconfirmed } : {} };
6683
6690
  }
6684
6691
  deleteShape(id) {
6685
6692
  const i = this.shapes.findIndex((x) => x.id === id);
@@ -7458,7 +7465,15 @@ var Session = class _Session {
7458
7465
  schema: ANN_SCHEMA,
7459
7466
  project_name: "",
7460
7467
  units: "imperial",
7461
- sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, units_per_px: s.upp })),
7468
+ sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({
7469
+ sheet_id: s.key,
7470
+ units_per_px: s.upp,
7471
+ // provenance rides the payload (it used to be dropped here): the canvas
7472
+ // hydrates scale_source for its report and scale_confirmed for the
7473
+ // scale gate's confirm affordance — absent = confirmed (pre-flag docs)
7474
+ ...s.scaleSource ? { scale_source: s.scaleSource } : {},
7475
+ ...s.scaleConfirmed === false ? { scale_confirmed: false } : {}
7476
+ })),
7462
7477
  conditions: this.conditions,
7463
7478
  shapes: this.shapes,
7464
7479
  markups: this.markups,
@@ -7488,7 +7503,7 @@ var Session = class _Session {
7488
7503
  projectName,
7489
7504
  rows,
7490
7505
  bySheet: sheetTotals(this.conditions, this.shapes),
7491
- scaleInfo: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, scale_source: s.scaleSource ?? "unknown" })),
7506
+ scaleInfo: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, scale_source: s.scaleSource ?? "unknown", scale_confirmed: s.scaleConfirmed !== false })),
7492
7507
  markups: this.markups,
7493
7508
  rfis: [],
7494
7509
  rollGoods: rollReportRows(byCond, rows)
@@ -7722,6 +7737,7 @@ var setScaleOutput = {
7722
7737
  upp: z.number().describe("Real feet per image px at render scale 2.0"),
7723
7738
  label: z.string().optional().describe("The standard scale label, when set by label or detected note"),
7724
7739
  source: z.enum(["label", "upp", "calibrate", "detected"]),
7740
+ confirmed: z.boolean().describe("Always false here: set_scale is the agent surface, and an agent-set scale stays UNCONFIRMED until a human confirms it in the canvas \u2014 quantities still flow, wearing the caveat"),
7725
7741
  warning: z.string().optional().describe("Present when the sheet carries MULTIPLE distinct scale notes (#153) \u2014 enlarged plans/details likely; region measurements under a disagreeing note will warn")
7726
7742
  };
7727
7743
  var oneClickOutput = {
@@ -7888,7 +7904,8 @@ var takeoffSummaryOutput = {
7888
7904
  lf_net: z.number(),
7889
7905
  ea: z.number(),
7890
7906
  sy_net: z.number()
7891
- }).passthrough()
7907
+ }).passthrough(),
7908
+ scale_unconfirmed: z.array(z.string()).optional().describe("Sheets whose scale is agent-set and no human has confirmed \u2014 these totals stand on an unverified scale; verify against a stated dimension or confirm in the canvas")
7892
7909
  };
7893
7910
  var exportTakeoffOutput = {
7894
7911
  schema: z.string(),
@@ -8790,6 +8807,80 @@ function rfiStatus(id) {
8790
8807
  return STATUS_BY_ID[id] || RFI_STATUSES[0];
8791
8808
  }
8792
8809
 
8810
+ // ../web/src/lib/stitches.ts
8811
+ function stitchExtent(members, dims) {
8812
+ let w = 0, h = 0;
8813
+ for (const m of members) {
8814
+ const d = dims[m.key];
8815
+ if (!d?.w) continue;
8816
+ w = Math.max(w, m.dx + d.w);
8817
+ h = Math.max(h, m.dy + d.h);
8818
+ }
8819
+ return { w: Math.ceil(w), h: Math.ceil(h) };
8820
+ }
8821
+ function memberBoxes(members, dims) {
8822
+ return members.map((m) => {
8823
+ const d = dims[m.key] || { w: 0, h: 0 };
8824
+ return { x0: m.dx, y0: m.dy, x1: m.dx + d.w, y1: m.dy + d.h };
8825
+ });
8826
+ }
8827
+ function seamClips(members, dims) {
8828
+ const boxes = memberBoxes(members, dims);
8829
+ const clips = boxes.map((b) => ({ ...b }));
8830
+ if (boxes.length < 2) return clips;
8831
+ const cxs = boxes.map((b) => (b.x0 + b.x1) / 2), cys = boxes.map((b) => (b.y0 + b.y1) / 2);
8832
+ const spread = (a) => Math.max(...a) - Math.min(...a);
8833
+ const horizontal = spread(cxs) >= spread(cys);
8834
+ const order = boxes.map((_, i) => i).sort((a, b) => horizontal ? boxes[a].x0 - boxes[b].x0 : boxes[a].y0 - boxes[b].y0);
8835
+ for (let k = 0; k + 1 < order.length; k++) {
8836
+ const i = order[k], j = order[k + 1];
8837
+ if (horizontal && boxes[j].x0 < boxes[i].x1) {
8838
+ const seam = (boxes[j].x0 + boxes[i].x1) / 2;
8839
+ clips[i].x1 = Math.min(clips[i].x1, seam);
8840
+ clips[j].x0 = Math.max(clips[j].x0, seam);
8841
+ } else if (!horizontal && boxes[j].y0 < boxes[i].y1) {
8842
+ const seam = (boxes[j].y0 + boxes[i].y1) / 2;
8843
+ clips[i].y1 = Math.min(clips[i].y1, seam);
8844
+ clips[j].y0 = Math.max(clips[j].y0, seam);
8845
+ }
8846
+ }
8847
+ return clips;
8848
+ }
8849
+ function stitchPagePlan(members, dims) {
8850
+ const clips = seamClips(members, dims);
8851
+ return {
8852
+ extent: stitchExtent(members, dims),
8853
+ members: members.map((m, i) => ({ key: m.key, dx: m.dx, dy: m.dy, clip: clips[i] }))
8854
+ };
8855
+ }
8856
+ function memberEmbed(vpTransform, member, pageH, renderScale) {
8857
+ const [a, b, c, d, e, f] = Array.from(vpTransform);
8858
+ const det = a * d - b * c;
8859
+ const inv = (x, y) => [(d * (x - e) - c * (y - f)) / det, (-b * (x - e) + a * (y - f)) / det];
8860
+ const { clip, dx, dy } = member;
8861
+ const corners = [
8862
+ inv(clip.x0 - dx, clip.y0 - dy),
8863
+ inv(clip.x1 - dx, clip.y0 - dy),
8864
+ inv(clip.x0 - dx, clip.y1 - dy),
8865
+ inv(clip.x1 - dx, clip.y1 - dy)
8866
+ ];
8867
+ const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]);
8868
+ const z4 = (n) => n + 0 || 0;
8869
+ return {
8870
+ bbox: { left: z4(Math.min(...xs)), bottom: z4(Math.min(...ys)), right: z4(Math.max(...xs)), top: z4(Math.max(...ys)) },
8871
+ // user → visual px (vpTransform), + stitch offset, ÷ renderScale into
8872
+ // points, y flipped into PDF's y-up — composed into one affine
8873
+ matrix: [
8874
+ a / renderScale,
8875
+ -b / renderScale,
8876
+ c / renderScale,
8877
+ -d / renderScale,
8878
+ (e + dx) / renderScale,
8879
+ pageH - (f + dy) / renderScale
8880
+ ]
8881
+ };
8882
+ }
8883
+
8793
8884
  // ../web/src/lib/lineStyles.js
8794
8885
  var LINE_STYLES = {
8795
8886
  solid: { label: "Solid", dash: null },
@@ -9165,7 +9256,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
9165
9256
  for (const sh of marked) {
9166
9257
  if (y < 90) break;
9167
9258
  const items = shapesBy.get(sh.key) || [];
9168
- draw(`${sh.label} \xB7 page ${sh.page} \xB7 ${items.length + (marksBy.get(sh.key) || []).length + (apBy.get(sh.key) || []).length} item(s)`, { x: 52, y, size: 9.5, font: bold, color: ink });
9259
+ const where = sh.stitch ? `stitched \xB7 ${sh.stitch.members.length} sheets` : `page ${sh.page}`;
9260
+ draw(`${sh.label} \xB7 ${where} \xB7 ${items.length + (marksBy.get(sh.key) || []).length + (apBy.get(sh.key) || []).length} item(s)`, { x: 52, y, size: 9.5, font: bold, color: ink });
9169
9261
  y -= 13;
9170
9262
  for (const r of bySheetId.get(sh.key)?.rows || []) {
9171
9263
  if (y < 92) break;
@@ -9244,37 +9336,100 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
9244
9336
  }
9245
9337
  }
9246
9338
  const srcDocs = /* @__PURE__ */ new Map();
9339
+ const srcDocFor = async (file) => {
9340
+ let src = srcDocs.get(file);
9341
+ if (!src) {
9342
+ src = await PDFDocument.load(await loadPdfData(file), { ignoreEncryption: true });
9343
+ srcDocs.set(file, src);
9344
+ }
9345
+ return src;
9346
+ };
9247
9347
  for (const sh of marked) {
9248
- const page = await getPage(sh.file, sh.page);
9249
- const vpR = page.getViewport({ scale: RENDER_SCALE });
9250
- const W = vpR.width, H = vpR.height;
9251
- let pg, toPage, chipRot = degrees(0);
9252
- if (dark) {
9253
- const vp1 = page.getViewport({ scale: 1 });
9254
- const s = Math.min(RASTER_MAX / Math.max(vp1.width, vp1.height), 4);
9255
- const vp = page.getViewport({ scale: s });
9256
- const cv = document.createElement("canvas");
9257
- cv.width = Math.ceil(vp.width);
9258
- cv.height = Math.ceil(vp.height);
9259
- await page.render({ canvasContext: cv.getContext("2d"), viewport: vp }).promise;
9260
- invertPixels(cv);
9261
- const png = await doc.embedPng(cv.toDataURL("image/png"));
9262
- pg = doc.addPage([vp1.width, vp1.height]);
9263
- pg.drawImage(png, { x: 0, y: 0, width: vp1.width, height: vp1.height });
9264
- const k = vp1.width / W;
9265
- toPage = (x, y) => [x * k, vp1.height - y * k];
9348
+ let pg, toPage, chipRot = degrees(0), W, H;
9349
+ if (sh.stitch) {
9350
+ const members = sh.stitch.members;
9351
+ const pages = [];
9352
+ const dims = {};
9353
+ for (const m of members) {
9354
+ const page = await getPage(m.file, m.page);
9355
+ const vpR = page.getViewport({ scale: RENDER_SCALE });
9356
+ dims[m.key] = { w: vpR.width, h: vpR.height };
9357
+ pages.push({ m, page, vpR });
9358
+ }
9359
+ const plan = stitchPagePlan(members, dims);
9360
+ W = plan.extent.w;
9361
+ H = plan.extent.h;
9362
+ const pageW = W / RENDER_SCALE, pageH = H / RENDER_SCALE;
9363
+ if (dark) {
9364
+ const s = Math.min(RASTER_MAX / Math.max(pageW, pageH), 4);
9365
+ const cv = document.createElement("canvas");
9366
+ cv.width = Math.ceil(pageW * s);
9367
+ cv.height = Math.ceil(pageH * s);
9368
+ const ctx = cv.getContext("2d");
9369
+ ctx.fillStyle = "#fff";
9370
+ ctx.fillRect(0, 0, cv.width, cv.height);
9371
+ const k = s / RENDER_SCALE;
9372
+ for (let i = 0; i < pages.length; i++) {
9373
+ const { page } = pages[i], pm = plan.members[i];
9374
+ const vp = page.getViewport({ scale: s });
9375
+ const mc = document.createElement("canvas");
9376
+ mc.width = Math.ceil(vp.width);
9377
+ mc.height = Math.ceil(vp.height);
9378
+ await page.render({ canvasContext: mc.getContext("2d"), viewport: vp }).promise;
9379
+ ctx.save();
9380
+ ctx.beginPath();
9381
+ ctx.rect(pm.clip.x0 * k, pm.clip.y0 * k, (pm.clip.x1 - pm.clip.x0) * k, (pm.clip.y1 - pm.clip.y0) * k);
9382
+ ctx.clip();
9383
+ ctx.drawImage(mc, pm.dx * k, pm.dy * k);
9384
+ ctx.restore();
9385
+ }
9386
+ invertPixels(cv);
9387
+ const png = await doc.embedPng(cv.toDataURL("image/png"));
9388
+ pg = doc.addPage([pageW, pageH]);
9389
+ pg.drawImage(png, { x: 0, y: 0, width: pageW, height: pageH });
9390
+ } else {
9391
+ pg = doc.addPage([pageW, pageH]);
9392
+ for (let i = 0; i < pages.length; i++) {
9393
+ const { m, vpR } = pages[i], pm = plan.members[i];
9394
+ const src = await srcDocFor(m.file);
9395
+ const { bbox, matrix } = memberEmbed(vpR.transform, pm, pageH, RENDER_SCALE);
9396
+ const emb = await doc.embedPage(src.getPage(m.page - 1), bbox, matrix);
9397
+ pg.drawPage(emb, { x: 0, y: 0 });
9398
+ }
9399
+ }
9400
+ toPage = (x, y) => [x / RENDER_SCALE, pageH - y / RENDER_SCALE];
9266
9401
  } else {
9267
- let src = srcDocs.get(sh.file);
9268
- if (!src) {
9269
- src = await PDFDocument.load(await loadPdfData(sh.file), { ignoreEncryption: true });
9270
- srcDocs.set(sh.file, src);
9271
- }
9272
- const [copied] = await doc.copyPages(src, [sh.page - 1]);
9273
- pg = doc.addPage(copied);
9274
- const [a, b, c, d, e, f] = vpR.transform;
9275
- const det = a * d - b * c;
9276
- toPage = (x, y) => [(d * (x - e) - c * (y - f)) / det, (-b * (x - e) + a * (y - f)) / det];
9277
- chipRot = degrees(page.rotate || 0);
9402
+ const page = await getPage(sh.file, sh.page);
9403
+ const vpR = page.getViewport({ scale: RENDER_SCALE });
9404
+ W = vpR.width;
9405
+ H = vpR.height;
9406
+ if (dark) {
9407
+ const vp1 = page.getViewport({ scale: 1 });
9408
+ const s = Math.min(RASTER_MAX / Math.max(vp1.width, vp1.height), 4);
9409
+ const vp = page.getViewport({ scale: s });
9410
+ const cv = document.createElement("canvas");
9411
+ cv.width = Math.ceil(vp.width);
9412
+ cv.height = Math.ceil(vp.height);
9413
+ await page.render({ canvasContext: cv.getContext("2d"), viewport: vp }).promise;
9414
+ invertPixels(cv);
9415
+ const png = await doc.embedPng(cv.toDataURL("image/png"));
9416
+ pg = doc.addPage([vp1.width, vp1.height]);
9417
+ pg.drawImage(png, { x: 0, y: 0, width: vp1.width, height: vp1.height });
9418
+ const k = vp1.width / W;
9419
+ toPage = (x, y) => [x * k, vp1.height - y * k];
9420
+ } else {
9421
+ let src = srcDocs.get(sh.file);
9422
+ if (!src) {
9423
+ src = await PDFDocument.load(await loadPdfData(sh.file), { ignoreEncryption: true });
9424
+ srcDocs.set(sh.file, src);
9425
+ }
9426
+ const [copied] = await doc.copyPages(src, [sh.page - 1]);
9427
+ pg = doc.addPage(copied);
9428
+ const [a, b, c, d, e, f] = vpR.transform;
9429
+ const det = a * d - b * c;
9430
+ toPage = (x, y) => [(d * (x - e) - c * (y - f)) / det, (-b * (x - e) + a * (y - f)) / det];
9431
+ chipRot = degrees(page.rotate || 0);
9432
+ }
9278
9433
  }
9279
9434
  const ptScale = Math.hypot(...(() => {
9280
9435
  const p0 = toPage(0, 0), p1 = toPage(1, 0);
@@ -9478,7 +9633,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
9478
9633
  const tw = bold.widthOfTextAtSize(label, size);
9479
9634
  pg.drawText(label, { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: acol, rotate: chipRot });
9480
9635
  }
9481
- text(`${sh.label} \xB7 marked set`, 14, 20, 8, muted);
9636
+ const stamp = sh.stitch ? `${sh.label} \xB7 stitched composite (${sh.stitch.members.map((m) => m.label || m.key).join(" + ")}) \xB7 marked set` : `${sh.label} \xB7 marked set`;
9637
+ text(stamp, 14, 20, 8, muted);
9482
9638
  }
9483
9639
  const allPages = doc.getPages();
9484
9640
  const lastPg = allPages[allPages.length - 1];
@@ -9792,6 +9948,7 @@ async function importTakeoff(session, filePath) {
9792
9948
  if (s && s.upp == null && row.units_per_px > 0) {
9793
9949
  s.upp = row.units_per_px;
9794
9950
  s.scaleSource = row.scale_source ?? "upp";
9951
+ if (row.scale_confirmed === false) s.scaleConfirmed = false;
9795
9952
  }
9796
9953
  }
9797
9954
  const tagKey2 = (t) => String(t || "").trim().toUpperCase();
@@ -9838,7 +9995,16 @@ var run = (tool, fn) => async (args) => {
9838
9995
  traceToolCall(tool, args, startedAt, reply);
9839
9996
  return reply;
9840
9997
  };
9841
- function registerTools(server, session) {
9998
+ function registerTools(realServer, session) {
9999
+ const registered = /* @__PURE__ */ new Map();
10000
+ const server = {
10001
+ registerTool(name, meta, handler) {
10002
+ const tool = realServer.registerTool(name, meta, handler);
10003
+ registered.set(name, tool);
10004
+ return tool;
10005
+ },
10006
+ sendResourceListChanged: () => realServer.sendResourceListChanged()
10007
+ };
9842
10008
  server.registerTool("load_plan", {
9843
10009
  description: `Open a plan PDF from disk. Default: replace the whole session (previous documents, scales, conditions, and shapes are cleared). merge: true ADDS the document to the working set instead (#152) \u2014 a bid set is plans + schedule + addenda, not one PDF \u2014 keeping every scale, condition, and shape; sheet keys carry file names so documents never collide, the sheet graph spans the whole set (resolve_tag can chain a plan tag on one file to a schedule row in another), and the marked set covers every worked sheet. Re-loading an already-merged file is refused \u2014 reload = replace, deliberately. Returns file, files, page_count, and one entry per sheet. The loaded sheets also become browsable resources (takeoff://sheets). ${COORDS}`,
9844
10010
  inputSchema: {
@@ -10294,6 +10460,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
10294
10460
  },
10295
10461
  outputSchema: deleteVerdictOutput
10296
10462
  }, run("delete_verdict", ({ verdict_id }) => session.deleteVerdict(verdict_id)));
10463
+ return registered;
10297
10464
  }
10298
10465
 
10299
10466
  // src/resources.ts
@@ -10372,10 +10539,107 @@ function registerResources(server, session) {
10372
10539
  );
10373
10540
  }
10374
10541
 
10542
+ // src/staging.ts
10543
+ import { z as z3 } from "zod";
10544
+ var TOOL_STAGES = {
10545
+ // Always enabled: an agent needs these to orient before anything else is useful.
10546
+ setup: [
10547
+ "load_plan",
10548
+ "sheet_info",
10549
+ "set_scale",
10550
+ "sheet_graph",
10551
+ "resolve_tag",
10552
+ "find_schedule",
10553
+ "read_sheet_text",
10554
+ "find_text",
10555
+ "sheet_context",
10556
+ "view_sheet"
10557
+ ],
10558
+ measure: [
10559
+ "one_click",
10560
+ "detect_rooms",
10561
+ "measure_polygon",
10562
+ "cut_out",
10563
+ "measure_line",
10564
+ "measure_surface",
10565
+ "place_count",
10566
+ "symbol_sweep",
10567
+ "sweep_schedule_row",
10568
+ "derive_base",
10569
+ "derive_transitions"
10570
+ ],
10571
+ revise: [
10572
+ "list_shapes",
10573
+ "delete_shape",
10574
+ "edit_shape",
10575
+ "edit_materials",
10576
+ "edit_condition",
10577
+ "duplicate_condition",
10578
+ "split_condition",
10579
+ "undo_last",
10580
+ "annotate",
10581
+ "list_annotations",
10582
+ "link_annotation",
10583
+ "mark_verdict",
10584
+ "delete_verdict"
10585
+ ],
10586
+ handoff: [
10587
+ "takeoff_summary",
10588
+ "export_takeoff",
10589
+ "export_report",
10590
+ "import_takeoff",
10591
+ "apply_rules",
10592
+ "export_marked_pdf"
10593
+ ]
10594
+ };
10595
+ var OPENABLE = ["measure", "revise", "handoff"];
10596
+ var openToolStageOutput = {
10597
+ stage: z3.string().describe("The stage that was opened"),
10598
+ enabled: z3.array(z3.string()).describe("Tool names enabled by this call (empty if the stage was already open)"),
10599
+ open_stages: z3.array(z3.string()).describe("Every stage currently enabled, setup included"),
10600
+ closed_stages: z3.array(z3.string()).describe("Stages still closed \u2014 open them here when the work reaches them")
10601
+ };
10602
+ var STAGED_INSTRUCTIONS = 'TOOL EXPOSURE IS STAGED: only the setup tools are enabled at start. Before measuring, call open_tool_stage {stage:"measure"}; likewise "revise" for edit/annotate/verdict tools and "handoff" for summaries and exports. Opening a stage is instant, idempotent, and never closes anything.';
10603
+ function applyStagedTools(server, registered) {
10604
+ const openStages = /* @__PURE__ */ new Set(["setup"]);
10605
+ for (const stage of OPENABLE) {
10606
+ for (const name of TOOL_STAGES[stage]) registered.get(name)?.disable();
10607
+ }
10608
+ server.registerTool("open_tool_stage", {
10609
+ description: `Enable a stage of this server's tools. Tool exposure is staged to match the takeoff workflow: "setup" (orient: load, scale, read the set) is always enabled; "measure" (commit shapes: one_click, detect_rooms, measure_*, sweeps and derives), "revise" (edit, annotate, verdict-mark, undo), and "handoff" (summaries, exports, the marked set) start closed and open here on demand. Opening a stage is idempotent and never closes another \u2014 the surface only grows. Call it the moment the work reaches a closed stage; the reply lists exactly which tools just became available.`,
10610
+ inputSchema: {
10611
+ stage: z3.enum(OPENABLE).describe('Which stage to enable: "measure", "revise", or "handoff"')
10612
+ },
10613
+ outputSchema: openToolStageOutput
10614
+ }, async ({ stage }) => {
10615
+ try {
10616
+ const names = TOOL_STAGES[stage];
10617
+ const enabled = [];
10618
+ for (const name of names) {
10619
+ const tool = registered.get(name);
10620
+ if (!tool) throw new UserError(`Stage table names an unregistered tool: ${name}`);
10621
+ if (!tool.enabled) {
10622
+ tool.enable();
10623
+ enabled.push(name);
10624
+ }
10625
+ }
10626
+ openStages.add(stage);
10627
+ return ok({
10628
+ stage,
10629
+ enabled,
10630
+ open_stages: Object.keys(TOOL_STAGES).filter((s) => openStages.has(s)),
10631
+ closed_stages: Object.keys(TOOL_STAGES).filter((s) => !openStages.has(s))
10632
+ });
10633
+ } catch (e) {
10634
+ return fail(e);
10635
+ }
10636
+ });
10637
+ }
10638
+
10375
10639
  // package.json
10376
10640
  var package_default = {
10377
10641
  name: "opentakeoff-mcp",
10378
- version: "0.9.38",
10642
+ version: "0.9.40",
10379
10643
  mcpName: "io.github.Kentucky-ai/opentakeoff",
10380
10644
  type: "module",
10381
10645
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -10391,7 +10655,7 @@ var package_default = {
10391
10655
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
10392
10656
  prepublishOnly: "npm run typecheck && npm test && npm run build",
10393
10657
  typecheck: "tsc --noEmit",
10394
- test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
10658
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
10395
10659
  },
10396
10660
  dependencies: {
10397
10661
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -10448,7 +10712,8 @@ var package_default = {
10448
10712
  };
10449
10713
 
10450
10714
  // server.ts
10451
- function buildServer(session = new Session()) {
10715
+ function buildServer(session = new Session(), opts = {}) {
10716
+ const staged = opts.stagedTools ?? process.env.OPENTAKEOFF_MCP_STAGED_TOOLS === "1";
10452
10717
  const server = new McpServer2({ name: "opentakeoff", version: package_default.version }, {
10453
10718
  // Served to every client at initialize — the discipline that makes agent
10454
10719
  // takeoffs land as reviewable work instead of a bare numbers report.
@@ -10460,11 +10725,13 @@ function buildServer(session = new Session()) {
10460
10725
  "3. DERIVE what follows from the rooms instead of re-measuring it: derive_base for base LF (perimeter \u2212 the door openings YOU state), derive_transitions for the line where two finishes meet. Both read committed floor shapes, so they come after step 2 and their output is audited in step 4 like anything else.",
10461
10726
  "4. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
10462
10727
  "5. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone.",
10463
- "WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse."
10728
+ "WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.",
10729
+ ...staged ? [STAGED_INSTRUCTIONS] : []
10464
10730
  ].join("\n")
10465
10731
  });
10466
- registerTools(server, session);
10732
+ const registered = registerTools(server, session);
10467
10733
  registerResources(server, session);
10734
+ if (staged) applyStagedTools(server, registered);
10468
10735
  return server;
10469
10736
  }
10470
10737
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.38",
3
+ "version": "0.9.40",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
@@ -16,7 +16,7 @@
16
16
  "mcpb": "npm run build && node scripts/build-mcpb.mjs",
17
17
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
18
18
  "typecheck": "tsc --noEmit",
19
- "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
19
+ "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",