opentakeoff-mcp 0.5.0 → 0.6.0

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();
@@ -1255,7 +1399,7 @@ import { z as z2 } from "zod";
1255
1399
  var TRACE_ENV = "OPENTAKEOFF_MCP_TRACE";
1256
1400
  function traceToolCall(tool, args, startedAt, reply) {
1257
1401
  if (process.env[TRACE_ENV] !== "1") return;
1258
- const text = reply.content[0]?.text ?? "";
1402
+ const text = reply.content.map((c) => "text" in c ? c.text : c.data).join("");
1259
1403
  const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
1260
1404
  const sheet = args && typeof args === "object" && "sheet" in args ? args.sheet : void 0;
1261
1405
  const event = {
@@ -1528,6 +1672,27 @@ function registerTools(server, session) {
1528
1672
  },
1529
1673
  outputSchema: readSheetTextOutput
1530
1674
  }, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
1675
+ server.registerTool("view_sheet", {
1676
+ 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}`,
1677
+ inputSchema: {
1678
+ sheet: z2.string(),
1679
+ 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"),
1680
+ 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"),
1681
+ overlay: z2.boolean().optional().describe("Burn committed shapes into the render (solid = human-affirmed, dashed = unreviewed)"),
1682
+ 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"`)
1683
+ }
1684
+ }, async (a) => {
1685
+ const startedAt = process.hrtime.bigint();
1686
+ let reply;
1687
+ try {
1688
+ const { png, meta } = await session.viewSheet(a.sheet, { region: a.region, px: a.px, overlay: a.overlay, grid: a.grid });
1689
+ reply = okImage(png, meta);
1690
+ } catch (e) {
1691
+ reply = fail(e);
1692
+ }
1693
+ traceToolCall("view_sheet", a, startedAt, reply);
1694
+ return reply;
1695
+ });
1531
1696
  }
1532
1697
 
1533
1698
  // src/resources.ts
@@ -1609,7 +1774,7 @@ function registerResources(server, session) {
1609
1774
  // package.json
1610
1775
  var package_default = {
1611
1776
  name: "opentakeoff-mcp",
1612
- version: "0.5.0",
1777
+ version: "0.6.0",
1613
1778
  mcpName: "io.github.Kentucky-ai/opentakeoff",
1614
1779
  type: "module",
1615
1780
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -1625,7 +1790,7 @@ var package_default = {
1625
1790
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
1626
1791
  prepublishOnly: "npm run typecheck && npm test && npm run build",
1627
1792
  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"
1793
+ 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
1794
  },
1630
1795
  dependencies: {
1631
1796
  "@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.0",
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",