opentakeoff-mcp 0.8.0 → 0.9.2

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,7 +1,8 @@
1
1
  # OpenTakeoff MCP server
2
2
 
3
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).
4
+ `io.github.Kentucky-ai/opentakeoff`, on [Glama](https://glama.ai/mcp/servers/Kentucky-ai/opentakeoff),
5
+ and on [Smithery](https://smithery.ai/servers/Kentucky-ai/opentakeoff).
5
6
 
6
7
  ## Run it in 60 seconds (npx)
7
8
 
@@ -167,9 +168,10 @@ set becomes browsable natively (`resources/list` re-announces itself via
167
168
  Page numbers — not file-derived sheet keys — address resources, so URIs stay
168
169
  clean regardless of the PDF's name; the human-facing key (`plan.pdf#2`) and
169
170
  title-block number (`A-101`) ride along as the resource name and title.
170
- Rendering uses `@napi-rs/canvas` (pdf.js's own optional dependency): on a
171
- platform without a prebuilt binary every non-raster capability still works and
172
- the image read explains exactly what's missing.
171
+ Rendering uses `@napi-rs/canvas`, declared as this package's own optional
172
+ dependency so a plain `npx opentakeoff-mcp` installs the prebuilt binary and
173
+ arrives with eyes; on a platform without a prebuilt binary every non-raster
174
+ capability still works and the image read explains exactly what's missing.
173
175
 
174
176
  The intended agent loop: read `takeoff://sheets` → look at
175
177
  `takeoff://sheet/{page}/image` → pick click targets → measure with the tools.
@@ -240,23 +242,48 @@ npm test # session + tool-layer + e2e, against demo/sample-plan.pdf
240
242
  MCP releases live in the **`mcp-v*`** tag namespace — bare `v*` tags belong to
241
243
  the app (v0.2.0, v0.3.0 are app releases). Releases publish via **npm trusted
242
244
  publishing**: the tag push fires `.github/workflows/publish-mcp.yml`, which
243
- pauses at the `release` environment for maintainer approval, then publishes
244
- the npm artifact over OIDC with a **provenance attestation** (no npm token
245
- exists anywhere — the npm package designates that exact repo + workflow as
246
- its trusted publisher), followed by the MCP registry entry, the GitHub
247
- release, and the MCPB bundle.
245
+ runs straight through no approval click and publishes the npm artifact
246
+ over OIDC with a **provenance attestation** (no npm token exists anywhere —
247
+ the npm package designates that exact repo + workflow as its trusted
248
+ publisher), followed by the MCP registry entry, the GitHub release, and the
249
+ MCPB bundle. The `release` environment's required-reviewer gate existed
250
+ briefly and was deliberately removed (2026-07-22) — the tag push is the one
251
+ human decision, and it's already admin-gated, so a second click added
252
+ friction without adding safety.
248
253
 
249
254
  ```bash
250
255
  # 1. bump the version — all three fields together:
251
256
  # package.json .version, server.json .version, server.json .packages[0].version
252
- # 2. tag and push — this fires the whole release:
257
+ # 2. tag and push — this fires the whole release, fully unattended:
253
258
  git tag mcp-v<version> && git push origin mcp-v<version>
254
- # 3. approve the run (GitHub → Actions → the paused "Publish to MCP Registry" run)
255
259
  ```
256
260
 
261
+ ⚠️ Because there's no approval step, an accidental or mistyped `mcp-v*` tag
262
+ publishes to npm immediately, and npm unpublish is heavily restricted —
263
+ double-check the version before tagging.
264
+
257
265
  The workflow checks version consistency, runs the full publish gate
258
266
  (`prepublishOnly` = typecheck + tests + build), publishes to npm and the
259
267
  official MCP registry, verifies the registry listing, and creates the GitHub
260
268
  release (titled `opentakeoff-mcp <version>`). A re-run skips the npm publish
261
269
  if that version already shipped, so a transient failure downstream is safe to
262
270
  retry.
271
+
272
+ ### Refreshing the Smithery listing
273
+
274
+ Smithery isn't part of the automated release above — it needs a **separate,
275
+ manual** publish after any tool signature change, because of a genuine spec
276
+ conflict between two validators: the official MCPB validator (what
277
+ `npm run mcpb` gates on) rejects a `tools[].inputSchema` key outright, while
278
+ Smithery's registry rejects a bundle *without* real `inputSchema` per tool
279
+ (smithery-ai/cli#770, #797, #787 — no manifest satisfies both). The canonical
280
+ `dist-mcpb/opentakeoff-mcp.mcpb` stays spec-compliant for Claude Desktop / the
281
+ official registry / Glama; `scripts/build-smithery-mcpb.mjs` builds a
282
+ Smithery-only bundle instead, with live-introspected tools + inputSchema baked
283
+ in, packed with a plain zip (bypassing `mcpb validate`, which would reject it):
284
+
285
+ ```bash
286
+ npm run build
287
+ node scripts/build-smithery-mcpb.mjs
288
+ smithery mcp publish dist-smithery/opentakeoff-mcp.mcpb -n Kentucky-ai/opentakeoff
289
+ ```
@@ -1073,7 +1073,7 @@ function drawShapes(ctx, toCanvas, shapes, sheetW, sheetH, longEdge) {
1073
1073
  var SNAP_CELL = 24;
1074
1074
  var SNAP_TOL = 7;
1075
1075
  var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
1076
- var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "fleur", "speckle"];
1076
+ var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo"];
1077
1077
  var mintUuid = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1078
1078
  var uid = (p) => `${p}-${mintUuid()}`;
1079
1079
  var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
@@ -1121,6 +1121,7 @@ var Session = class {
1121
1121
  doc = null;
1122
1122
  sheets = /* @__PURE__ */ new Map();
1123
1123
  conditions = [];
1124
+ markups = [];
1124
1125
  shapes = [];
1125
1126
  /** Newest-last. Capped at UNDO_CAP; the oldest entry falls off the front. */
1126
1127
  journal = [];
@@ -1148,6 +1149,7 @@ var Session = class {
1148
1149
  this.sheets.clear();
1149
1150
  this.conditions = [];
1150
1151
  this.shapes = [];
1152
+ this.markups = [];
1151
1153
  this.file = null;
1152
1154
  this.journal = [];
1153
1155
  this.pendingCommits = [];
@@ -1805,6 +1807,95 @@ var Session = class {
1805
1807
  ...undone.length < n ? { note: `Only ${undone.length} step(s) were available to undo.` } : {}
1806
1808
  };
1807
1809
  }
1810
+ /** Place an annotation. A note ABOUT the work — it never measures anything
1811
+ * and never touches a quantity, which is why there is no review gate here:
1812
+ * the pencil-not-ink rule exists to stop an agent inventing GEOMETRY, and a
1813
+ * cloud saying "verify substrate" is not geometry.
1814
+ *
1815
+ * `condition` attaches it to a scope by finish tag, minting the condition on
1816
+ * first touch exactly like one_click/measure_polygon — so an agent can note
1817
+ * something about CPT-1 before anything is traced for CPT-1. Omit it for a
1818
+ * note about the sheet rather than about a finish. */
1819
+ annotate(a) {
1820
+ const s = this.sheet(a.sheet);
1821
+ const n = ([x, y]) => [x / s.widthPx, y / s.heightPx];
1822
+ if ((a.type === "cloud" || a.type === "highlight") && !a.rect) throw new UserError(`a ${a.type} needs rect: [[x0,y0],[x1,y1]] in image px`);
1823
+ if ((a.type === "text" || a.type === "callout") && !a.at) throw new UserError(`a ${a.type} needs at: [x,y] in image px`);
1824
+ if (a.type === "callout" && !a.target) throw new UserError("a callout needs target: [x,y] \u2014 the point the leader line aims at");
1825
+ const cond = a.condition ? this.conditionFor(a.condition) : null;
1826
+ const m = {
1827
+ id: uid("mk"),
1828
+ sheet_id: s.key,
1829
+ type: a.type,
1830
+ text: a.text || "",
1831
+ condition_id: cond?.id ?? "",
1832
+ rfi_id: "",
1833
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1834
+ ...a.at ? { at: n(a.at) } : {},
1835
+ ...a.target ? { target: n(a.target) } : {},
1836
+ ...a.rect ? { rect: [n(a.rect[0]), n(a.rect[1])] } : {}
1837
+ };
1838
+ this.markups.push(m);
1839
+ return {
1840
+ id: m.id,
1841
+ sheet: s.key,
1842
+ type: m.type,
1843
+ text: m.text,
1844
+ condition: cond?.finish_tag ?? "",
1845
+ condition_id: m.condition_id,
1846
+ note: cond ? `Attached to ${cond.finish_tag} \u2014 it wears that condition's colour on the canvas and in the marked set.` : "Unattached \u2014 a note about the sheet. Pass condition to tie it to a scope."
1847
+ };
1848
+ }
1849
+ /** Read annotations, optionally narrowed to a sheet and/or a condition.
1850
+ * Resolves condition_id to its finish tag so a caller can act on the reply
1851
+ * without joining against the conditions array. */
1852
+ listAnnotations(f = {}) {
1853
+ const tagById = new Map(this.conditions.map((c) => [c.id, c.finish_tag]));
1854
+ let rows = this.markups;
1855
+ if (f.sheet) {
1856
+ const s = this.sheet(f.sheet);
1857
+ rows = rows.filter((m) => m.sheet_id === s.key);
1858
+ }
1859
+ if (f.condition) {
1860
+ const c = this.conditions.find((x) => x.finish_tag === f.condition);
1861
+ if (!c) throw new UserError(`no condition "${f.condition}" \u2014 tags: ${this.conditions.map((x) => x.finish_tag).join(", ") || "(none)"}`);
1862
+ rows = rows.filter((m) => m.condition_id === c.id);
1863
+ }
1864
+ const s0 = this.sheets;
1865
+ const px = (m, p) => {
1866
+ const sh = s0.get(m.sheet_id);
1867
+ if (!p || !sh) return void 0;
1868
+ return [round1(p[0] * sh.widthPx), round1(p[1] * sh.heightPx)];
1869
+ };
1870
+ return {
1871
+ annotations: rows.map((m) => ({
1872
+ id: m.id,
1873
+ sheet: m.sheet_id,
1874
+ type: m.type,
1875
+ text: m.text,
1876
+ condition: tagById.get(m.condition_id) ?? "",
1877
+ condition_id: m.condition_id,
1878
+ ...m.at ? { at: px(m, m.at) } : {},
1879
+ ...m.target ? { target: px(m, m.target) } : {},
1880
+ ...m.rect ? { rect: [px(m, m.rect[0]), px(m, m.rect[1])] } : {}
1881
+ })),
1882
+ count: rows.length,
1883
+ unattached: rows.filter((m) => !m.condition_id).length
1884
+ };
1885
+ }
1886
+ /** Attach an existing annotation to a condition, or detach it with "". The
1887
+ * canvas's Attach/Detach, reachable by an agent. */
1888
+ linkAnnotation(id, condition) {
1889
+ const m = this.markups.find((x) => x.id === id);
1890
+ if (!m) throw new UserError(`no annotation "${id}" \u2014 call list_annotations for real ids`);
1891
+ if (!condition) {
1892
+ m.condition_id = "";
1893
+ return { id: m.id, condition: "", note: "Detached \u2014 now a note about the sheet." };
1894
+ }
1895
+ const c = this.conditionFor(condition);
1896
+ m.condition_id = c.id;
1897
+ return { id: m.id, condition: c.finish_tag, condition_id: c.id, note: `Attached to ${c.finish_tag}.` };
1898
+ }
1808
1899
  /** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
1809
1900
  * store.saveAnnotations stamps) — importable by the app. */
1810
1901
  exportPayload() {
@@ -1816,7 +1907,7 @@ var Session = class {
1816
1907
  sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, units_per_px: s.upp })),
1817
1908
  conditions: this.conditions,
1818
1909
  shapes: this.shapes,
1819
- markups: [],
1910
+ markups: this.markups,
1820
1911
  sheet_group: [],
1821
1912
  last_group: [],
1822
1913
  sheet_tabs: [],
@@ -2113,6 +2204,37 @@ var sheetContextOutput = {
2113
2204
  }),
2114
2205
  hatch: z.object({ families: z.array(hatchFamilyRow), count: z.number().int() })
2115
2206
  };
2207
+ var annotationRow = z.object({
2208
+ id: z.string(),
2209
+ sheet: z.string(),
2210
+ type: z.string(),
2211
+ text: z.string(),
2212
+ condition: z.string().describe("Resolved finish tag, or '' when unattached \u2014 saves joining against conditions[]"),
2213
+ condition_id: z.string(),
2214
+ at: z.tuple([z.number(), z.number()]).optional(),
2215
+ target: z.tuple([z.number(), z.number()]).optional(),
2216
+ rect: z.array(z.tuple([z.number(), z.number()]).optional()).optional()
2217
+ });
2218
+ var annotateOutput = {
2219
+ id: z.string(),
2220
+ sheet: z.string(),
2221
+ type: z.string(),
2222
+ text: z.string(),
2223
+ condition: z.string(),
2224
+ condition_id: z.string(),
2225
+ note: z.string()
2226
+ };
2227
+ var listAnnotationsOutput = {
2228
+ annotations: z.array(annotationRow),
2229
+ count: z.number().int(),
2230
+ unattached: z.number().int().describe("How many carry no condition \u2014 candidates for link_annotation")
2231
+ };
2232
+ var linkAnnotationOutput = {
2233
+ id: z.string(),
2234
+ condition: z.string(),
2235
+ condition_id: z.string().optional(),
2236
+ note: z.string()
2237
+ };
2116
2238
 
2117
2239
  // src/tools.ts
2118
2240
  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.";
@@ -2311,6 +2433,39 @@ function registerTools(server, session) {
2311
2433
  traceToolCall("view_sheet", a, startedAt, reply);
2312
2434
  return reply;
2313
2435
  });
2436
+ server.registerTool("annotate", {
2437
+ description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at).
2438
+
2439
+ Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like one_click/measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself.
2440
+
2441
+ No review gate: the pencil-not-ink rule exists to stop an agent inventing geometry, and a cloud reading "verify substrate" is not geometry. It touches no quantity. ${COORDS}`,
2442
+ inputSchema: {
2443
+ sheet: z2.string().describe("Sheet name or number, as sheet_info reports it"),
2444
+ type: z2.enum(["cloud", "text", "callout", "highlight"]).describe("cloud/highlight need rect; text/callout need at; callout also needs target"),
2445
+ text: z2.string().default("").describe("The note. A cloud with no text still reads as 'look here'"),
2446
+ condition: z2.string().optional().describe("Finish tag to attach this note to, e.g. 'CPT-1' (minted on first use). Omit for an unattached sheet note"),
2447
+ at: pointSchema.optional().describe("Anchor point (image px) \u2014 text and callout"),
2448
+ target: pointSchema.optional().describe("What a callout's leader line points at (image px)"),
2449
+ rect: z2.tuple([pointSchema, pointSchema]).optional().describe("Corners (image px) \u2014 cloud and highlight")
2450
+ },
2451
+ outputSchema: annotateOutput
2452
+ }, run("annotate", (a) => session.annotate(a)));
2453
+ server.registerTool("list_annotations", {
2454
+ description: `Every annotation on the takeoff, with condition_id RESOLVED to its finish tag so you can act on the reply without joining against conditions[]. Filter by sheet, by condition, or both. Coordinates come back in image px (the same frame you passed in), not the normalized form they're stored as. \`unattached\` counts the notes carrying no condition \u2014 the candidates for link_annotation. ${COORDS}`,
2455
+ inputSchema: {
2456
+ sheet: z2.string().optional().describe("Only annotations on this sheet"),
2457
+ condition: z2.string().optional().describe("Only annotations attached to this finish tag")
2458
+ },
2459
+ outputSchema: listAnnotationsOutput
2460
+ }, run("list_annotations", (a) => session.listAnnotations(a)));
2461
+ server.registerTool("link_annotation", {
2462
+ description: `Attach an existing annotation to a condition, or detach it by passing an empty condition \u2014 the canvas's Attach/Detach control, reachable by an agent. Use it to tie up notes left unattached (list_annotations reports how many), or to move one to the finish it actually concerns. Attaching mints the tag on first use.`,
2463
+ inputSchema: {
2464
+ annotation_id: z2.string().describe("Id from annotate or list_annotations"),
2465
+ condition: z2.string().describe("Finish tag to attach to; empty string detaches")
2466
+ },
2467
+ outputSchema: linkAnnotationOutput
2468
+ }, run("link_annotation", (a) => session.linkAnnotation(a.annotation_id, a.condition)));
2314
2469
  }
