opentakeoff-mcp 0.1.3 → 0.3.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 +83 -2
- package/dist/server-core.js +321 -38
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# OpenTakeoff MCP server
|
|
2
2
|
|
|
3
|
+
Listed in the [official MCP registry](https://registry.modelcontextprotocol.io) as
|
|
4
|
+
`io.github.Kentucky-ai/opentakeoff` and on [Glama](https://glama.ai/mcp/servers/Kentucky-ai/opentakeoff).
|
|
5
|
+
|
|
3
6
|
## Run it in 60 seconds (npx)
|
|
4
7
|
|
|
5
8
|
No clone, no build — point your MCP client at the published package:
|
|
@@ -17,6 +20,17 @@ No clone, no build — point your MCP client at the published package:
|
|
|
17
20
|
|
|
18
21
|
Works with Claude Code (`claude mcp add opentakeoff -- npx -y opentakeoff-mcp`), Claude Desktop, Cursor, or any stdio MCP client. Node 20+.
|
|
19
22
|
|
|
23
|
+
## One-click install (Claude Desktop)
|
|
24
|
+
|
|
25
|
+
No Node, no npm: download **`opentakeoff-mcp.mcpb`** from the
|
|
26
|
+
[latest release](https://github.com/Kentucky-ai/opentakeoff/releases) and
|
|
27
|
+
double-click it — Claude Desktop installs the server with its dependencies
|
|
28
|
+
bundled. Built by `npm run mcpb` and attached automatically to every `mcp-v*`
|
|
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.
|
|
33
|
+
|
|
20
34
|
|
|
21
35
|
The takeoff engine — One-Click Area, the scale model, conditions, totals — on
|
|
22
36
|
**stdio for your MCP client**. An agent can open a plan, read the title block,
|
|
@@ -25,6 +39,25 @@ app autosaves. Same engine, same math: the server imports
|
|
|
25
39
|
`web/src/lib/{oneclick,sheets,geometry,totals}` directly, so a shape committed
|
|
26
40
|
here is field-identical to one committed on the canvas.
|
|
27
41
|
|
|
42
|
+
## Run with Docker
|
|
43
|
+
|
|
44
|
+
Build from the repository root so the Dockerfile can bundle the shared web
|
|
45
|
+
engine:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
docker build -f mcp/Dockerfile -t opentakeoff-mcp .
|
|
49
|
+
docker run --rm -i opentakeoff-mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Mount local plans read-only and pass that container path to `load_plan`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
docker run --rm -i -v "$PWD/demo:/plans:ro" opentakeoff-mcp
|
|
56
|
+
docker run --rm -i -e OPENTAKEOFF_MCP_TRACE=1 -v "$PWD/demo:/plans:ro" opentakeoff-mcp
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For example, load `/plans/sample-plan.pdf` after mounting `demo/`.
|
|
60
|
+
|
|
28
61
|
## Quickstart
|
|
29
62
|
|
|
30
63
|
Both `web/` and `mcp/` need their dependencies (the engine's pdf.js lives in
|
|
@@ -82,8 +115,36 @@ includes document text, shape vertices, or result payload content.
|
|
|
82
115
|
| `delete_shape` | Remove a committed shape by id. |
|
|
83
116
|
| `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
|
|
84
117
|
|
|
85
|
-
Every
|
|
86
|
-
|
|
118
|
+
Every tool declares an **`outputSchema`**, and every reply carries the payload
|
|
119
|
+
as **`structuredContent`** — typed, machine-validated on every call — alongside
|
|
120
|
+
the same compact JSON in a single text item for clients that predate structured
|
|
121
|
+
output. Failures come back as `isError: true` with `{"error": "..."}` — never a
|
|
122
|
+
dropped connection.
|
|
123
|
+
|
|
124
|
+
## Resources — browse before you measure
|
|
125
|
+
|
|
126
|
+
Tools let an agent act; resources let it **see**. When a plan loads, the sheet
|
|
127
|
+
set becomes browsable natively (`resources/list` re-announces itself via
|
|
128
|
+
`list_changed`):
|
|
129
|
+
|
|
130
|
+
| URI | Contents |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `takeoff://sheets` | The plan index — file, page count, every sheet's dims, title-block number, detected scale, scale state, shape count. Always listed; before any plan loads it says so and points at `load_plan`. |
|
|
133
|
+
| `takeoff://sheet/{page}` | One sheet's metadata (JSON), addressed by 1-based page number. |
|
|
134
|
+
| `takeoff://sheet/{page}/text` | The sheet's text, joined — title block, room labels, schedules. Positions live in the `read_sheet_text` tool. |
|
|
135
|
+
| `takeoff://sheet/{page}/image` | The page rendered to PNG, long edge capped at **1568 px** — the native resolution of vision-model eyes. Rendered lazily, cached until the next `load_plan`. |
|
|
136
|
+
|
|
137
|
+
Page numbers — not file-derived sheet keys — address resources, so URIs stay
|
|
138
|
+
clean regardless of the PDF's name; the human-facing key (`plan.pdf#2`) and
|
|
139
|
+
title-block number (`A-101`) ride along as the resource name and title.
|
|
140
|
+
Rendering uses `@napi-rs/canvas` (pdf.js's own optional dependency): on a
|
|
141
|
+
platform without a prebuilt binary every non-raster capability still works and
|
|
142
|
+
the image read explains exactly what's missing.
|
|
143
|
+
|
|
144
|
+
The intended agent loop: read `takeoff://sheets` → look at
|
|
145
|
+
`takeoff://sheet/{page}/image` → pick click targets → measure with the tools.
|
|
146
|
+
An image coordinate maps to the tool space (image px at render scale 2.0) by
|
|
147
|
+
multiplying by `width_px / <image pixel width>`.
|
|
87
148
|
|
|
88
149
|
## The coordinate contract
|
|
89
150
|
|
|
@@ -143,3 +204,23 @@ sheet number (`A-101`) wherever a sheet is named.
|
|
|
143
204
|
npm run typecheck
|
|
144
205
|
npm test # session + tool-layer + e2e, against demo/sample-plan.pdf
|
|
145
206
|
```
|
|
207
|
+
|
|
208
|
+
## Releasing (maintainers)
|
|
209
|
+
|
|
210
|
+
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). The npm artifact publishes manually
|
|
212
|
+
(hardware-key 2FA) **before** the tag is pushed; the workflow refuses to run
|
|
213
|
+
ahead of it.
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# 1. bump the version — all three fields together:
|
|
217
|
+
# package.json .version, server.json .version, server.json .packages[0].version
|
|
218
|
+
# 2. from mcp/, publish with the hardware key:
|
|
219
|
+
npm publish
|
|
220
|
+
# 3. tag and push — this fires .github/workflows/publish-mcp.yml:
|
|
221
|
+
git tag mcp-v<version> && git push origin mcp-v<version>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The workflow checks version consistency, requires the npm artifact to exist,
|
|
225
|
+
publishes to the official MCP registry via GitHub OIDC, verifies the listing,
|
|
226
|
+
and creates the GitHub release (titled `opentakeoff-mcp <version>`).
|
package/dist/server-core.js
CHANGED
|
@@ -14,7 +14,7 @@ if (typeof Promise.withResolvers !== "function") {
|
|
|
14
14
|
|
|
15
15
|
// server.ts
|
|
16
16
|
import { pathToFileURL } from "node:url";
|
|
17
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
19
|
|
|
20
20
|
// src/session.ts
|
|
@@ -126,6 +126,21 @@ function detectScale(textContent, viewport) {
|
|
|
126
126
|
var requireHere = createRequire(import.meta.url);
|
|
127
127
|
var PDFJS_ROOT = path.dirname(requireHere.resolve("pdfjs-dist/package.json"));
|
|
128
128
|
var OPS2 = pdfjs.OPS;
|
|
129
|
+
async function ensureCanvasGlobals() {
|
|
130
|
+
const g = globalThis;
|
|
131
|
+
if (g.Path2D && g.DOMMatrix && g.ImageData) return;
|
|
132
|
+
let napi;
|
|
133
|
+
try {
|
|
134
|
+
napi = await import("@napi-rs/canvas");
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"Page rendering needs @napi-rs/canvas (pdfjs-dist's optional dependency), which did not install on this platform. Reinstall with optional dependencies enabled."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
g.Path2D ??= napi.Path2D;
|
|
141
|
+
g.DOMMatrix ??= napi.DOMMatrix;
|
|
142
|
+
g.ImageData ??= napi.ImageData;
|
|
143
|
+
}
|
|
129
144
|
async function openPdf(filePath) {
|
|
130
145
|
const bytes = await readFile(filePath);
|
|
131
146
|
const doc = await pdfjs.getDocument({
|
|
@@ -151,7 +166,19 @@ async function openPdf(filePath) {
|
|
|
151
166
|
heightPt: vp1.height,
|
|
152
167
|
viewport: { width: vp.width, height: vp.height, transform: vp.transform },
|
|
153
168
|
textContent,
|
|
154
|
-
operatorList: async () => await page.getOperatorList()
|
|
169
|
+
operatorList: async () => await page.getOperatorList(),
|
|
170
|
+
async renderPng(scale) {
|
|
171
|
+
await ensureCanvasGlobals();
|
|
172
|
+
const rvp = page.getViewport({ scale });
|
|
173
|
+
const factory = doc.canvasFactory;
|
|
174
|
+
const target = factory.create(Math.ceil(rvp.width), Math.ceil(rvp.height));
|
|
175
|
+
try {
|
|
176
|
+
await page.render({ canvasContext: target.context, viewport: rvp }).promise;
|
|
177
|
+
return new Uint8Array(target.canvas.toBuffer("image/png"));
|
|
178
|
+
} finally {
|
|
179
|
+
factory.destroy(target);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
155
182
|
};
|
|
156
183
|
},
|
|
157
184
|
destroy: () => doc.destroy().then(() => void 0)
|
|
@@ -172,6 +199,7 @@ function positionedText(ph) {
|
|
|
172
199
|
var UserError = class extends Error {
|
|
173
200
|
};
|
|
174
201
|
var ok = (payload) => ({
|
|
202
|
+
structuredContent: payload,
|
|
175
203
|
content: [{ type: "text", text: JSON.stringify(payload) }]
|
|
176
204
|
});
|
|
177
205
|
var fail = (err) => ({
|
|
@@ -859,6 +887,7 @@ var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert"
|
|
|
859
887
|
var _idn = 0;
|
|
860
888
|
var uid = (p) => `${p}-${Date.now().toString(36)}-${(_idn++).toString(36)}`;
|
|
861
889
|
var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
890
|
+
var IMAGE_MAX_EDGE = 1568;
|
|
862
891
|
var sheetSummary = (s) => ({
|
|
863
892
|
sheet: s.key,
|
|
864
893
|
page: s.pageNum,
|
|
@@ -920,6 +949,41 @@ var Session = class {
|
|
|
920
949
|
for (const s of this.sheets.values()) if (s.sheetNumber === wanted) return s;
|
|
921
950
|
throw new UserError(`Unknown sheet "${name}" \u2014 loaded sheets: ${[...this.sheets.keys()].join(", ")}.`);
|
|
922
951
|
}
|
|
952
|
+
/** Resource-URI addressing: sheets by 1-based page number. */
|
|
953
|
+
sheetForPage(page) {
|
|
954
|
+
if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
955
|
+
for (const s of this.sheets.values()) if (s.pageNum === page) return s;
|
|
956
|
+
throw new UserError(`No page ${page} \u2014 the loaded plan has pages 1\u2013${this.sheets.size}.`);
|
|
957
|
+
}
|
|
958
|
+
/** Every loaded sheet, in page order — [] before any plan loads. */
|
|
959
|
+
sheetList() {
|
|
960
|
+
return [...this.sheets.values()].sort((a, b) => a.pageNum - b.pageNum);
|
|
961
|
+
}
|
|
962
|
+
/** The takeoff://sheets index payload — cheap (no geometry is built). */
|
|
963
|
+
index() {
|
|
964
|
+
if (!this.doc) {
|
|
965
|
+
return { file: null, page_count: 0, sheets: [], hint: "No plan loaded \u2014 call the load_plan tool with a PDF path, then list resources again." };
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
file: this.file,
|
|
969
|
+
page_count: this.sheets.size,
|
|
970
|
+
sheets: this.sheetList().map((s) => ({
|
|
971
|
+
...sheetSummary(s),
|
|
972
|
+
scale_set: s.upp != null,
|
|
973
|
+
shape_count: this.shapes.filter((x) => x.sheet_id === s.key).length
|
|
974
|
+
}))
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
/** Rendered-page PNG, long edge capped at IMAGE_MAX_EDGE (never above the
|
|
978
|
+
* canvas-native RENDER_SCALE), cached per sheet until the next load_plan. */
|
|
979
|
+
async renderSheetPng(page) {
|
|
980
|
+
const s = this.sheetForPage(page);
|
|
981
|
+
if (!s.png) {
|
|
982
|
+
const scale = Math.min(RENDER_SCALE, IMAGE_MAX_EDGE / Math.max(s.widthPt, s.heightPt));
|
|
983
|
+
s.png = await s.page.renderPng(scale);
|
|
984
|
+
}
|
|
985
|
+
return s.png;
|
|
986
|
+
}
|
|
923
987
|
async ensureGeometry(s) {
|
|
924
988
|
if (!s.geo) {
|
|
925
989
|
const opList = await s.page.operatorList();
|
|
@@ -1113,7 +1177,7 @@ var Session = class {
|
|
|
1113
1177
|
};
|
|
1114
1178
|
|
|
1115
1179
|
// src/tools.ts
|
|
1116
|
-
import { z } from "zod";
|
|
1180
|
+
import { z as z2 } from "zod";
|
|
1117
1181
|
|
|
1118
1182
|
// src/trace.ts
|
|
1119
1183
|
var TRACE_ENV = "OPENTAKEOFF_MCP_TRACE";
|
|
@@ -1134,10 +1198,136 @@ function traceToolCall(tool, args, startedAt, reply) {
|
|
|
1134
1198
|
`);
|
|
1135
1199
|
}
|
|
1136
1200
|
|
|
1201
|
+
// src/outputs.ts
|
|
1202
|
+
import { z } from "zod";
|
|
1203
|
+
var point = z.tuple([z.number(), z.number()]);
|
|
1204
|
+
var sheetSummary2 = {
|
|
1205
|
+
sheet: z.string().describe('Sheet key: page 1 is the bare file name ("plan.pdf"), pages 2+ are "plan.pdf#2"'),
|
|
1206
|
+
page: z.number().int().describe("1-based page number"),
|
|
1207
|
+
width_pt: z.number(),
|
|
1208
|
+
height_pt: z.number(),
|
|
1209
|
+
width_px: z.number().describe("Image px at render scale 2.0 \u2014 the coordinate space every tool speaks"),
|
|
1210
|
+
height_px: z.number(),
|
|
1211
|
+
sheet_number: z.string().optional().describe('Title-block sheet number ("A-101") where detected'),
|
|
1212
|
+
detected_scale: z.string().optional().describe("Drawn scale note read off the sheet \u2014 a suggestion, never auto-applied")
|
|
1213
|
+
};
|
|
1214
|
+
var loadPlanOutput = {
|
|
1215
|
+
file: z.string(),
|
|
1216
|
+
page_count: z.number().int(),
|
|
1217
|
+
sheets: z.array(z.object(sheetSummary2)),
|
|
1218
|
+
note: z.string()
|
|
1219
|
+
};
|
|
1220
|
+
var sheetInfoOutput = {
|
|
1221
|
+
...sheetSummary2,
|
|
1222
|
+
seg_count: z.number().int().describe("Vector segment count"),
|
|
1223
|
+
has_vector_linework: z.boolean().describe("one_click needs vector linework"),
|
|
1224
|
+
scale_set: z.boolean(),
|
|
1225
|
+
upp: z.number().optional().describe("Real feet per image px at render scale 2.0 \u2014 present once the scale is set"),
|
|
1226
|
+
shape_count: z.number().int().describe("Committed shapes on this sheet")
|
|
1227
|
+
};
|
|
1228
|
+
var setScaleOutput = {
|
|
1229
|
+
sheet: z.string(),
|
|
1230
|
+
upp: z.number().describe("Real feet per image px at render scale 2.0"),
|
|
1231
|
+
label: z.string().optional().describe("The standard scale label, when set by label or detected note"),
|
|
1232
|
+
source: z.enum(["label", "upp", "calibrate", "detected"])
|
|
1233
|
+
};
|
|
1234
|
+
var oneClickOutput = {
|
|
1235
|
+
status: z.literal("ok"),
|
|
1236
|
+
nverts: z.number().int().describe("Vertex count of the traced polygon"),
|
|
1237
|
+
hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
|
|
1238
|
+
verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
|
|
1239
|
+
area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
|
|
1240
|
+
perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
|
|
1241
|
+
shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
|
|
1242
|
+
area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
|
|
1243
|
+
perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px"),
|
|
1244
|
+
warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
|
|
1245
|
+
};
|
|
1246
|
+
var measurePolygonOutput = {
|
|
1247
|
+
area_sf: z.number(),
|
|
1248
|
+
perimeter_lf: z.number(),
|
|
1249
|
+
nverts: z.number().int(),
|
|
1250
|
+
shape_id: z.string().optional().describe("Present when condition was passed and the shape committed")
|
|
1251
|
+
};
|
|
1252
|
+
var measureLineOutput = {
|
|
1253
|
+
length_lf: z.number(),
|
|
1254
|
+
npts: z.number().int(),
|
|
1255
|
+
shape_id: z.string().optional().describe("Present when condition was passed and the shape committed")
|
|
1256
|
+
};
|
|
1257
|
+
var summaryRow = z.object({
|
|
1258
|
+
id: z.string(),
|
|
1259
|
+
finish_tag: z.string(),
|
|
1260
|
+
multiplier: z.number(),
|
|
1261
|
+
waste_pct: z.number(),
|
|
1262
|
+
shape_count: z.number().int(),
|
|
1263
|
+
floor_sf: z.number(),
|
|
1264
|
+
wall_sf: z.number(),
|
|
1265
|
+
border_sf: z.number(),
|
|
1266
|
+
lf: z.number(),
|
|
1267
|
+
ea: z.number(),
|
|
1268
|
+
total_sf: z.number(),
|
|
1269
|
+
floor_sf_net: z.number(),
|
|
1270
|
+
wall_sf_net: z.number(),
|
|
1271
|
+
border_sf_net: z.number(),
|
|
1272
|
+
lf_net: z.number(),
|
|
1273
|
+
total_sf_net: z.number(),
|
|
1274
|
+
sy_net: z.number()
|
|
1275
|
+
}).passthrough();
|
|
1276
|
+
var takeoffSummaryOutput = {
|
|
1277
|
+
conditions: z.array(summaryRow),
|
|
1278
|
+
totals: z.object({
|
|
1279
|
+
total_sf: z.number(),
|
|
1280
|
+
total_sf_net: z.number(),
|
|
1281
|
+
lf: z.number(),
|
|
1282
|
+
lf_net: z.number(),
|
|
1283
|
+
ea: z.number(),
|
|
1284
|
+
sy_net: z.number()
|
|
1285
|
+
}).passthrough()
|
|
1286
|
+
};
|
|
1287
|
+
var exportTakeoffOutput = {
|
|
1288
|
+
schema: z.string(),
|
|
1289
|
+
project_name: z.string(),
|
|
1290
|
+
units: z.string(),
|
|
1291
|
+
sheets: z.array(z.object({ sheet_id: z.string(), units_per_px: z.number() })),
|
|
1292
|
+
conditions: z.array(z.object({
|
|
1293
|
+
id: z.string(),
|
|
1294
|
+
finish_tag: z.string(),
|
|
1295
|
+
color: z.string(),
|
|
1296
|
+
fill: z.string(),
|
|
1297
|
+
hatch: z.string(),
|
|
1298
|
+
multiplier: z.number(),
|
|
1299
|
+
waste_pct: z.number(),
|
|
1300
|
+
materials: z.array(z.unknown())
|
|
1301
|
+
}).passthrough()),
|
|
1302
|
+
shapes: z.array(z.object({
|
|
1303
|
+
id: z.string(),
|
|
1304
|
+
sheet_id: z.string(),
|
|
1305
|
+
condition_id: z.string(),
|
|
1306
|
+
measure_role: z.enum(["floor_area", "deduct", "linear"]),
|
|
1307
|
+
verts_norm: z.array(point).describe("Vertices normalized to sheet dims (0\u20131)"),
|
|
1308
|
+
computed: z.object({ area_sf: z.number(), perimeter_lf: z.number() }).passthrough(),
|
|
1309
|
+
origin: z.object({}).passthrough().optional().describe("Provenance for one-click traces")
|
|
1310
|
+
}).passthrough()),
|
|
1311
|
+
markups: z.array(z.unknown()),
|
|
1312
|
+
sheet_group: z.array(z.unknown()),
|
|
1313
|
+
last_group: z.array(z.unknown()),
|
|
1314
|
+
sheet_tabs: z.array(z.unknown()),
|
|
1315
|
+
sheet_levels: z.object({}).passthrough()
|
|
1316
|
+
};
|
|
1317
|
+
var deleteShapeOutput = {
|
|
1318
|
+
deleted: z.string().describe("The removed shape's id"),
|
|
1319
|
+
shape_count: z.number().int().describe("Committed shapes remaining")
|
|
1320
|
+
};
|
|
1321
|
+
var readSheetTextOutput = {
|
|
1322
|
+
sheet: z.string(),
|
|
1323
|
+
items: z.array(z.object({ str: z.string(), x: z.number(), y: z.number() })).describe("Positioned text items (image px)"),
|
|
1324
|
+
text: z.string().describe("The items joined with spaces")
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1137
1327
|
// src/tools.ts
|
|
1138
1328
|
var COORDS = "Coordinates are image px at render scale 2.0: PDF pt \xD7 2, origin top-left, y down (the browser canvas's native space). Sheet payloads carry dims in both px and pt.";
|
|
1139
|
-
var pointSchema =
|
|
1140
|
-
var roleSchema =
|
|
1329
|
+
var pointSchema = z2.tuple([z2.number(), z2.number()]);
|
|
1330
|
+
var roleSchema = z2.enum(["floor_area", "deduct"]).default("floor_area");
|
|
1141
1331
|
var run = (tool, fn) => async (args) => {
|
|
1142
1332
|
const startedAt = process.hrtime.bigint();
|
|
1143
1333
|
let reply;
|
|
@@ -1151,22 +1341,29 @@ var run = (tool, fn) => async (args) => {
|
|
|
1151
1341
|
};
|
|
1152
1342
|
function registerTools(server, session) {
|
|
1153
1343
|
server.registerTool("load_plan", {
|
|
1154
|
-
description: `Open a plan PDF from disk and replace the whole session (previous document, scales, conditions, and shapes are cleared). Returns file, page_count, and one entry per sheet: dims, title-block sheet_number, and the detected drawn scale where present. ${COORDS}`,
|
|
1155
|
-
inputSchema: { path:
|
|
1156
|
-
|
|
1344
|
+
description: `Open a plan PDF from disk and replace the whole session (previous document, scales, conditions, and shapes are cleared). Returns file, page_count, and one entry per sheet: dims, title-block sheet_number, and the detected drawn scale where present. The loaded sheets also become browsable resources (takeoff://sheets). ${COORDS}`,
|
|
1345
|
+
inputSchema: { path: z2.string().describe("Path to a plan PDF on disk") },
|
|
1346
|
+
outputSchema: loadPlanOutput
|
|
1347
|
+
}, run("load_plan", async ({ path: path3 }) => {
|
|
1348
|
+
const loaded = await session.loadPlan(path3);
|
|
1349
|
+
server.sendResourceListChanged();
|
|
1350
|
+
return loaded;
|
|
1351
|
+
}));
|
|
1157
1352
|
server.registerTool("sheet_info", {
|
|
1158
1353
|
description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (one_click needs it), scale status, the detected scale suggestion, and this sheet's committed shape count. ${COORDS}`,
|
|
1159
|
-
inputSchema: { sheet:
|
|
1354
|
+
inputSchema: { sheet: z2.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') },
|
|
1355
|
+
outputSchema: sheetInfoOutput
|
|
1160
1356
|
}, run("sheet_info", ({ sheet }) => session.sheetInfo(sheet)));
|
|
1161
1357
|
server.registerTool("set_scale", {
|
|
1162
1358
|
description: `Set a sheet's scale \u2014 exactly ONE of: label (a standard scale, e.g. '1/4" = 1'-0"'), upp (real feet per image px), calibrate (two points along a known dimension plus its real feet), or use_detected (adopt the drawn scale note read off the sheet). The detected scale is never applied automatically \u2014 setting it is always this explicit call. ${COORDS}`,
|
|
1163
1359
|
inputSchema: {
|
|
1164
|
-
sheet:
|
|
1165
|
-
label:
|
|
1166
|
-
upp:
|
|
1167
|
-
calibrate:
|
|
1168
|
-
use_detected:
|
|
1169
|
-
}
|
|
1360
|
+
sheet: z2.string(),
|
|
1361
|
+
label: z2.string().optional().describe("A standard scale label, exactly as listed in the error on a miss"),
|
|
1362
|
+
upp: z2.number().optional().describe("Real feet per image px at render scale 2.0"),
|
|
1363
|
+
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"),
|
|
1364
|
+
use_detected: z2.boolean().optional().describe("true = adopt the sheet's detected scale")
|
|
1365
|
+
},
|
|
1366
|
+
outputSchema: setScaleOutput
|
|
1170
1367
|
}, run("set_scale", (a) => {
|
|
1171
1368
|
const given = [a.label !== void 0, a.upp !== void 0, a.calibrate !== void 0, a.use_detected !== void 0].filter(Boolean).length;
|
|
1172
1369
|
if (given !== 1) throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
|
|
@@ -1175,38 +1372,43 @@ function registerTools(server, session) {
|
|
|
1175
1372
|
server.registerTool("one_click", {
|
|
1176
1373
|
description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. ${COORDS}`,
|
|
1177
1374
|
inputSchema: {
|
|
1178
|
-
sheet:
|
|
1179
|
-
x:
|
|
1180
|
-
y:
|
|
1181
|
-
condition:
|
|
1375
|
+
sheet: z2.string(),
|
|
1376
|
+
x: z2.number(),
|
|
1377
|
+
y: z2.number(),
|
|
1378
|
+
condition: z2.string().optional().describe("Finish tag to commit under (minted on first use)"),
|
|
1182
1379
|
role: roleSchema,
|
|
1183
|
-
return_verts:
|
|
1184
|
-
}
|
|
1380
|
+
return_verts: z2.boolean().default(false).describe("Include the traced polygon's vertices (image px)")
|
|
1381
|
+
},
|
|
1382
|
+
outputSchema: oneClickOutput
|
|
1185
1383
|
}, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
|
|
1186
1384
|
server.registerTool("measure_polygon", {
|
|
1187
1385
|
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}`,
|
|
1188
1386
|
inputSchema: {
|
|
1189
|
-
sheet:
|
|
1190
|
-
verts:
|
|
1191
|
-
condition:
|
|
1387
|
+
sheet: z2.string(),
|
|
1388
|
+
verts: z2.array(pointSchema).min(3),
|
|
1389
|
+
condition: z2.string().optional(),
|
|
1192
1390
|
role: roleSchema
|
|
1193
|
-
}
|
|
1391
|
+
},
|
|
1392
|
+
outputSchema: measurePolygonOutput
|
|
1194
1393
|
}, run("measure_polygon", (a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role })));
|
|
1195
1394
|
server.registerTool("measure_line", {
|
|
1196
1395
|
description: `Measure an open polyline (min 2 points, image px): length_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it as a linear shape (base, transitions, feature strips). ${COORDS}`,
|
|
1197
1396
|
inputSchema: {
|
|
1198
|
-
sheet:
|
|
1199
|
-
pts:
|
|
1200
|
-
condition:
|
|
1201
|
-
}
|
|
1397
|
+
sheet: z2.string(),
|
|
1398
|
+
pts: z2.array(pointSchema).min(2),
|
|
1399
|
+
condition: z2.string().optional()
|
|
1400
|
+
},
|
|
1401
|
+
outputSchema: measureLineOutput
|
|
1202
1402
|
}, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition })));
|
|
1203
1403
|
server.registerTool("takeoff_summary", {
|
|
1204
1404
|
description: `Per-condition totals (floor/wall/border SF, LF, EA, SY, with and without waste) plus grand totals \u2014 the Report's numbers, computed by the same rules. ${COORDS}`,
|
|
1205
|
-
inputSchema: {}
|
|
1405
|
+
inputSchema: {},
|
|
1406
|
+
outputSchema: takeoffSummaryOutput
|
|
1206
1407
|
}, run("takeoff_summary", () => session.summary()));
|
|
1207
1408
|
server.registerTool("export_takeoff", {
|
|
1208
1409
|
description: `The full "opentakeoff.takeoff_canvas.v1" annotations payload \u2014 exactly what the app autosaves, importable by it. Returned inline; pass path to also write it to disk as JSON. ${COORDS}`,
|
|
1209
|
-
inputSchema: { path:
|
|
1410
|
+
inputSchema: { path: z2.string().optional().describe("File path to write the payload to") },
|
|
1411
|
+
outputSchema: exportTakeoffOutput
|
|
1210
1412
|
}, run("export_takeoff", async ({ path: outPath }) => {
|
|
1211
1413
|
const payload = session.exportPayload();
|
|
1212
1414
|
if (outPath) {
|
|
@@ -1217,21 +1419,99 @@ function registerTools(server, session) {
|
|
|
1217
1419
|
}));
|
|
1218
1420
|
server.registerTool("delete_shape", {
|
|
1219
1421
|
description: `Remove a committed shape by the id returned when it was committed. ${COORDS}`,
|
|
1220
|
-
inputSchema: { shape_id:
|
|
1422
|
+
inputSchema: { shape_id: z2.string() },
|
|
1423
|
+
outputSchema: deleteShapeOutput
|
|
1221
1424
|
}, run("delete_shape", ({ shape_id }) => session.deleteShape(shape_id)));
|
|
1222
1425
|
server.registerTool("read_sheet_text", {
|
|
1223
1426
|
description: `The sheet's text with positions \u2014 items [{str, x, y}] in image px plus the joined text. Optionally restrict to a region {x0, y0, x1, y1}. Use it to read title blocks, room labels, finish schedules, and scale notes. ${COORDS}`,
|
|
1224
1427
|
inputSchema: {
|
|
1225
|
-
sheet:
|
|
1226
|
-
region:
|
|
1227
|
-
}
|
|
1428
|
+
sheet: z2.string(),
|
|
1429
|
+
region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional()
|
|
1430
|
+
},
|
|
1431
|
+
outputSchema: readSheetTextOutput
|
|
1228
1432
|
}, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
|
|
1229
1433
|
}
|
|
1230
1434
|
|
|
1435
|
+
// src/resources.ts
|
|
1436
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1437
|
+
function toBase64(bytes) {
|
|
1438
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
1439
|
+
}
|
|
1440
|
+
function parsePage(session, raw) {
|
|
1441
|
+
const s = Array.isArray(raw) ? raw[0] : raw;
|
|
1442
|
+
if (!/^\d+$/.test(s ?? "")) throw new Error(`Sheet resources are addressed by page number \u2014 got ${JSON.stringify(s)}.`);
|
|
1443
|
+
return session.sheetForPage(Number(s));
|
|
1444
|
+
}
|
|
1445
|
+
function registerResources(server, session) {
|
|
1446
|
+
const sheetEntries = (suffix, mimeType, what) => () => ({
|
|
1447
|
+
resources: session.sheetList().map((s) => ({
|
|
1448
|
+
uri: `takeoff://sheet/${s.pageNum}${suffix}`,
|
|
1449
|
+
name: `${s.key}${suffix.replace("/", " \xB7 ")}`,
|
|
1450
|
+
...s.sheetNumber ? { title: `${s.sheetNumber} \u2014 ${what}` } : { title: `page ${s.pageNum} \u2014 ${what}` },
|
|
1451
|
+
description: `${what} for ${s.key}${s.sheetNumber ? ` (${s.sheetNumber})` : ""}`,
|
|
1452
|
+
mimeType
|
|
1453
|
+
}))
|
|
1454
|
+
});
|
|
1455
|
+
server.registerResource(
|
|
1456
|
+
"sheet-index",
|
|
1457
|
+
"takeoff://sheets",
|
|
1458
|
+
{
|
|
1459
|
+
title: "Sheet index",
|
|
1460
|
+
description: "The loaded plan set at a glance: file, page count, and every sheet's dims, title-block number, detected scale, scale state, and shape count. Read this first.",
|
|
1461
|
+
mimeType: "application/json"
|
|
1462
|
+
},
|
|
1463
|
+
async (uri) => ({
|
|
1464
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(session.index()) }]
|
|
1465
|
+
})
|
|
1466
|
+
);
|
|
1467
|
+
server.registerResource(
|
|
1468
|
+
"sheet",
|
|
1469
|
+
new ResourceTemplate("takeoff://sheet/{page}", { list: sheetEntries("", "application/json", "sheet metadata") }),
|
|
1470
|
+
{
|
|
1471
|
+
title: "Sheet metadata",
|
|
1472
|
+
description: "One sheet: dims (px and pt), title-block sheet number, detected scale, scale state, committed shape count. JSON.",
|
|
1473
|
+
mimeType: "application/json"
|
|
1474
|
+
},
|
|
1475
|
+
async (uri, { page }) => {
|
|
1476
|
+
const s = parsePage(session, page);
|
|
1477
|
+
const idx = session.index();
|
|
1478
|
+
const row = idx.sheets.find((x) => x.page === s.pageNum);
|
|
1479
|
+
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(row) }] };
|
|
1480
|
+
}
|
|
1481
|
+
);
|
|
1482
|
+
server.registerResource(
|
|
1483
|
+
"sheet-text",
|
|
1484
|
+
new ResourceTemplate("takeoff://sheet/{page}/text", { list: sheetEntries("/text", "text/plain", "sheet text") }),
|
|
1485
|
+
{
|
|
1486
|
+
title: "Sheet text",
|
|
1487
|
+
description: "The sheet's text content, reading order, joined \u2014 title block, room labels, schedules, scale notes. For positions use the read_sheet_text tool.",
|
|
1488
|
+
mimeType: "text/plain"
|
|
1489
|
+
},
|
|
1490
|
+
async (uri, { page }) => {
|
|
1491
|
+
const s = parsePage(session, page);
|
|
1492
|
+
return { contents: [{ uri: uri.href, mimeType: "text/plain", text: session.readSheetText(s.key).text }] };
|
|
1493
|
+
}
|
|
1494
|
+
);
|
|
1495
|
+
server.registerResource(
|
|
1496
|
+
"sheet-image",
|
|
1497
|
+
new ResourceTemplate("takeoff://sheet/{page}/image", { list: sheetEntries("/image", "image/png", "rendered page") }),
|
|
1498
|
+
{
|
|
1499
|
+
title: "Rendered page",
|
|
1500
|
+
description: "The page rendered to PNG, long edge capped at 1568 px \u2014 sized for vision-model eyes. Coordinates in the image scale linearly to the tool coordinate space (image px at render scale 2.0).",
|
|
1501
|
+
mimeType: "image/png"
|
|
1502
|
+
},
|
|
1503
|
+
async (uri, { page }) => {
|
|
1504
|
+
const s = parsePage(session, page);
|
|
1505
|
+
const png = await session.renderSheetPng(s.pageNum);
|
|
1506
|
+
return { contents: [{ uri: uri.href, mimeType: "image/png", blob: toBase64(png) }] };
|
|
1507
|
+
}
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1231
1511
|
// package.json
|
|
1232
1512
|
var package_default = {
|
|
1233
1513
|
name: "opentakeoff-mcp",
|
|
1234
|
-
version: "0.
|
|
1514
|
+
version: "0.3.0",
|
|
1235
1515
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
1236
1516
|
type: "module",
|
|
1237
1517
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -1243,9 +1523,11 @@ var package_default = {
|
|
|
1243
1523
|
dev: "node --import tsx server.ts",
|
|
1244
1524
|
start: "node dist/server.js",
|
|
1245
1525
|
build: "esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
1526
|
+
"smoke:dist": "node scripts/smoke-dist.mjs",
|
|
1527
|
+
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
1246
1528
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
1247
1529
|
typecheck: "tsc --noEmit",
|
|
1248
|
-
test: "node --import tsx --test test
|
|
1530
|
+
test: "node --import tsx --test test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
|
|
1249
1531
|
},
|
|
1250
1532
|
dependencies: {
|
|
1251
1533
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
@@ -1286,8 +1568,9 @@ var package_default = {
|
|
|
1286
1568
|
|
|
1287
1569
|
// server.ts
|
|
1288
1570
|
function buildServer(session = new Session()) {
|
|
1289
|
-
const server = new
|
|
1571
|
+
const server = new McpServer2({ name: "opentakeoff", version: package_default.version });
|
|
1290
1572
|
registerTools(server, session);
|
|
1573
|
+
registerResources(server, session);
|
|
1291
1574
|
return server;
|
|
1292
1575
|
}
|
|
1293
1576
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"dev": "node --import tsx server.ts",
|
|
13
13
|
"start": "node dist/server.js",
|
|
14
14
|
"build": "esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
15
|
+
"smoke:dist": "node scripts/smoke-dist.mjs",
|
|
16
|
+
"mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
15
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
16
18
|
"typecheck": "tsc --noEmit",
|
|
17
|
-
"test": "node --import tsx --test test
|
|
19
|
+
"test": "node --import tsx --test test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts"
|
|
18
20
|
},
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|