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 +26 -17
- package/dist/server-core.js +254 -26
- 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
|
|
@@ -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
|
|
120
|
-
as **`structuredContent`** — typed, machine-validated on every call —
|
|
121
|
-
the same compact JSON in a single text item for clients that predate
|
|
122
|
-
output.
|
|
123
|
-
|
|
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).
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
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,
|
|
226
|
-
|
|
227
|
-
|
|
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) }) }]
|
|
@@ -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).
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
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
|
|
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)
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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 (
|
|
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
|
|
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: [
|
|
1374
|
+
seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
|
|
1185
1375
|
reviewed: false,
|
|
1186
|
-
...
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|