opentakeoff-mcp 0.5.0 → 0.6.1

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
@@ -27,9 +27,9 @@ No Node, no npm: download **`opentakeoff-mcp.mcpb`** from the
27
27
  double-click it — Claude Desktop installs the server with its dependencies
28
28
  bundled. Built by `npm run mcpb` and attached automatically to every `mcp-v*`
29
29
  release. The bundle is platform-neutral on purpose: it excludes the optional
30
- native canvas, so every tool and the text/metadata resources work everywhere,
31
- and the sheet-image resource says exactly what's missing where rendering isn't
32
- available.
30
+ native canvas, so every JSON tool and the text/metadata resources work
31
+ everywhere; the sheet-image resource and the `view_sheet` tool say exactly
32
+ what's missing where rendering isn't available.
33
33
 
34
34
 
35
35
  The takeoff engine — One-Click Area, the scale model, conditions, totals — on
@@ -115,12 +115,15 @@ includes document text, shape vertices, or result payload content.
115
115
  | `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
116
116
  | `delete_shape` | Remove a committed shape by id. |
117
117
  | `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
118
+ | `view_sheet` | The agent's eyes: render the sheet (or an image-px crop) to PNG. `overlay` burns committed shapes in (solid = human-affirmed, dashed = unreviewed) to verify geometry landed; `grid` burns in a calibrated 1-ft/5-ft measuring grid with foot labels (`"auto"` from the set scale, or the drawing scale like `"1/4"`) so dimensions are counted off cells, not guessed. |
118
119
 
119
- Every tool declares an **`outputSchema`**, and every reply carries the payload
120
- as **`structuredContent`** — typed, machine-validated on every call — alongside
121
- the same compact JSON in a single text item for clients that predate structured
122
- output. Failures come back as `isError: true` with `{"error": "..."}` never a
123
- dropped connection.
120
+ Every JSON tool declares an **`outputSchema`**, and every reply carries the
121
+ payload as **`structuredContent`** — typed, machine-validated on every call —
122
+ alongside the same compact JSON in a single text item for clients that predate
123
+ structured output. `view_sheet` is the one image tool: its reply is a PNG
124
+ content item plus a JSON meta text item (image replies aren't structured
125
+ output, so it declares no schema by design). Failures come back as
126
+ `isError: true` with `{"error": "..."}` — never a dropped connection.
124
127
 
125
128
  ## Resources — browse before you measure
126
129
 
@@ -209,19 +212,25 @@ npm test # session + tool-layer + e2e, against demo/sample-plan.pdf
209
212
  ## Releasing (maintainers)
210
213
 
211
214
  MCP releases live in the **`mcp-v*`** tag namespace — bare `v*` tags belong to
212
- the app (v0.2.0, v0.3.0 are app releases). The npm artifact publishes manually
213
- (hardware-key 2FA) **before** the tag is pushed; the workflow refuses to run
214
- ahead of it.
215
+ the app (v0.2.0, v0.3.0 are app releases). Releases publish via **npm trusted
216
+ publishing**: the tag push fires `.github/workflows/publish-mcp.yml`, which
217
+ pauses at the `release` environment for maintainer approval, then publishes
218
+ the npm artifact over OIDC with a **provenance attestation** (no npm token
219
+ exists anywhere — the npm package designates that exact repo + workflow as
220
+ its trusted publisher), followed by the MCP registry entry, the GitHub
221
+ release, and the MCPB bundle.
215
222
 
216
223
  ```bash
217
224
  # 1. bump the version — all three fields together:
218
225
  # package.json .version, server.json .version, server.json .packages[0].version
219
- # 2. from mcp/, publish with the hardware key:
220
- npm publish
221
- # 3. tag and push — this fires .github/workflows/publish-mcp.yml:
226
+ # 2. tag and push this fires the whole release:
222
227
  git tag mcp-v<version> && git push origin mcp-v<version>
228
+ # 3. approve the run (GitHub → Actions → the paused "Publish to MCP Registry" run)
223
229
  ```
224
230
 
225
- The workflow checks version consistency, requires the npm artifact to exist,
226
- publishes to the official MCP registry via GitHub OIDC, verifies the listing,
227
- and creates the GitHub release (titled `opentakeoff-mcp <version>`).
231
+ The workflow checks version consistency, runs the full publish gate
232
+ (`prepublishOnly` = typecheck + tests + build), publishes to npm and the
233
+ official MCP registry, verifies the registry listing, and creates the GitHub
234
+ release (titled `opentakeoff-mcp <version>`). A re-run skips the npm publish
235
+ if that version already shipped, so a transient failure downstream is safe to
236
+ retry.
@@ -178,6 +178,28 @@ async function openPdf(filePath) {
178
178
  } finally {
179
179
  factory.destroy(target);
180
180
  }
