opentakeoff-mcp 0.7.0 → 0.9.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,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
 
@@ -115,8 +116,10 @@ includes document text, shape vertices, or result payload content.
115
116
  | `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
116
117
  | `delete_shape` | Remove a committed shape by id. |
117
118
  | `edit_shape` | **Revise** a committed shape instead of redoing it: new `verts`, a different `condition`, a different `role`, or any combination — quantities recomputed from the result. Refuses shapes a human affirmed. |
118
- | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was. A whole `detect_rooms` sweep is **one** step. |
119
+ | `edit_materials` | Add/remove/patch supporting-materials rows on a condition the coverage-rate lines (adhesive at N sf/gal, grout at N lf/bag, …) that turn a measured quantity into an order quantity, matching the canvas's Supporting Materials panel. `condition` mints on first touch, like `one_click`/`measure_polygon`. No review gate (materials rows are quantity config, not traced geometry) — edits directly, reversible with `undo_last`. |
120
+ | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored. A whole `detect_rooms` sweep is **one** step. |
119
121
  | `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
122
+ | `find_text` | **Locate** a known string — the complement to `read_sheet_text` (which returns what a region *says*; this finds *where* a string sits). Case-insensitive substring match per pdf.js text run; each hit's center feeds straight into `one_click`'s seed. |
120
123
  | `sheet_context` | The region's STRUCTURE in one frame: classified vector segments (endpoints as drawn, meta byte per segment), text spans with bboxes, and hatch-family instances with content-derived ids — same pattern spec ⇒ same id anywhere on the sheet, so plan↔legend matching is `id === id`. Decimation is declared and counted on every reply: `kept + dropped === total_in_region`, cap applies longest-first so walls survive. |
121
124
  | `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. |
122
125
 
@@ -238,23 +241,48 @@ npm test # session + tool-layer + e2e, against demo/sample-plan.pdf
238
241
  MCP releases live in the **`mcp-v*`** tag namespace — bare `v*` tags belong to
239
242
  the app (v0.2.0, v0.3.0 are app releases). Releases publish via **npm trusted
240
243
  publishing**: the tag push fires `.github/workflows/publish-mcp.yml`, which
241
- pauses at the `release` environment for maintainer approval, then publishes
242
- the npm artifact over OIDC with a **provenance attestation** (no npm token
243
- exists anywhere — the npm package designates that exact repo + workflow as
244
- its trusted publisher), followed by the MCP registry entry, the GitHub
245
- release, and the MCPB bundle.
244
+ runs straight through no approval click and publishes the npm artifact
245
+ over OIDC with a **provenance attestation** (no npm token exists anywhere —
246
+ the npm package designates that exact repo + workflow as its trusted
247
+ publisher), followed by the MCP registry entry, the GitHub release, and the
248
+ MCPB bundle. The `release` environment's required-reviewer gate existed
249
+ briefly and was deliberately removed (2026-07-22) — the tag push is the one
250
+ human decision, and it's already admin-gated, so a second click added
251
+ friction without adding safety.
246
252
 
247
253
  ```bash
248
254
  # 1. bump the version — all three fields together:
249
255
  # package.json .version, server.json .version, server.json .packages[0].version
250
- # 2. tag and push — this fires the whole release:
256
+ # 2. tag and push — this fires the whole release, fully unattended:
251
257
  git tag mcp-v<version> && git push origin mcp-v<version>
