opentakeoff-mcp 0.9.39 → 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,6 +101,28 @@ 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 |
@@ -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;
@@ -4860,7 +4860,7 @@ function drawShapes(ctx, toCanvas, shapes, sheetW, sheetH, longEdge) {
4860
4860
  var SNAP_CELL = 24;
4861
4861
  var SNAP_TOL = 7;
4862
4862
  var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
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"];
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"];
4864
4864
  var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
4865
4865
  var uid = (p) => `${p}-${mintUuid2()}`;
4866
4866
  var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
@@ -8807,6 +8807,80 @@ function rfiStatus(id) {
8807
8807
  return STATUS_BY_ID[id] || RFI_STATUSES[0];
8808
8808
  }
8809
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
+
8810
8884
  // ../web/src/lib/lineStyles.js
8811
8885
  var LINE_STYLES = {
8812
8886
  solid: { label: "Solid", dash: null },
@@ -9182,7 +9256,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
9182
9256
  for (const sh of marked) {
9183
9257
  if (y < 90) break;
9184
9258
  const items = shapesBy.get(sh.key) || [];
9185
- 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 });
9186
9261
  y -= 13;
9187
9262
  for (const r of bySheetId.get(sh.key)?.rows || []) {
9188
9263
  if (y < 92) break;
@@ -9261,37 +9336,100 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
9261
9336
  }
9262
9337
  }
9263
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
+ };
9264
9347
  for (const sh of marked) {
9265
- const page = await getPage(sh.file, sh.page);
9266
- const vpR = page.getViewport({ scale: RENDER_SCALE });
9267
- const W = vpR.width, H = vpR.height;
9268
- let pg, toPage, chipRot = degrees(0);
9269
- if (dark) {
9270
- const vp1 = page.getViewport({ scale: 1 });
9271
- const s = Math.min(RASTER_MAX / Math.max(vp1.width, vp1.height), 4);
9272
- const vp = page.getViewport({ scale: s });
9273
- const cv = document.createElement("canvas");
9274
- cv.width = Math.ceil(vp.width);
9275
- cv.height = Math.ceil(vp.height);
9276
- await page.render({ canvasContext: cv.getContext("2d"), viewport: vp }).promise;
9277
- invertPixels(cv);
9278
- const png = await doc.embedPng(cv.toDataURL("image/png"));
9279
- pg = doc.addPage([vp1.width, vp1.height]);
9280
- pg.drawImage(png, { x: 0, y: 0, width: vp1.width, height: vp1.height });
9281
- const k = vp1.width / W;
9282
- 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];
9283
9401
  } else {
9284
- let src = srcDocs.get(sh.file);
9285
- if (!src) {
9286
- src = await PDFDocument.load(await loadPdfData(sh.file), { ignoreEncryption: true });
9287
- srcDocs.set(sh.file, src);
9288
- }
9289
- const [copied] = await doc.copyPages(src, [sh.page - 1]);
9290
- pg = doc.addPage(copied);
9291
- const [a, b, c, d, e, f] = vpR.transform;
9292
- const det = a * d - b * c;
9293
- toPage = (x, y) => [(d * (x - e) - c * (y - f)) / det, (-b * (x - e) + a * (y - f)) / det];
9294
- 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
+ }
9295
9433
  }
9296
9434
  const ptScale = Math.hypot(...(() => {
9297
9435
  const p0 = toPage(0, 0), p1 = toPage(1, 0);
@@ -9495,7 +9633,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
9495
9633
  const tw = bold.widthOfTextAtSize(label, size);
9496
9634
  pg.drawText(label, { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: acol, rotate: chipRot });
9497
9635
  }
9498
- 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);
9499
9638
  }
9500
9639
  const allPages = doc.getPages();
9501
9640
  const lastPg = allPages[allPages.length - 1];
@@ -9856,7 +9995,16 @@ var run = (tool, fn) => async (args) => {
9856
9995
  traceToolCall(tool, args, startedAt, reply);
9857
9996
  return reply;
9858
9997
  };
9859
- 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
+ };
9860
10008
  server.registerTool("load_plan", {
9861
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}`,
9862
10010
  inputSchema: {
@@ -10312,6 +10460,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
10312
10460
  },
10313
10461
  outputSchema: deleteVerdictOutput
10314
10462
  }, run("delete_verdict", ({ verdict_id }) => session.deleteVerdict(verdict_id)));
10463
+ return registered;
10315
10464
  }
10316
10465
 
10317
10466
  // src/resources.ts
@@ -10390,10 +10539,107 @@ function registerResources(server, session) {
10390
10539
  );
10391
10540
  }
10392
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
+
10393
10639
  // package.json
10394
10640
  var package_default = {
10395
10641
  name: "opentakeoff-mcp",
10396
- version: "0.9.39",
10642
+ version: "0.9.40",
10397
10643
  mcpName: "io.github.Kentucky-ai/opentakeoff",
10398
10644
  type: "module",
10399
10645
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -10409,7 +10655,7 @@ var package_default = {
10409
10655
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
10410
10656
  prepublishOnly: "npm run typecheck && npm test && npm run build",
10411
10657
  typecheck: "tsc --noEmit",
10412
- 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"
10413
10659
  },
10414
10660
  dependencies: {
10415
10661
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -10466,7 +10712,8 @@ var package_default = {
10466
10712
  };
10467
10713
 
10468
10714
  // server.ts
10469
- function buildServer(session = new Session()) {
10715
+ function buildServer(session = new Session(), opts = {}) {
10716
+ const staged = opts.stagedTools ?? process.env.OPENTAKEOFF_MCP_STAGED_TOOLS === "1";
10470
10717
  const server = new McpServer2({ name: "opentakeoff", version: package_default.version }, {
10471
10718
  // Served to every client at initialize — the discipline that makes agent
10472
10719
  // takeoffs land as reviewable work instead of a bare numbers report.
@@ -10478,11 +10725,13 @@ function buildServer(session = new Session()) {
10478
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.",
10479
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).",
10480
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.",
10481
- "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] : []
10482
10730
  ].join("\n")
10483
10731
  });
10484
- registerTools(server, session);
10732
+ const registered = registerTools(server, session);
10485
10733
  registerResources(server, session);
10734
+ if (staged) applyStagedTools(server, registered);
10486
10735
  return server;
10487
10736
  }
10488
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.39",
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",