181
+ },
182
+ async renderRegionPng(region, longEdge, draw) {
183
+ await ensureCanvasGlobals();
184
+ const w = region.x1 - region.x0;
185
+ const h = region.y1 - region.y0;
186
+ const zoom = longEdge / Math.max(w, h);
187
+ const width = Math.max(1, Math.round(w * zoom));
188
+ const height = Math.max(1, Math.round(h * zoom));
189
+ const rvp = page.getViewport({
190
+ scale: RENDER_SCALE * zoom,
191
+ offsetX: -region.x0 * zoom,
192
+ offsetY: -region.y0 * zoom
193
+ });
194
+ const factory = doc.canvasFactory;
195
+ const target = factory.create(width, height);
196
+ try {
197
+ await page.render({ canvasContext: target.context, viewport: rvp }).promise;
198
+ draw?.(target.context, (x, y) => [(x - region.x0) * zoom, (y - region.y0) * zoom]);
199
+ return { png: new Uint8Array(target.canvas.toBuffer("image/png")), width, height, zoom };
200
+ } finally {
201
+ factory.destroy(target);
202
+ }
181
203
  }
182
204
  };
183
205
  },
@@ -202,6 +224,12 @@ var ok = (payload) => ({
202
224
  structuredContent: payload,
203
225
  content: [{ type: "text", text: JSON.stringify(payload) }]
204
226
  });
227
+ var okImage = (png, meta) => ({
228
+ content: [
229
+ { type: "image", data: Buffer.from(png.buffer, png.byteOffset, png.byteLength).toString("base64"), mimeType: "image/png" },
230
+ { type: "text", text: JSON.stringify(meta) }
231
+ ]
232
+ });
205
233
  var fail = (err) => ({
206
234
  isError: true,
207
235
  content: [{ type: "text", text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }) }]
@@ -900,6 +928,86 @@ function grandTotals(rows) {
900
928
  };
901
929
  }
902
930
 