252
- # 3. approve the run (GitHub → Actions → the paused "Publish to MCP Registry" run)
253
258
  ```
254
259
 
260
+ ⚠️ Because there's no approval step, an accidental or mistyped `mcp-v*` tag
261
+ publishes to npm immediately, and npm unpublish is heavily restricted —
262
+ double-check the version before tagging.
263
+
255
264
  The workflow checks version consistency, runs the full publish gate
256
265
  (`prepublishOnly` = typecheck + tests + build), publishes to npm and the
257
266
  official MCP registry, verifies the registry listing, and creates the GitHub
258
267
  release (titled `opentakeoff-mcp <version>`). A re-run skips the npm publish
259
268
  if that version already shipped, so a transient failure downstream is safe to
260
269
  retry.
270
+
271
+ ### Refreshing the Smithery listing
272
+
273
+ Smithery isn't part of the automated release above — it needs a **separate,
274
+ manual** publish after any tool signature change, because of a genuine spec
275
+ conflict between two validators: the official MCPB validator (what
276
+ `npm run mcpb` gates on) rejects a `tools[].inputSchema` key outright, while
277
+ Smithery's registry rejects a bundle *without* real `inputSchema` per tool
278
+ (smithery-ai/cli#770, #797, #787 — no manifest satisfies both). The canonical
279
+ `dist-mcpb/opentakeoff-mcp.mcpb` stays spec-compliant for Claude Desktop / the
280
+ official registry / Glama; `scripts/build-smithery-mcpb.mjs` builds a
281
+ Smithery-only bundle instead, with live-introspected tools + inputSchema baked
282
+ in, packed with a plain zip (bypassing `mcpb validate`, which would reject it):
283
+
284
+ ```bash
285
+ npm run build
286
+ node scripts/build-smithery-mcpb.mjs
287
+ smithery mcp publish dist-smithery/opentakeoff-mcp.mcpb -n Kentucky-ai/opentakeoff
288
+ ```
@@ -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 = [];
@@ -1711,6 +1713,64 @@ var Session = class {
1711
1713
  agent_edits: this.shapes[i].origin?.agent_edits ?? 0
1712
1714
  };
1713
1715
  }
1716
+ /** Add/remove/patch supporting-materials rows on a condition, in one call.
1717
+ * Unlike editShape there is no review gate to check — materials rows carry
1718
+ * no origin/reviewed field, because they are quantity CONFIG (a coverage
1719
+ * rate), not geometry a human traced. Validated all-or-nothing before
1720
+ * anything is written: a bad id anywhere in remove/patch throws and nothing
1721
+ * changes, same discipline as the shapes tools. Reversible with undo_last —
1722
+ * one journal entry snapshots the condition's whole materials array before
1723
+ * the call, restored verbatim on undo (same pattern as editShape's `before`
1724
+ * capture, simpler here because there is no per-row provenance to preserve). */
1725
+ editMaterials(tag, opts) {
1726
+ const add = opts.add ?? [], remove = opts.remove ?? [], patch = opts.patch ?? [];
1727
+ if (!add.length && !remove.length && !patch.length) {
1728
+ throw new UserError("Nothing to change \u2014 pass at least one of add, remove, patch.");
1729
+ }
1730
+ for (let i = 0; i < add.length; i++) {
1731
+ if (!add[i].name.trim()) throw new UserError(`add[${i}]: name required.`);
1732
+ }
1733
+ const existingMaterials = this.conditions.find((x) => x.finish_tag === tag)?.materials ?? [];
1734
+ const byId = new Map(existingMaterials.map((m) => [m.id, m]));
1735
+ for (const id of remove) {
1736
+ if (!byId.has(id)) throw new UserError(`remove: no material row ${JSON.stringify(id)} on condition ${JSON.stringify(tag)}.`);
1737
+ }
1738
+ for (let i = 0; i < patch.length; i++) {
1739
+ if (!byId.has(patch[i].id)) throw new UserError(`patch[${i}]: no material row ${JSON.stringify(patch[i].id)} on condition ${JSON.stringify(tag)}.`);
1740
+ if (!Object.keys(patch[i].fields).length) throw new UserError(`patch[${i}]: fields must be non-empty.`);
1741
+ }
1742
+ const c = this.conditionFor(tag);
1743
+ const before = structuredClone(c.materials);
1744
+ const added = [];
1745
+ for (const a of add) {
1746
+ const row = {
1747
+ id: uid("mat"),
1748
+ name: a.name.trim(),
1749
+ per: Math.max(0, a.per ?? 0),
1750
+ basis: a.basis ?? "area",
1751
+ unit: a.unit ?? "",
1752
+ round: a.round ?? true,
1753
+ ...a.note ? { note: a.note } : {}
1754
+ };
1755
+ c.materials.push(row);
1756
+ added.push(row.id);
1757
+ }
1758
+ const removed = new Set(remove);
1759
+ if (removed.size) c.materials = c.materials.filter((m) => !removed.has(m.id));
1760
+ const patched = [];
1761
+ for (const p of patch) {
1762
+ const m = c.materials.find((x) => x.id === p.id);
1763
+ Object.assign(m, p.fields);
1764
+ patched.push(p.id);
1765
+ }
1766
+ this.record({ op: "materials", tool: "edit_materials", condition_id: c.id, before });
1767
+ return {
1768
+ condition: tag,
1769
+ condition_id: c.id,
1770
+ changed: { added, removed: [...removed], patched },
1771
+ materials: c.materials
1772
+ };
1773
+ }
1714
1774
  /** Step back over this session's own last n mutations, newest first. Each
1715
1775
  * entry's inverse is exact (see JournalEntry), so this restores state rather
1716
1776
  * than approximating it. Reads are not journaled, so undo never has to step
@@ -1728,6 +1788,10 @@ var Session = class {
1728
1788
  const i = this.shapes.findIndex((x) => x.id === e.before.id);
1729
1789
  if (i >= 0) this.shapes[i] = e.before;
1730
1790
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: i >= 0 ? 1 : 0 });
1791
+ } else if (e.op === "materials") {
1792
+ const c = this.conditions.find((x) => x.id === e.condition_id);
1793
+ if (c) c.materials = e.before;
1794
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
1731
1795
  } else {
1732
1796
  for (const { shape, index } of e.removed) {
1733
1797
  this.shapes.splice(Math.min(index, this.shapes.length), 0, shape);
@@ -1743,6 +1807,95 @@ var Session = class {
1743
1807
  ...undone.length < n ? { note: `Only ${undone.length} step(s) were available to undo.` } : {}
1744
1808
  };
1745
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
+ }
1746
1899
  /** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
1747
1900
  * store.saveAnnotations stamps) — importable by the app. */
1748
1901
  exportPayload() {
@@ -1754,7 +1907,7 @@ var Session = class {
1754
1907
  sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, units_per_px: s.upp })),
1755
1908
  conditions: this.conditions,
1756
1909
  shapes: this.shapes,
1757
- markups: [],
1910
+ markups: this.markups,
1758
1911
  sheet_group: [],
1759
1912
  last_group: [],
1760
1913
  sheet_tabs: [],
@@ -1766,6 +1919,30 @@ var Session = class {
1766
1919
  const items = region ? s.text.filter((t) => t.x >= region.x0 && t.x <= region.x1 && t.y >= region.y0 && t.y <= region.y1) : s.text;
1767
1920
  return { sheet: s.key, items, text: items.map((t) => t.str).join(" ") };
1768
1921
  }
1922
+ /** LOCATE a known string — the complement to readSheetText (which returns
1923
+ * what a region SAYS; this finds WHERE a string you already know sits).
1924
+ * Case-insensitive substring match per pdf.js text run, so a room label
1925
+ * split across runs ("OFFICE" then "134" as separate items) needs its own
1926
+ * find_text call per fragment, or read_sheet_text over a region to see the
1927
+ * whole thing at once — this tool doesn't merge runs into lines. Reuses the
1928
+ * bbox spans sheet_context lazily builds (same cache, same textSpans()
1929
+ * call), so calling both on one sheet costs the extraction once. */
1930
+ findText(name, q, opts = {}) {
1931
+ const query = q.trim();
1932
+ if (!query) throw new UserError("q must be a non-empty string.");
1933
+ const s = this.sheet(name);
1934
+ if (!s.spans) s.spans = textSpans(s.page);
1935
+ const r = opts.region;
1936
+ const needle = query.toLowerCase();
1937
+ const limit = opts.limit ?? 200;
1938
+ const all = s.spans.filter((sp) => sp.str.toLowerCase().includes(needle) && (!r || sp.x0 <= r.x1 && sp.x1 >= r.x0 && sp.y0 <= r.y1 && sp.y1 >= r.y0));
1939
+ const hits = all.slice(0, limit).map((sp) => ({
1940
+ str: sp.str,
1941
+ bbox: [sp.x0, sp.y0, sp.x1, sp.y1],
1942
+ center: [round1((sp.x0 + sp.x1) / 2), round1((sp.y0 + sp.y1) / 2)]
1943
+ }));
1944
+ return { sheet: s.key, q: query, count: all.length, truncated: all.length > hits.length, hits };
1945
+ }
1769
1946
  };
1770
1947
 
1771
1948
  // src/tools.ts
@@ -1949,14 +2126,44 @@ var undoLastOutput = {
1949
2126
  undone: z.number().int().describe("Steps actually reversed"),
1950
2127
  steps: z.array(z.object({
1951
2128
  seq: z.number().int(),
1952
- op: z.enum(["commit", "edit", "delete"]),
2129
+ op: z.enum(["commit", "edit", "delete", "materials"]),
1953
2130
  tool: z.string().describe("The tool call this step came from"),
1954
- shapes: z.number().int().describe("Shapes affected by reversing this step")
2131
+ shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes)")
1955
2132
  })).describe("Newest first"),
1956
2133
  shape_count: z.number().int().describe("Committed shapes after the undo"),
1957
2134
  remaining: z.number().int().describe("Steps still available to undo"),
1958
2135
  note: z.string().optional()
1959
2136
  };
2137
+ var findTextOutput = {
2138
+ sheet: z.string(),
2139
+ q: z.string(),
2140
+ count: z.number().int().describe("Total matches before the limit cap"),
2141
+ truncated: z.boolean().describe("true = count exceeds hits.length; narrow the region or raise limit"),
2142
+ hits: z.array(z.object({
2143
+ str: z.string().describe("The matched pdf.js text run, verbatim (may be shorter than the full label \u2014 runs aren't merged into lines)"),
2144
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).describe("[x0, y0, x1, y1] image px"),
2145
+ center: z.tuple([z.number(), z.number()]).describe("Bbox center, image px \u2014 feed straight into one_click's seed")
2146
+ }))
2147
+ };
2148
+ var materialRow = z.object({
2149
+ id: z.string(),
2150
+ name: z.string(),
2151
+ per: z.number().describe("Coverage rate: basis \xF7 per = order quantity"),
2152
+ basis: z.enum(["area", "linear", "count"]).describe("Which of the condition's totals this row's quantity is computed against"),
2153
+ unit: z.string(),
2154
+ round: z.boolean().describe("true = round up to whole purchase units (the default \u2014 you buy whole bags/buckets)"),
2155
+ note: z.string().optional()
2156
+ });
2157
+ var editMaterialsOutput = {
2158
+ condition: z.string().describe("The finish tag passed in"),
2159
+ condition_id: z.string(),
2160
+ changed: z.object({
2161
+ added: z.array(z.string()).describe("Ids of newly added rows"),
2162
+ removed: z.array(z.string()).describe("Ids removed"),
2163
+ patched: z.array(z.string()).describe("Ids whose fields changed")
2164
+ }),
2165
+ materials: z.array(materialRow).describe("The condition's full materials array after this write")
2166
+ };
1960
2167
  var readSheetTextOutput = {
1961
2168
  sheet: z.string(),
1962
2169
  items: z.array(z.object({ str: z.string(), x: z.number(), y: z.number() })).describe("Positioned text items (image px)"),
@@ -1997,6 +2204,37 @@ var sheetContextOutput = {
1997
2204
  }),
1998
2205
  hatch: z.object({ families: z.array(hatchFamilyRow), count: z.number().int() })
1999
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
+ };
2000
2238
 
2001
2239
  // src/tools.ts
2002
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.";
@@ -2129,8 +2367,28 @@ function registerTools(server, session) {
2129
2367
  },
2130
2368
  outputSchema: editShapeOutput
2131
2369
  }, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role })));
2370
+ server.registerTool("edit_materials", {
2371
+ description: `Add, remove, or patch supporting-materials rows on a condition \u2014 the coverage-rate lines that turn a measured area/length/count into an order quantity (adhesive at N sf/gal, grout at N lf/bag, \u2026), matching the canvas's per-condition Supporting Materials panel. Each row is {name, per, basis, unit, round, note}: quantity = the condition's basis total (area/linear/count) \xF7 per, rounded up to whole purchase units unless round:false. condition names an existing OR NEW finish tag (minted on first touch, same as one_click/measure_polygon) \u2014 add alone is enough to seed materials on a condition before you've traced anything. remove/patch target existing row ids from this reply or export_takeoff (takeoff_summary strips materials for a compact quantities-only reply); a bad id 404s the WHOLE call before anything is written, and referencing an id on a tag with no condition yet errors rather than silently minting an empty one. No review gate here \u2014 materials rows are quantity config, not traced geometry, so this edits directly; undo_last reverses a call in one step (the condition's whole materials array, snapshotted before the write, restored verbatim).`,
2372
+ inputSchema: {
2373
+ condition: z2.string().describe("Finish tag, e.g. 'CPT-1'"),
2374
+ add: z2.array(z2.object({
2375
+ name: z2.string().min(1),
2376
+ per: z2.number().min(0).optional().describe("Coverage rate \u2014 basis units per purchase unit, e.g. 250 for 1 gal / 250 sf. Default 0 (quantity 0 until set)"),
2377
+ basis: z2.enum(["area", "linear", "count"]).optional().describe("Which of the condition's totals this row divides against \u2014 default 'area' (total SF)"),
2378
+ unit: z2.string().optional().describe("Purchase unit, e.g. 'gal', 'bag', 'roll'"),
2379
+ round: z2.boolean().optional().describe("Round up to whole purchase units \u2014 default true"),
2380
+ note: z2.string().optional()
2381
+ })).optional().describe("New rows to add"),
2382
+ remove: z2.array(z2.string()).optional().describe("Existing row ids to remove"),
2383
+ patch: z2.array(z2.object({
2384
+ id: z2.string(),
2385
+ fields: z2.record(z2.union([z2.string(), z2.number(), z2.boolean()])).describe("Field:value pairs \u2014 name/per/basis/unit/round/note only")
2386
+ })).optional().describe("Field changes on existing rows")
2387
+ },
2388
+ outputSchema: editMaterialsOutput
2389
+ }, run("edit_materials", (a) => session.editMaterials(a.condition, { add: a.add, remove: a.remove, patch: a.patch })));
2132
2390
  server.registerTool("undo_last", {
2133
- description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, or a delete_shape. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
2391
+ description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, or an edit_materials call. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
2134
2392
  inputSchema: {
2135
2393
  n: z2.number().int().min(1).max(UNDO_CAP).default(1).describe(`How many steps to reverse (1\u2013${UNDO_CAP})`)
2136
2394
  },
@@ -2144,6 +2402,16 @@ function registerTools(server, session) {
2144
2402
  },
2145
2403
  outputSchema: readSheetTextOutput
2146
2404
  }, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
2405
+ server.registerTool("find_text", {
2406
+ description: `LOCATE a known string on a sheet \u2014 the complement to read_sheet_text (which returns what a region SAYS; this finds WHERE a string you already know sits). Case-insensitive substring match against each pdf.js text run, so a room label split across runs ("OFFICE" then "134" as separate items) needs a find_text call per fragment, or read_sheet_text over a region to see the whole thing joined. Every hit's center feeds straight into one_click as the seed \u2014 the locate-then-trace workflow: find_text the room number, one_click at (or just past) its center. Optionally restrict to a region {x0, y0, x1, y1}; results cap at limit (default 200), with count/truncated telling you exactly how much a tighter region or higher limit would recover. ${COORDS}`,
2407
+ inputSchema: {
2408
+ sheet: z2.string(),
2409
+ q: z2.string().min(1).describe("Text to find \u2014 a room number ('134'), a label fragment ('RECEPTION'), a schedule tag ('CPT-1')"),
2410
+ region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Rect in image px (origin top-left, y down); omit for the full sheet"),
2411
+ limit: z2.number().int().min(1).max(2e3).default(200).describe("Max hits returned")
2412
+ },
2413
+ outputSchema: findTextOutput
2414
+ }, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
2147
2415
  server.registerTool("view_sheet", {
2148
2416
  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}`,
2149
2417
  inputSchema: {
@@ -2165,6 +2433,39 @@ function registerTools(server, session) {
2165
2433
  traceToolCall("view_sheet", a, startedAt, reply);
2166
2434
  return reply;
2167
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)));
2168
2469
  }
2169
2470
 
2170
2471
  // src/resources.ts
@@ -2246,7 +2547,7 @@ function registerResources(server, session) {
2246
2547
  // package.json
2247
2548
  var package_default = {
2248
2549
  name: "opentakeoff-mcp",
2249
- version: "0.7.0",
2550
+ version: "0.9.0",
2250
2551
  mcpName: "io.github.Kentucky-ai/opentakeoff",
2251
2552
  type: "module",
2252
2553
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -2297,7 +2598,12 @@ var package_default = {
2297
2598
  "estimating",
2298
2599
  "pdf",
2299
2600
  "quantity-takeoff",
2300
- "flooring"
2601
+ "flooring",
2602
+ "ai-agent",
2603
+ "agentic-ai",
2604
+ "preconstruction",
2605
+ "construction-management",
2606
+ "project-management"
2301
2607
  ],
2302
2608
  overrides: {
2303
2609
  "fast-uri": "^3.1.4",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.",
@@ -51,10 +51,15 @@
51
51
  "estimating",
52
52
  "pdf",
53
53
  "quantity-takeoff",
54
- "flooring"
54
+ "flooring",
55
+ "ai-agent",
56
+ "agentic-ai",
57
+ "preconstruction",
58
+ "construction-management",
59
+ "project-management"
55
60
  ],
56
61
  "overrides": {
57
62
  "fast-uri": "^3.1.4",
58
63
  "@hono/node-server": "^2.0.5"
59
64
  }
60
- }
65
+ }