opentakeoff-mcp 0.1.3 → 0.2.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 +67 -0
- package/dist/server-core.js +150 -6
- package/package.json +1 -1
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:
|
|
@@ -25,6 +28,25 @@ app autosaves. Same engine, same math: the server imports
|
|
|
25
28
|
`web/src/lib/{oneclick,sheets,geometry,totals}` directly, so a shape committed
|
|
26
29
|
here is field-identical to one committed on the canvas.
|
|
27
30
|
|
|
31
|
+
## Run with Docker
|
|
32
|
+
|
|
33
|
+
Build from the repository root so the Dockerfile can bundle the shared web
|
|
34
|
+
engine:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
docker build -f mcp/Dockerfile -t opentakeoff-mcp .
|
|
38
|
+
docker run --rm -i opentakeoff-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Mount local plans read-only and pass that container path to `load_plan`:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
docker run --rm -i -v "$PWD/demo:/plans:ro" opentakeoff-mcp
|
|
45
|
+
docker run --rm -i -e OPENTAKEOFF_MCP_TRACE=1 -v "$PWD/demo:/plans:ro" opentakeoff-mcp
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For example, load `/plans/sample-plan.pdf` after mounting `demo/`.
|
|
49
|
+
|
|
28
50
|
## Quickstart
|
|
29
51
|
|
|
30
52
|
Both `web/` and `mcp/` need their dependencies (the engine's pdf.js lives in
|
|
@@ -85,6 +107,31 @@ includes document text, shape vertices, or result payload content.
|
|
|
85
107
|
Every reply is one compact JSON text item. Failures come back as
|
|
86
108
|
`isError: true` with `{"error": "..."}` — never a dropped connection.
|
|
87
109
|
|
|
110
|
+
## Resources — browse before you measure
|
|
111
|
+
|
|
112
|
+
Tools let an agent act; resources let it **see**. When a plan loads, the sheet
|
|
113
|
+
set becomes browsable natively (`resources/list` re-announces itself via
|
|
114
|
+
`list_changed`):
|
|
115
|
+
|
|
116
|
+
| URI | Contents |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `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`. |
|
|
119
|
+
| `takeoff://sheet/{page}` | One sheet's metadata (JSON), addressed by 1-based page number. |
|
|
120
|
+
| `takeoff://sheet/{page}/text` | The sheet's text, joined — title block, room labels, schedules. Positions live in the `read_sheet_text` tool. |
|
|
121
|
+
| `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`. |
|
|
122
|
+
|
|
123
|
+
Page numbers — not file-derived sheet keys — address resources, so URIs stay
|
|
124
|
+
clean regardless of the PDF's name; the human-facing key (`plan.pdf#2`) and
|
|
125
|
+
title-block number (`A-101`) ride along as the resource name and title.
|
|
126
|
+
Rendering uses `@napi-rs/canvas` (pdf.js's own optional dependency): on a
|
|
127
|
+
platform without a prebuilt binary every non-raster capability still works and
|
|
128
|
+
the image read explains exactly what's missing.
|
|
129
|
+
|
|
130
|
+
The intended agent loop: read `takeoff://sheets` → look at
|
|
131
|
+
`takeoff://sheet/{page}/image` → pick click targets → measure with the tools.
|
|
132
|
+
An image coordinate maps to the tool space (image px at render scale 2.0) by
|
|
133
|
+
multiplying by `width_px / <image pixel width>`.
|
|
134
|
+
|
|
88
135
|
## The coordinate contract
|
|
89
136
|
|
|
90
137
|
All coordinates are **image pixels at render scale 2.0**: PDF points × 2,
|
|
@@ -143,3 +190,23 @@ sheet number (`A-101`) wherever a sheet is named.
|
|
|
143
190
|
npm run typecheck
|
|
144
191
|
npm test # session + tool-layer + e2e, against demo/sample-plan.pdf
|
|
145
192
|
```
|
|
193
|
+
|
|
194
|
+
## Releasing (maintainers)
|
|
195
|
+
|
|
196
|
+
MCP releases live in the **`mcp-v*`** tag namespace — bare `v*` tags belong to
|
|
197
|
+
the app (v0.2.0, v0.3.0 are app releases). The npm artifact publishes manually
|
|
198
|
+
(hardware-key 2FA) **before** the tag is pushed; the workflow refuses to run
|
|
199
|
+
ahead of it.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# 1. bump the version — all three fields together:
|
|
203
|
+
# package.json .version, server.json .version, server.json .packages[0].version
|
|
204
|
+
# 2. from mcp/, publish with the hardware key:
|
|
205
|
+
npm publish
|
|
206
|
+
# 3. tag and push — this fires .github/workflows/publish-mcp.yml:
|
|
207
|
+
git tag mcp-v<version> && git push origin mcp-v<version>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The workflow checks version consistency, requires the npm artifact to exist,
|
|
211
|
+
publishes to the official MCP registry via GitHub OIDC, verifies the listing,
|
|
212
|
+
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)
|
|
@@ -859,6 +886,7 @@ var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert"
|
|
|
859
886
|
var _idn = 0;
|
|
860
887
|
var uid = (p) => `${p}-${Date.now().toString(36)}-${(_idn++).toString(36)}`;
|
|
861
888
|
var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
|
|
889
|
+
var IMAGE_MAX_EDGE = 1568;
|
|
862
890
|
var sheetSummary = (s) => ({
|
|
863
891
|
sheet: s.key,
|
|
864
892
|
page: s.pageNum,
|
|
@@ -920,6 +948,41 @@ var Session = class {
|
|
|
920
948
|
for (const s of this.sheets.values()) if (s.sheetNumber === wanted) return s;
|
|
921
949
|
throw new UserError(`Unknown sheet "${name}" \u2014 loaded sheets: ${[...this.sheets.keys()].join(", ")}.`);
|
|
922
950
|
}
|
|
951
|
+
/** Resource-URI addressing: sheets by 1-based page number. */
|
|
952
|
+
sheetForPage(page) {
|
|
953
|
+
if (!this.doc) throw new UserError("No plan loaded \u2014 call load_plan first.");
|
|
954
|
+
for (const s of this.sheets.values()) if (s.pageNum === page) return s;
|
|
955
|
+
throw new UserError(`No page ${page} \u2014 the loaded plan has pages 1\u2013${this.sheets.size}.`);
|
|
956
|
+
}
|
|
957
|
+
/** Every loaded sheet, in page order — [] before any plan loads. */
|
|
958
|
+
sheetList() {
|
|
959
|
+
return [...this.sheets.values()].sort((a, b) => a.pageNum - b.pageNum);
|
|
960
|
+
}
|
|
961
|
+
/** The takeoff://sheets index payload — cheap (no geometry is built). */
|
|
962
|
+
index() {
|
|
963
|
+
if (!this.doc) {
|
|
964
|
+
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." };
|
|
965
|
+
}
|
|
966
|
+
return {
|
|
967
|
+
file: this.file,
|
|
968
|
+
page_count: this.sheets.size,
|
|
969
|
+
sheets: this.sheetList().map((s) => ({
|
|
970
|
+
...sheetSummary(s),
|
|
971
|
+
scale_set: s.upp != null,
|
|
972
|
+
shape_count: this.shapes.filter((x) => x.sheet_id === s.key).length
|
|
973
|
+
}))
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/** Rendered-page PNG, long edge capped at IMAGE_MAX_EDGE (never above the
|
|
977
|
+
* canvas-native RENDER_SCALE), cached per sheet until the next load_plan. */
|
|
978
|
+
async renderSheetPng(page) {
|
|
979
|
+
const s = this.sheetForPage(page);
|
|
980
|
+
if (!s.png) {
|
|
981
|
+
const scale = Math.min(RENDER_SCALE, IMAGE_MAX_EDGE / Math.max(s.widthPt, s.heightPt));
|
|
982
|
+
s.png = await s.page.renderPng(scale);
|
|
983
|
+
}
|
|
984
|
+
return s.png;
|
|
985
|
+
}
|
|
923
986
|
async ensureGeometry(s) {
|
|
924
987
|
if (!s.geo) {
|
|
925
988
|
const opList = await s.page.operatorList();
|
|
@@ -1151,9 +1214,13 @@ var run = (tool, fn) => async (args) => {
|
|
|
1151
1214
|
};
|
|
1152
1215
|
function registerTools(server, session) {
|
|
1153
1216
|
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}`,
|
|
1217
|
+
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}`,
|
|
1155
1218
|
inputSchema: { path: z.string().describe("Path to a plan PDF on disk") }
|
|
1156
|
-
}, run("load_plan", ({ path: path3 }) =>
|
|
1219
|
+
}, run("load_plan", async ({ path: path3 }) => {
|
|
1220
|
+
const loaded = await session.loadPlan(path3);
|
|
1221
|
+
server.sendResourceListChanged();
|
|
1222
|
+
return loaded;
|
|
1223
|
+
}));
|
|
1157
1224
|
server.registerTool("sheet_info", {
|
|
1158
1225
|
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
1226
|
inputSchema: { sheet: z.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') }
|
|
@@ -1228,10 +1295,86 @@ function registerTools(server, session) {
|
|
|
1228
1295
|
}, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
|
|
1229
1296
|
}
|
|
1230
1297
|
|
|
1298
|
+
// src/resources.ts
|
|
1299
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1300
|
+
function toBase64(bytes) {
|
|
1301
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
1302
|
+
}
|
|
1303
|
+
function parsePage(session, raw) {
|
|
1304
|
+
const s = Array.isArray(raw) ? raw[0] : raw;
|
|
1305
|
+
if (!/^\d+$/.test(s ?? "")) throw new Error(`Sheet resources are addressed by page number \u2014 got ${JSON.stringify(s)}.`);
|
|
1306
|
+
return session.sheetForPage(Number(s));
|
|
1307
|
+
}
|
|
1308
|
+
function registerResources(server, session) {
|
|
1309
|
+
const sheetEntries = (suffix, mimeType, what) => () => ({
|
|
1310
|
+
resources: session.sheetList().map((s) => ({
|
|
1311
|
+
uri: `takeoff://sheet/${s.pageNum}${suffix}`,
|
|
1312
|
+
name: `${s.key}${suffix.replace("/", " \xB7 ")}`,
|
|
1313
|
+
...s.sheetNumber ? { title: `${s.sheetNumber} \u2014 ${what}` } : { title: `page ${s.pageNum} \u2014 ${what}` },
|
|
1314
|
+
description: `${what} for ${s.key}${s.sheetNumber ? ` (${s.sheetNumber})` : ""}`,
|
|
1315
|
+
mimeType
|
|
1316
|
+
}))
|
|
1317
|
+
});
|
|
1318
|
+
server.registerResource(
|
|
1319
|
+
"sheet-index",
|
|
1320
|
+
"takeoff://sheets",
|
|
1321
|
+
{
|
|
1322
|
+
title: "Sheet index",
|
|
1323
|
+
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.",
|
|
1324
|
+
mimeType: "application/json"
|
|
1325
|
+
},
|
|
1326
|
+
async (uri) => ({
|
|
1327
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(session.index()) }]
|
|
1328
|
+
})
|
|
1329
|
+
);
|
|
1330
|
+
server.registerResource(
|
|
1331
|
+
"sheet",
|
|
1332
|
+
new ResourceTemplate("takeoff://sheet/{page}", { list: sheetEntries("", "application/json", "sheet metadata") }),
|
|
1333
|
+
{
|
|
1334
|
+
title: "Sheet metadata",
|
|
1335
|
+
description: "One sheet: dims (px and pt), title-block sheet number, detected scale, scale state, committed shape count. JSON.",
|
|
1336
|
+
mimeType: "application/json"
|
|
1337
|
+
},
|
|
1338
|
+
async (uri, { page }) => {
|
|
1339
|
+
const s = parsePage(session, page);
|
|
1340
|
+
const idx = session.index();
|
|
1341
|
+
const row = idx.sheets.find((x) => x.page === s.pageNum);
|
|
1342
|
+
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(row) }] };
|
|
1343
|
+
}
|
|
1344
|
+
);
|
|
1345
|
+
server.registerResource(
|
|
1346
|
+
"sheet-text",
|
|
1347
|
+
new ResourceTemplate("takeoff://sheet/{page}/text", { list: sheetEntries("/text", "text/plain", "sheet text") }),
|
|
1348
|
+
{
|
|
1349
|
+
title: "Sheet text",
|
|
1350
|
+
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.",
|
|
1351
|
+
mimeType: "text/plain"
|
|
1352
|
+
},
|
|
1353
|
+
async (uri, { page }) => {
|
|
1354
|
+
const s = parsePage(session, page);
|
|
1355
|
+
return { contents: [{ uri: uri.href, mimeType: "text/plain", text: session.readSheetText(s.key).text }] };
|
|
1356
|
+
}
|
|
1357
|
+
);
|
|
1358
|
+
server.registerResource(
|
|
1359
|
+
"sheet-image",
|
|
1360
|
+
new ResourceTemplate("takeoff://sheet/{page}/image", { list: sheetEntries("/image", "image/png", "rendered page") }),
|
|
1361
|
+
{
|
|
1362
|
+
title: "Rendered page",
|
|
1363
|
+
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).",
|
|
1364
|
+
mimeType: "image/png"
|
|
1365
|
+
},
|
|
1366
|
+
async (uri, { page }) => {
|
|
1367
|
+
const s = parsePage(session, page);
|
|
1368
|
+
const png = await session.renderSheetPng(s.pageNum);
|
|
1369
|
+
return { contents: [{ uri: uri.href, mimeType: "image/png", blob: toBase64(png) }] };
|
|
1370
|
+
}
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1231
1374
|
// package.json
|
|
1232
1375
|
var package_default = {
|
|
1233
1376
|
name: "opentakeoff-mcp",
|
|
1234
|
-
version: "0.
|
|
1377
|
+
version: "0.2.0",
|
|
1235
1378
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
1236
1379
|
type: "module",
|
|
1237
1380
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -1286,8 +1429,9 @@ var package_default = {
|
|
|
1286
1429
|
|
|
1287
1430
|
// server.ts
|
|
1288
1431
|
function buildServer(session = new Session()) {
|
|
1289
|
-
const server = new
|
|
1432
|
+
const server = new McpServer2({ name: "opentakeoff", version: package_default.version });
|
|
1290
1433
|
registerTools(server, session);
|
|
1434
|
+
registerResources(server, session);
|
|
1291
1435
|
return server;
|
|
1292
1436
|
}
|
|
1293
1437
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
package/package.json
CHANGED