opentakeoff-mcp 0.1.2 → 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 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>`).
@@ -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 }) => session.loadPlan(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,11 +1295,87 @@ 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.1.2",
1235
- mcpName: "io.github.kentucky-ai/opentakeoff",
1377
+ version: "0.2.0",
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.",
1238
1381
  license: "Apache-2.0",
@@ -1286,8 +1429,9 @@ var package_default = {
1286
1429
 
1287
1430
  // server.ts
1288
1431
  function buildServer(session = new Session()) {
1289
- const server = new McpServer({ name: "opentakeoff", version: package_default.version });
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.1.2",
4
- "mcpName": "io.github.kentucky-ai/opentakeoff",
3
+ "version": "0.2.0",
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.",
7
7
  "license": "Apache-2.0",