931
+ // src/view.ts
932
+ var INK = "#d91a1a";
933
+ var PENCIL = "#2659e6";
934
+ var GRID_MINOR = "rgba(184, 191, 217, 0.55)";
935
+ var GRID_MAJOR = "rgba(51, 107, 242, 0.6)";
936
+ var GRID_LABEL = "#336bf2";
937
+ function gridPxPerFoot(spec, upp) {
938
+ const s = (spec || "").trim().toLowerCase();
939
+ if (!s) return null;
940
+ if (s === "auto") {
941
+ if (upp == null) {
942
+ throw new UserError(`grid "auto" needs this sheet's scale set \u2014 call set_scale first, or pass the drawing scale read off the title block instead (e.g. grid: "1/4").`);
943
+ }
944
+ return 1 / upp;
945
+ }
946
+ let inPerFt;
947
+ if (s.includes("/")) {
948
+ const [num, den] = s.split("/", 2);
949
+ inPerFt = Number(num) / Number(den);
950
+ } else {
951
+ inPerFt = Number(s);
952
+ }
953
+ if (!Number.isFinite(inPerFt)) {
954
+ throw new UserError(`Bad grid scale ${JSON.stringify(spec)} \u2014 use inches-per-foot like "1/4", "3/16", or "0.25" (or "auto" once the scale is set).`);
955
+ }
956
+ if (inPerFt < 0.01 || inPerFt > 12) {
957
+ throw new UserError(`Grid scale out of range: ${JSON.stringify(spec)} \u2014 inches-per-foot must be between 0.01 and 12.`);
958
+ }
959
+ return inPerFt * 72 * RENDER_SCALE;
960
+ }
961
+ function drawGrid(ctx, toCanvas, region, ppf) {
962
+ const [ox, oy] = toCanvas(region.x0, region.y0);
963
+ const step = toCanvas(region.x0 + ppf, region.y0)[0] - ox;
964
+ const [ex, ey] = toCanvas(region.x1, region.y1);
965
+ const nx = Math.ceil((region.x1 - region.x0) / ppf);
966
+ const ny = Math.ceil((region.y1 - region.y0) / ppf);
967
+ ctx.setLineDash([]);
968
+ for (const major of [false, true]) {
969
+ ctx.strokeStyle = major ? GRID_MAJOR : GRID_MINOR;
970
+ ctx.lineWidth = major ? 1.5 : 0.75;
971
+ if (!major && step < 3) continue;
972
+ ctx.beginPath();
973
+ for (let i = 0; i <= nx; i++) {
974
+ if (i % 5 === 0 !== major) continue;
975
+ const x = ox + i * step;
976
+ ctx.moveTo(x, oy);
977
+ ctx.lineTo(x, ey);
978
+ }
979
+ for (let j = 0; j <= ny; j++) {
980
+ if (j % 5 === 0 !== major) continue;
981
+ const y = oy + j * step;
982
+ ctx.moveTo(ox, y);
983
+ ctx.lineTo(ex, y);
984
+ }
985
+ ctx.stroke();
986
+ }
987
+ const size = Math.max(9, Math.min(36, step * 0.38));
988
+ ctx.font = `${size}px sans-serif`;
989
+ ctx.fillStyle = GRID_LABEL;
990
+ for (let i = 0; i <= nx; i += 5) ctx.fillText(String(i), ox + i * step + 3, oy + size + 2);
991
+ for (let j = 5; j <= ny; j += 5) ctx.fillText(String(j), ox + 3, oy + j * step - 3);
992
+ }
993
+ function drawShapes(ctx, toCanvas, shapes, sheetW, sheetH, longEdge) {
994
+ const w = Math.max(1.4, longEdge / 700);
995
+ for (const s of shapes) {
996
+ const pts = s.verts_norm.map(([nx, ny]) => toCanvas(nx * sheetW, ny * sheetH));
997
+ if (pts.length < 2) continue;
998
+ const pending = s.origin?.reviewed === false;
999
+ ctx.strokeStyle = pending ? PENCIL : INK;
1000
+ ctx.lineWidth = w;
1001
+ ctx.setLineDash(pending ? [w * 4, w * 3] : []);
1002
+ ctx.beginPath();
1003
+ ctx.moveTo(pts[0][0], pts[0][1]);
1004
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
1005
+ if (s.measure_role !== "linear") ctx.closePath();
1006
+ ctx.stroke();
1007
+ }
1008
+ ctx.setLineDash([]);
1009
+ }
1010
+
903
1011
  // src/session.ts
904
1012
  var SNAP_CELL = 24;
905
1013
  var SNAP_TOL = 7;
@@ -909,6 +1017,9 @@ var mintUuid = () => globalThis.crypto && typeof globalThis.crypto.randomUUID ==
909
1017
  var uid = (p) => `${p}-${mintUuid()}`;
910
1018
  var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
911
1019
  var IMAGE_MAX_EDGE = 1568;
