opentakeoff-mcp 0.4.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 +27 -17
- package/dist/server-core.js +266 -4
- package/package.json +2 -2
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
|
|
31
|
-
|
|
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
|
|
@@ -108,18 +108,22 @@ includes document text, shape vertices, or result payload content.
|
|
|
108
108
|
| `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
|
|
109
109
|
| `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
|
|
110
110
|
| `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. Pass `condition` to commit; `role: "deduct"` subtracts. |
|
|
111
|
+
| `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. Only cleanly-traced rooms come back; a leaked/dense-linework label is silently withheld. Pass `condition` to commit every detected room. |
|
|
111
112
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
112
113
|
| `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
|
|
113
114
|
| `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
|
|
114
115
|
| `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
|
|
115
116
|
| `delete_shape` | Remove a committed shape by id. |
|
|
116
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. |
|
|
117
119
|
|
|
118
|
-
Every tool declares an **`outputSchema`**, and every reply carries the
|
|
119
|
-
as **`structuredContent`** — typed, machine-validated on every call —
|
|
120
|
-
the same compact JSON in a single text item for clients that predate
|
|
121
|
-
output.
|
|
122
|
-
|
|
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.
|
|
123
127
|
|
|
124
128
|
## Resources — browse before you measure
|
|
125
129
|
|
|
@@ -208,19 +212,25 @@ npm test # session + tool-layer + e2e, against demo/sample-plan.pdf
|
|
|
208
212
|
## Releasing (maintainers)
|
|
209
213
|
|
|
210
214
|
MCP releases live in the **`mcp-v*`** tag namespace — bare `v*` tags belong to
|
|
211
|
-
the app (v0.2.0, v0.3.0 are app releases).
|
|
212
|
-
|
|
213
|
-
|
|
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.
|
|
214
222
|
|
|
215
223
|
```bash
|
|
216
224
|
# 1. bump the version — all three fields together:
|
|
217
225
|
# package.json .version, server.json .version, server.json .packages[0].version
|
|
218
|
-
# 2.
|
|
219
|
-
npm publish
|
|
220
|
-
# 3. tag and push — this fires .github/workflows/publish-mcp.yml:
|
|
226
|
+
# 2. tag and push — this fires the whole release:
|
|
221
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)
|
|
222
229
|
```
|
|
223
230
|
|
|
224
|
-
The workflow checks version consistency,
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
package/dist/server-core.js
CHANGED
|
@@ -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) }) }]
|
|
@@ -740,6 +768,27 @@ function ringArea(pts) {
|
|
|
740
768
|
return Math.abs(a) / 2;
|
|
741
769
|
}
|
|
742
770
|
|
|
771
|
+
// ../web/src/lib/detectRooms.ts
|
|
772
|
+
var ROOM_LABEL_RE = /^\d{2,3}[A-Z]?$/;
|
|
773
|
+
function roomLabelSeeds(items) {
|
|
774
|
+
const out = [];
|
|
775
|
+
for (const it of items) {
|
|
776
|
+
const num = (it.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
|
|
777
|
+
if (!num) continue;
|
|
778
|
+
out.push({ str: num, seed: [it.x, it.y] });
|
|
779
|
+
}
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
function detectRegions(maskObj, seeds, sensitivity = SENS_BALANCED) {
|
|
783
|
+
const out = [];
|
|
784
|
+
for (const s of seeds) {
|
|
785
|
+
const f = floodRegion(maskObj, s.seed[0], s.seed[1], sensitivity);
|
|
786
|
+
if (f.status !== "ok") continue;
|
|
787
|
+
out.push({ str: s.str, seed: s.seed, flood: f });
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
|
|
743
792
|
// ../web/src/lib/geometry.js
|
|
744
793
|
function buildSnapGrid(points, cell) {
|
|
745
794
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -879,6 +928,86 @@ function grandTotals(rows) {
|
|
|
879
928
|
};
|
|
880
929
|
}
|
|
881
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
|
+
|
|
882
1011
|
// src/session.ts
|
|
883
1012
|
var SNAP_CELL = 24;
|
|
884
1013
|
var SNAP_TOL = 7;
|
|
@@ -888,6 +1017,9 @@ var mintUuid = () => globalThis.crypto && typeof globalThis.crypto.randomUUID ==
|
|
|
888
1017
|
var uid = (p) => `${p}-${mintUuid()}`;
|
|
889
1018
|
var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
890
1019
|
var IMAGE_MAX_EDGE = 1568;
|
|
1020
|
+
var VIEW_MIN_PX = 200;
|
|
1021
|
+
var VIEW_DEFAULT_PX = 1400;
|
|
1022
|
+
var VIEW_MAX_PX = 2e3;
|
|
891
1023
|
var sheetSummary = (s) => ({
|
|
892
1024
|
sheet: s.key,
|
|
893
1025
|
page: s.pageNum,
|
|
@@ -984,6 +1116,39 @@ var Session = class {
|
|
|
984
1116
|
}
|
|
985
1117
|
return s.png;
|
|
986
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
|
+
}
|
|
987
1152
|
async ensureGeometry(s) {
|
|
988
1153
|
if (!s.geo) {
|
|
989
1154
|
const opList = await s.page.operatorList();
|
|
@@ -1123,6 +1288,56 @@ var Session = class {
|
|
|
1123
1288
|
}
|
|
1124
1289
|
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
|
|
1125
1290
|
}
|
|
1291
|
+
/** Batch room detection: read every room-number label off the sheet's text
|
|
1292
|
+
* layer, seed the existing One-Click flood at each, and trace/commit
|
|
1293
|
+
* exactly like oneClick — just N of them from one call instead of N
|
|
1294
|
+
* reasoning-heavy round-trips. Same contract as oneClick: no scale → a
|
|
1295
|
+
* px-only preview per room; no condition → nothing commits (a review
|
|
1296
|
+
* pass, not a proposal-acceptance gate — this server has none). A region
|
|
1297
|
+
* that traces to a degenerate ring (<3 verts) is dropped from the batch
|
|
1298
|
+
* rather than failing the whole call — one bad label must not sink every
|
|
1299
|
+
* other clean detection on the sheet. */
|
|
1300
|
+
async detectRooms(name, opts) {
|
|
1301
|
+
const s = this.sheet(name);
|
|
1302
|
+
const mask = await this.ensureMask(name);
|
|
1303
|
+
if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
|
|
1304
|
+
const seeds = roomLabelSeeds(s.text);
|
|
1305
|
+
const regions = detectRegions(mask, seeds);
|
|
1306
|
+
const rooms = regions.map((r) => {
|
|
1307
|
+
const ring = snapVertices(traceRegion(r.flood), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
|
|
1308
|
+
if (ring.length < 3) return null;
|
|
1309
|
+
const areaPx2 = ringArea(ring);
|
|
1310
|
+
const perimPx = closedMetrics(ring).perim;
|
|
1311
|
+
const common = {
|
|
1312
|
+
label: r.str,
|
|
1313
|
+
nverts: ring.length,
|
|
1314
|
+
...r.flood.hatchFiltered ? { hatch_filtered: true } : {},
|
|
1315
|
+
...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
|
|
1316
|
+
};
|
|
1317
|
+
if (s.upp == null) {
|
|
1318
|
+
return { ...common, area_px2: round1(areaPx2), perimeter_px: round1(perimPx) };
|
|
1319
|
+
}
|
|
1320
|
+
const upp = s.upp;
|
|
1321
|
+
const area_sf = round2(areaPx2 * upp * upp);
|
|
1322
|
+
const perimeter_lf = round2(perimPx * upp);
|
|
1323
|
+
let shape_id;
|
|
1324
|
+
if (opts.condition) {
|
|
1325
|
+
shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
|
|
1326
|
+
method: "one_click_v1",
|
|
1327
|
+
actor: "agent",
|
|
1328
|
+
seed_norm: [r.seed[0] / s.widthPx, r.seed[1] / s.heightPx],
|
|
1329
|
+
reviewed: false,
|
|
1330
|
+
...r.flood.hatchFiltered ? { hatch_filtered: true } : {}
|
|
1331
|
+
}).id;
|
|
1332
|
+
}
|
|
1333
|
+
return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
|
|
1334
|
+
}).filter((r) => r !== null);
|
|
1335
|
+
return {
|
|
1336
|
+
detected: rooms.length,
|
|
1337
|
+
rooms,
|
|
1338
|
+
...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1126
1341
|
measurePolygon(name, verts, opts) {
|
|
1127
1342
|
const s = this.sheet(name);
|
|
1128
1343
|
if (s.upp == null) throw new UserError(this.scaleGate(s));
|
|
@@ -1184,7 +1399,7 @@ import { z as z2 } from "zod";
|
|
|
1184
1399
|
var TRACE_ENV = "OPENTAKEOFF_MCP_TRACE";
|
|
1185
1400
|
function traceToolCall(tool, args, startedAt, reply) {
|
|
1186
1401
|
if (process.env[TRACE_ENV] !== "1") return;
|
|
1187
|
-
const text = reply.content
|
|
1402
|
+
const text = reply.content.map((c) => "text" in c ? c.text : c.data).join("");
|
|
1188
1403
|
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
1189
1404
|
const sheet = args && typeof args === "object" && "sheet" in args ? args.sheet : void 0;
|
|
1190
1405
|
const event = {
|
|
@@ -1244,6 +1459,22 @@ var oneClickOutput = {
|
|
|
1244
1459
|
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px"),
|
|
1245
1460
|
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
1246
1461
|
};
|
|
1462
|
+
var detectedRoom = z.object({
|
|
1463
|
+
label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
|
|
1464
|
+
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
1465
|
+
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
1466
|
+
verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
|
|
1467
|
+
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
1468
|
+
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
1469
|
+
shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
|
|
1470
|
+
area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
|
|
1471
|
+
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px")
|
|
1472
|
+
});
|
|
1473
|
+
var detectRoomsOutput = {
|
|
1474
|
+
detected: z.number().int().describe("Count of cleanly-detected rooms \u2014 may be fewer than the labels found on the sheet"),
|
|
1475
|
+
rooms: z.array(detectedRoom),
|
|
1476
|
+
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
1477
|
+
};
|
|
1247
1478
|
var measurePolygonOutput = {
|
|
1248
1479
|
area_sf: z.number(),
|
|
1249
1480
|
perimeter_lf: z.number(),
|
|
@@ -1362,7 +1593,7 @@ function registerTools(server, session) {
|
|
|
1362
1593
|
label: z2.string().optional().describe("A standard scale label, exactly as listed in the error on a miss"),
|
|
1363
1594
|
upp: z2.number().optional().describe("Real feet per image px at render scale 2.0"),
|
|
1364
1595
|
calibrate: z2.object({ p1: pointSchema, p2: pointSchema, feet: z2.number() }).optional().describe("Two points (image px) a known real distance apart, and that distance in feet"),
|
|
1365
|
-
use_detected: z2.
|
|
1596
|
+
use_detected: z2.literal(true).optional().describe("true = adopt the sheet's detected scale")
|
|
1366
1597
|
},
|
|
1367
1598
|
outputSchema: setScaleOutput
|
|
1368
1599
|
}, run("set_scale", (a) => {
|
|
@@ -1382,6 +1613,16 @@ function registerTools(server, session) {
|
|
|
1382
1613
|
},
|
|
1383
1614
|
outputSchema: oneClickOutput
|
|
1384
1615
|
}, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
|
|
1616
|
+
server.registerTool("detect_rooms", {
|
|
1617
|
+
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}`,
|
|
1618
|
+
inputSchema: {
|
|
1619
|
+
sheet: z2.string(),
|
|
1620
|
+
condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
|
|
1621
|
+
role: roleSchema,
|
|
1622
|
+
return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)")
|
|
1623
|
+
},
|
|
1624
|
+
outputSchema: detectRoomsOutput
|
|
1625
|
+
}, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
|
|
1385
1626
|
server.registerTool("measure_polygon", {
|
|
1386
1627
|
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}`,
|
|
1387
1628
|
inputSchema: {
|
|
@@ -1431,6 +1672,27 @@ function registerTools(server, session) {
|
|
|
1431
1672
|
},
|
|
1432
1673
|
outputSchema: readSheetTextOutput
|
|
1433
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
|
+
});
|
|
1434
1696
|
}
|
|
1435
1697
|
|
|
1436
1698
|
// src/resources.ts
|
|
@@ -1512,7 +1774,7 @@ function registerResources(server, session) {
|
|
|
1512
1774
|
// package.json
|
|
1513
1775
|
var package_default = {
|
|
1514
1776
|
name: "opentakeoff-mcp",
|
|
1515
|
-
version: "0.
|
|
1777
|
+
version: "0.6.0",
|
|
1516
1778
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
1517
1779
|
type: "module",
|
|
1518
1780
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -1528,7 +1790,7 @@ var package_default = {
|
|
|
1528
1790
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
1529
1791
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
1530
1792
|
typecheck: "tsc --noEmit",
|
|
1531
|
-
test: "node --import tsx --test 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"
|
|
1532
1794
|
},
|
|
1533
1795
|
dependencies: {
|
|
1534
1796
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "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/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",
|