2315
2470
 
2316
2471
  // src/resources.ts
@@ -2392,7 +2547,7 @@ function registerResources(server, session) {
2392
2547
  // package.json
2393
2548
  var package_default = {
2394
2549
  name: "opentakeoff-mcp",
2395
- version: "0.8.0",
2550
+ version: "0.9.2",
2396
2551
  mcpName: "io.github.Kentucky-ai/opentakeoff",
2397
2552
  type: "module",
2398
2553
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -2415,6 +2570,9 @@ var package_default = {
2415
2570
  "pdfjs-dist": "^4.10.38",
2416
2571
  zod: "^3.24.1"
2417
2572
  },
2573
+ optionalDependencies: {
2574
+ "@napi-rs/canvas": "^0.1.100"
2575
+ },
2418
2576
  devDependencies: {
2419
2577
  "@types/node": "^22.19.21",
2420
2578
  typescript: "^5.7.2",
@@ -2443,7 +2601,12 @@ var package_default = {
2443
2601
  "estimating",
2444
2602
  "pdf",
2445
2603
  "quantity-takeoff",
2446
- "flooring"
2604
+ "flooring",
2605
+ "ai-agent",
2606
+ "agentic-ai",
2607
+ "preconstruction",
2608
+ "construction-management",
2609
+ "project-management"
2447
2610
  ],
2448
2611
  overrides: {
2449
2612
  "fast-uri": "^3.1.4",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.2",
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.",
@@ -23,6 +23,9 @@
23
23
  "pdfjs-dist": "^4.10.38",
24
24
  "zod": "^3.24.1"
25
25
  },
26
+ "optionalDependencies": {
27
+ "@napi-rs/canvas": "^0.1.100"
28
+ },
26
29
  "devDependencies": {
27
30
  "@types/node": "^22.19.21",
28
31
  "typescript": "^5.7.2",
@@ -51,10 +54,15 @@
51
54
  "estimating",
52
55
  "pdf",
53
56
  "quantity-takeoff",
54
- "flooring"
57
+ "flooring",
58
+ "ai-agent",
59
+ "agentic-ai",
60
+ "preconstruction",
61
+ "construction-management",
62
+ "project-management"
55
63
  ],
56
64
  "overrides": {
57
65
  "fast-uri": "^3.1.4",
58
66
  "@hono/node-server": "^2.0.5"
59
67
  }
60
- }
68
+ }