1020
+ var VIEW_MIN_PX = 200;
1021
+ var VIEW_DEFAULT_PX = 1400;
1022
+ var VIEW_MAX_PX = 2e3;
912
1023
  var sheetSummary = (s) => ({
913
1024
  sheet: s.key,
914
1025
  page: s.pageNum,
@@ -1005,6 +1116,39 @@ var Session = class {
1005
1116
  }
1006
1117
  return s.png;
1007
1118
  }
1119
+ /** view_sheet: render a sheet (or an image-px crop of it) to PNG, with an
1120
+ * optional committed-shapes overlay and calibrated measuring grid. The grid
1121
+ * draws under the overlay, both in canvas space after the page rasterizes. */
1122
+ async viewSheet(name, opts) {
1123
+ const s = this.sheet(name);
1124
+ const px = Math.max(VIEW_MIN_PX, Math.min(VIEW_MAX_PX, Math.round(opts.px ?? VIEW_DEFAULT_PX)));
1125
+ const clampX = (v) => Math.max(0, Math.min(v, s.widthPx));
1126
+ const clampY = (v) => Math.max(0, Math.min(v, s.heightPx));
1127
+ const r = opts.region ? { x0: clampX(opts.region.x0), y0: clampY(opts.region.y0), x1: clampX(opts.region.x1), y1: clampY(opts.region.y1) } : { x0: 0, y0: 0, x1: s.widthPx, y1: s.heightPx };
1128
+ if (!(r.x1 - r.x0 >= 1 && r.y1 - r.y0 >= 1)) {
1129
+ throw new UserError(`Empty view region \u2014 need x1 > x0 and y1 > y0 in image px inside the sheet (${s.widthPx} \xD7 ${s.heightPx}).`);
1130
+ }
1131
+ const ppf = gridPxPerFoot(opts.grid, s.upp);
1132
+ const sheetShapes = this.shapes.filter((x) => x.sheet_id === s.key);
1133
+ const { png, width, height, zoom } = await s.page.renderRegionPng(r, px, (ctx, toCanvas) => {
1134
+ if (ppf) drawGrid(ctx, toCanvas, r, ppf);
1135
+ if (opts.overlay) drawShapes(ctx, toCanvas, sheetShapes, s.widthPx, s.heightPx, px);
1136
+ });
1137
+ return {
1138
+ png,
1139
+ meta: {
1140
+ sheet: s.key,
1141
+ page: s.pageNum,
1142
+ sheet_px: [s.widthPx, s.heightPx],
1143
+ region: [round1(r.x0), round1(r.y0), round1(r.x1), round1(r.y1)],
1144
+ img_px: [width, height],
1145
+ zoom: +zoom.toFixed(4),
1146
+ overlay: !!opts.overlay,
1147
+ ...opts.overlay ? { shapes_drawn: sheetShapes.length } : {},
1148
+ grid_px_per_foot: ppf ? round2(ppf) : 0
1149
+ }
1150
+ };
1151
+ }
1008
1152
  async ensureGeometry(s) {
1009
1153
  if (!s.geo) {
1010
1154
  const opList = await s.page.operatorList();
@@ -1149,48 +1293,101 @@ var Session = class {
1149
1293
  * exactly like oneClick — just N of them from one call instead of N
1150
1294
  * reasoning-heavy round-trips. Same contract as oneClick: no scale → a
1151
1295
  * px-only preview per room; no condition → nothing commits (a review
1152
- * pass, not a proposal-acceptance gate — this server has none). A region
1153
- * that traces to a degenerate ring (<3 verts) is dropped from the batch
1154
- * rather than failing the whole call one bad label must not sink every
1155
- * other clean detection on the sheet. */
1296
+ * pass, not a proposal-acceptance gate — this server has none).
1297
+ *
1298
+ * Withholding nothing is committed until it survives all three, and the
1299
+ * batch NEVER silently drops work: every withheld seed is counted and
1300
+ * reasoned in `withheld`, because a room the tool knows it skipped is a
1301
+ * question the caller can ask, while a room it skipped silently is a hole
1302
+ * in a bid.
1303
+ * 1. degenerate — traced to fewer than 3 vertices.
1304
+ * 2. duplicate — two labels flooding one region (a room tagged twice, or
1305
+ * a legend number landing in the same space) trace to an identical
1306
+ * ring. Committing both double-counts the area with no signal, which
1307
+ * is the worst failure mode an estimating tool has. One region commits
1308
+ * once; the collapsed labels ride along on `merged_labels`.
1309
+ * 3. implausible — a flood trapped inside a room-number bubble, a door
1310
+ * swing, or a wall cavity is fully enclosed, so it traces clean and
1311
+ * `detectRegions` passes it. Area is the only thing that separates it
1312
+ * from a room. Withheld below `minAreaSf` (default 5 SF — smaller than
1313
+ * any real finished space; a broom closet is ~10 SF). Only applied
1314
+ * once a scale exists, since without one there is no real area to
1315
+ * judge and nothing commits anyway. */
1156
1316
  async detectRooms(name, opts) {
1157
1317
  const s = this.sheet(name);
1158
1318
  const mask = await this.ensureMask(name);
1159
1319
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1320
+ const minAreaSf = opts.minAreaSf ?? 5;
1160
1321
  const seeds = roomLabelSeeds(s.text);
1161
1322
  const regions = detectRegions(mask, seeds);
1162
- const rooms = regions.map((r) => {
1323
+ const withheld = { degenerate: 0, duplicate: 0, implausible: 0 };
1324
+ const byRing = /* @__PURE__ */ new Map();
1325
+ const order = [];
1326
+ for (const r of regions) {
1163
1327
  const ring = snapVertices(traceRegion(r.flood), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
1164
- if (ring.length < 3) return null;
1165
- const areaPx2 = ringArea(ring);
1166
- const perimPx = closedMetrics(ring).perim;
1167
- const common = {
1328
+ if (ring.length < 3) {
1329
+ withheld.degenerate++;
1330
+ continue;
1331
+ }
1332
+ const key = ring.map(([x, y]) => `${Math.round(x)},${Math.round(y)}`).join(";");
1333
+ const seen = byRing.get(key);
1334
+ if (seen) {
1335
+ seen.merged.push(r.str);
1336
+ withheld.duplicate++;
1337
+ continue;
1338
+ }
1339
+ const cand = {
1168
1340
  label: r.str,
1169
- nverts: ring.length,
1170
- ...r.flood.hatchFiltered ? { hatch_filtered: true } : {},
1171
- ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
1341
+ ring,
1342
+ areaPx2: ringArea(ring),
1343
+ perimPx: closedMetrics(ring).perim,
1344
+ seed: r.seed,
1345
+ hatch: !!r.flood.hatchFiltered,
1346
+ merged: []
1347
+ };
1348
+ byRing.set(key, cand);
1349
+ order.push(cand);
1350
+ }
1351
+ const upp = s.upp;
1352
+ const rooms = order.map((c) => {
1353
+ const common = {
1354
+ label: c.label,
1355
+ nverts: c.ring.length,
1356
+ ...c.merged.length ? { merged_labels: c.merged } : {},
1357
+ ...c.hatch ? { hatch_filtered: true } : {},
1358
+ ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
1172
1359
  };
1173
- if (s.upp == null) {
1174
- return { ...common, area_px2: round1(areaPx2), perimeter_px: round1(perimPx) };
1360
+ if (upp == null) {
1361
+ return { ...common, area_px2: round1(c.areaPx2), perimeter_px: round1(c.perimPx) };
1362
+ }
1363
+ const area_sf = round2(c.areaPx2 * upp * upp);
1364
+ if (area_sf < minAreaSf) {
1365
+ withheld.implausible++;
1366
+ return null;
1175
1367
  }
1176
- const upp = s.upp;
1177
- const area_sf = round2(areaPx2 * upp * upp);
1178
- const perimeter_lf = round2(perimPx * upp);
1368
+ const perimeter_lf = round2(c.perimPx * upp);
1179
1369
  let shape_id;
1180
1370
  if (opts.condition) {
1181
- shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
1371
+ shape_id = this.commit(s, opts.condition, opts.role, c.ring, { area_sf, perimeter_lf }, {
1182
1372
  method: "one_click_v1",
1183
1373
  actor: "agent",
1184
- seed_norm: [r.seed[0] / s.widthPx, r.seed[1] / s.heightPx],
1374
+ seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
1185
1375
  reviewed: false,
1186
- ...r.flood.hatchFiltered ? { hatch_filtered: true } : {}
1376
+ ...c.hatch ? { hatch_filtered: true } : {}
1187
1377
  }).id;
1188
1378
  }
1189
1379
  return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
1190
1380
  }).filter((r) => r !== null);
1381
+ const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.implausible;
1191
1382
  return {
1192
1383
  detected: rooms.length,
1193
1384
  rooms,
1385
+ withheld: {
1386
+ total: withheldTotal,
1387
+ ...withheld,
1388
+ ...upp != null ? { min_area_sf: minAreaSf } : {}
1389
+ },
1390
+ ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable. Raise or lower min_area_sf to see more.` } : {},
1194
1391
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
1195
1392
  };
1196
1393
  }
@@ -1255,7 +1452,7 @@ import { z as z2 } from "zod";
1255
1452
  var TRACE_ENV = "OPENTAKEOFF_MCP_TRACE";
1256
1453
  function traceToolCall(tool, args, startedAt, reply) {
1257
1454
  if (process.env[TRACE_ENV] !== "1") return;
1258
- const text = reply.content[0]?.text ?? "";
1455
+ const text = reply.content.map((c) => "text" in c ? c.text : c.data).join("");
1259
1456
  const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
1260
1457
  const sheet = args && typeof args === "object" && "sheet" in args ? args.sheet : void 0;
1261
1458
  const event = {
@@ -1318,6 +1515,7 @@ var oneClickOutput = {
1318
1515
  var detectedRoom = z.object({
1319
1516
  label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
1320
1517
  nverts: z.number().int().describe("Vertex count of the traced polygon"),
1518
+ merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
1321
1519
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
1322
1520
  verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
1323
1521
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
@@ -1329,6 +1527,14 @@ var detectedRoom = z.object({
1329
1527
  var detectRoomsOutput = {
1330
1528
  detected: z.number().int().describe("Count of cleanly-detected rooms \u2014 may be fewer than the labels found on the sheet"),
1331
1529
  rooms: z.array(detectedRoom),
1530
+ withheld: z.object({
1531
+ total: z.number().int().describe("Seeds found on the sheet but not reported as rooms"),
1532
+ degenerate: z.number().int().describe("Traced to fewer than 3 vertices"),
1533
+ duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
1534
+ implausible: z.number().int().describe("Enclosed and clean, but smaller than min_area_sf \u2014 a label bubble, door swing, or wall cavity rather than a room"),
1535
+ min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
1536
+ }).describe("What detection skipped and why \u2014 a withheld room is a question the caller can ask; a silently dropped one is a hole in a bid"),
1537
+ note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
1332
1538
  warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
1333
1539
  };
1334
1540
  var measurePolygonOutput = {
@@ -1470,15 +1676,16 @@ function registerTools(server, session) {
1470
1676
  outputSchema: oneClickOutput
1471
1677
  }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
1472
1678
  server.registerTool("detect_rooms", {
1473
- description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. Only cleanly-traced rooms are returned; a label that leaked or landed in dense linework is silently withheld, never reported as a bad room. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing. ${COORDS}`,
1679
+ description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. ${COORDS}`,
1474
1680
  inputSchema: {
1475
1681
  sheet: z2.string(),
1476
1682
  condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
1477
1683
  role: roleSchema,
1478
- return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)")
1684
+ return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
1685
+ min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed regions smaller than this are withheld as label bubbles/cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped.")
1479
1686
  },
1480
1687
  outputSchema: detectRoomsOutput
1481
- }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
1688
+ }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf })));
1482
1689
  server.registerTool("measure_polygon", {
1483
1690
  description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
1484
1691
  inputSchema: {
@@ -1528,6 +1735,27 @@ function registerTools(server, session) {
1528
1735
  },
1529
1736
  outputSchema: readSheetTextOutput
1530
1737
  }, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
1738
+ server.registerTool("view_sheet", {
1739
+ description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan: full-sheet overview first, then tight crops at higher px until dimension strings and room labels read cleanly. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
1740
+ inputSchema: {
1741
+ sheet: z2.string(),
1742
+ region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Crop rect in image px (origin top-left, y down); omit for the full sheet"),
1743
+ px: z2.number().int().min(200).max(2e3).optional().describe("Long-side pixel budget of the returned image (default 1400) \u2014 small region + high px = readable dimension strings"),
1744
+ overlay: z2.boolean().optional().describe("Burn committed shapes into the render (solid = human-affirmed, dashed = unreviewed)"),
1745
+ grid: z2.string().optional().describe(`Burn in a calibrated 1-ft/5-ft measuring grid: "auto" = the sheet's set scale; otherwise the drawing scale as inches-per-foot, e.g. "1/4", "3/16", "0.25"`)
1746
+ }
1747
+ }, async (a) => {
1748
+ const startedAt = process.hrtime.bigint();
1749
+ let reply;
1750
+ try {
1751
+ const { png, meta } = await session.viewSheet(a.sheet, { region: a.region, px: a.px, overlay: a.overlay, grid: a.grid });
1752
+ reply = okImage(png, meta);
1753
+ } catch (e) {
1754
+ reply = fail(e);
1755
+ }
1756
+ traceToolCall("view_sheet", a, startedAt, reply);
1757
+ return reply;
1758
+ });
1531
1759
  }
1532
1760
 
1533
1761
  // src/resources.ts
@@ -1609,7 +1837,7 @@ function registerResources(server, session) {
1609
1837
  // package.json
1610
1838
  var package_default = {
1611
1839
  name: "opentakeoff-mcp",
1612
- version: "0.5.0",
1840
+ version: "0.6.1",
1613
1841
  mcpName: "io.github.Kentucky-ai/opentakeoff",
1614
1842
  type: "module",
1615
1843
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -1625,7 +1853,7 @@ var package_default = {
1625
1853
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
1626
1854
  prepublishOnly: "npm run typecheck && npm test && npm run build",
1627
1855
  typecheck: "tsc --noEmit",
1628
- test: "node --import tsx --test test/conformance.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
1856
+ test: "node --import tsx --test test/conformance.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
1629
1857
  },
1630
1858
  dependencies: {
1631
1859
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
19
+ "test": "node --import tsx --test test/conformance.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",