opentakeoff-mcp 0.1.0 → 0.1.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 +10 -0
- package/dist/server-core.js +95 -14
- package/dist/server.js +0 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -57,6 +57,16 @@ see `src/hush.ts`.)
|
|
|
57
57
|
`tsx` is a runtime dependency, not a build tool: the engine is imported
|
|
58
58
|
straight from `web/src/lib` as TypeScript, so plain `node` can't run it.
|
|
59
59
|
|
|
60
|
+
For tool-call debugging, opt into structured stderr tracing:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
OPENTAKEOFF_MCP_TRACE=1 node --import tsx server.ts
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Each tool call writes one JSON line to stderr with the tool name, duration,
|
|
67
|
+
sheet, result size, and error flag. The trace never writes to stdout and never
|
|
68
|
+
includes document text, shape vertices, or result payload content.
|
|
69
|
+
|
|
60
70
|
## Tools
|
|
61
71
|
|
|
62
72
|
| Tool | What it does |
|
package/dist/server-core.js
CHANGED
|
@@ -1114,25 +1114,50 @@ var Session = class {
|
|
|
1114
1114
|
|
|
1115
1115
|
// src/tools.ts
|
|
1116
1116
|
import { z } from "zod";
|
|
1117
|
+
|
|
1118
|
+
// src/trace.ts
|
|
1119
|
+
var TRACE_ENV = "OPENTAKEOFF_MCP_TRACE";
|
|
1120
|
+
function traceToolCall(tool, args, startedAt, reply) {
|
|
1121
|
+
if (process.env[TRACE_ENV] !== "1") return;
|
|
1122
|
+
const text = reply.content[0]?.text ?? "";
|
|
1123
|
+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
1124
|
+
const sheet = args && typeof args === "object" && "sheet" in args ? args.sheet : void 0;
|
|
1125
|
+
const event = {
|
|
1126
|
+
event: "opentakeoff_mcp_tool_call",
|
|
1127
|
+
tool,
|
|
1128
|
+
duration_ms: Math.round(durationMs * 100) / 100,
|
|
1129
|
+
sheet: typeof sheet === "string" ? sheet : null,
|
|
1130
|
+
result_size: text.length,
|
|
1131
|
+
is_error: reply.isError === true
|
|
1132
|
+
};
|
|
1133
|
+
process.stderr.write(`${JSON.stringify(event)}
|
|
1134
|
+
`);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/tools.ts
|
|
1117
1138
|
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.";
|
|
1118
1139
|
var pointSchema = z.tuple([z.number(), z.number()]);
|
|
1119
1140
|
var roleSchema = z.enum(["floor_area", "deduct"]).default("floor_area");
|
|
1120
|
-
var run = (fn) => async (args) => {
|
|
1141
|
+
var run = (tool, fn) => async (args) => {
|
|
1142
|
+
const startedAt = process.hrtime.bigint();
|
|
1143
|
+
let reply;
|
|
1121
1144
|
try {
|
|
1122
|
-
|
|
1145
|
+
reply = ok(await fn(args));
|
|
1123
1146
|
} catch (e) {
|
|
1124
|
-
|
|
1147
|
+
reply = fail(e);
|
|
1125
1148
|
}
|
|
1149
|
+
traceToolCall(tool, args, startedAt, reply);
|
|
1150
|
+
return reply;
|
|
1126
1151
|
};
|
|
1127
1152
|
function registerTools(server, session) {
|
|
1128
1153
|
server.registerTool("load_plan", {
|
|
1129
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}`,
|
|
1130
1155
|
inputSchema: { path: z.string().describe("Path to a plan PDF on disk") }
|
|
1131
|
-
}, run(({ path: path3 }) => session.loadPlan(path3)));
|
|
1156
|
+
}, run("load_plan", ({ path: path3 }) => session.loadPlan(path3)));
|
|
1132
1157
|
server.registerTool("sheet_info", {
|
|
1133
1158
|
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}`,
|
|
1134
1159
|
inputSchema: { sheet: z.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') }
|
|
1135
|
-
}, run(({ sheet }) => session.sheetInfo(sheet)));
|
|
1160
|
+
}, run("sheet_info", ({ sheet }) => session.sheetInfo(sheet)));
|
|
1136
1161
|
server.registerTool("set_scale", {
|
|
1137
1162
|
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}`,
|
|
1138
1163
|
inputSchema: {
|
|
@@ -1142,7 +1167,7 @@ function registerTools(server, session) {
|
|
|
1142
1167
|
calibrate: z.object({ p1: pointSchema, p2: pointSchema, feet: z.number() }).optional().describe("Two points (image px) a known real distance apart, and that distance in feet"),
|
|
1143
1168
|
use_detected: z.boolean().optional().describe("true = adopt the sheet's detected scale")
|
|
1144
1169
|
}
|
|
1145
|
-
}, run((a) => {
|
|
1170
|
+
}, run("set_scale", (a) => {
|
|
1146
1171
|
const given = [a.label !== void 0, a.upp !== void 0, a.calibrate !== void 0, a.use_detected !== void 0].filter(Boolean).length;
|
|
1147
1172
|
if (given !== 1) throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
|
|
1148
1173
|
return session.setScale(a.sheet, a);
|
|
@@ -1157,7 +1182,7 @@ function registerTools(server, session) {
|
|
|
1157
1182
|
role: roleSchema,
|
|
1158
1183
|
return_verts: z.boolean().default(false).describe("Include the traced polygon's vertices (image px)")
|
|
1159
1184
|
}
|
|
1160
|
-
}, run((a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
|
|
1185
|
+
}, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
|
|
1161
1186
|
server.registerTool("measure_polygon", {
|
|
1162
1187
|
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}`,
|
|
1163
1188
|
inputSchema: {
|
|
@@ -1166,7 +1191,7 @@ function registerTools(server, session) {
|
|
|
1166
1191
|
condition: z.string().optional(),
|
|
1167
1192
|
role: roleSchema
|
|
1168
1193
|
}
|
|
1169
|
-
}, run((a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role })));
|
|
1194
|
+
}, run("measure_polygon", (a) => session.measurePolygon(a.sheet, a.verts, { condition: a.condition, role: a.role })));
|
|
1170
1195
|
server.registerTool("measure_line", {
|
|
1171
1196
|
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}`,
|
|
1172
1197
|
inputSchema: {
|
|
@@ -1174,15 +1199,15 @@ function registerTools(server, session) {
|
|
|
1174
1199
|
pts: z.array(pointSchema).min(2),
|
|
1175
1200
|
condition: z.string().optional()
|
|
1176
1201
|
}
|
|
1177
|
-
}, run((a) => session.measureLine(a.sheet, a.pts, { condition: a.condition })));
|
|
1202
|
+
}, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition })));
|
|
1178
1203
|
server.registerTool("takeoff_summary", {
|
|
1179
1204
|
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}`,
|
|
1180
1205
|
inputSchema: {}
|
|
1181
|
-
}, run(() => session.summary()));
|
|
1206
|
+
}, run("takeoff_summary", () => session.summary()));
|
|
1182
1207
|
server.registerTool("export_takeoff", {
|
|
1183
1208
|
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}`,
|
|
1184
1209
|
inputSchema: { path: z.string().optional().describe("File path to write the payload to") }
|
|
1185
|
-
}, run(async ({ path: outPath }) => {
|
|
1210
|
+
}, run("export_takeoff", async ({ path: outPath }) => {
|
|
1186
1211
|
const payload = session.exportPayload();
|
|
1187
1212
|
if (outPath) {
|
|
1188
1213
|
const { writeFile } = await import("node:fs/promises");
|
|
@@ -1193,19 +1218,75 @@ function registerTools(server, session) {
|
|
|
1193
1218
|
server.registerTool("delete_shape", {
|
|
1194
1219
|
description: `Remove a committed shape by the id returned when it was committed. ${COORDS}`,
|
|
1195
1220
|
inputSchema: { shape_id: z.string() }
|
|
1196
|
-
}, run(({ shape_id }) => session.deleteShape(shape_id)));
|
|
1221
|
+
}, run("delete_shape", ({ shape_id }) => session.deleteShape(shape_id)));
|
|
1197
1222
|
server.registerTool("read_sheet_text", {
|
|
1198
1223
|
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}`,
|
|
1199
1224
|
inputSchema: {
|
|
1200
1225
|
sheet: z.string(),
|
|
1201
1226
|
region: z.object({ x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() }).optional()
|
|
1202
1227
|
}
|
|
1203
|
-
}, run((a) => session.readSheetText(a.sheet, a.region)));
|
|
1228
|
+
}, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
|
|
1204
1229
|
}
|
|
1205
1230
|
|
|
1231
|
+
// package.json
|
|
1232
|
+
var package_default = {
|
|
1233
|
+
name: "opentakeoff-mcp",
|
|
1234
|
+
version: "0.1.2",
|
|
1235
|
+
mcpName: "io.github.kentucky-ai/opentakeoff",
|
|
1236
|
+
type: "module",
|
|
1237
|
+
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
1238
|
+
license: "Apache-2.0",
|
|
1239
|
+
engines: {
|
|
1240
|
+
node: ">=20"
|
|
1241
|
+
},
|
|
1242
|
+
scripts: {
|
|
1243
|
+
dev: "node --import tsx server.ts",
|
|
1244
|
+
start: "node dist/server.js",
|
|
1245
|
+
build: "esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
1246
|
+
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
1247
|
+
typecheck: "tsc --noEmit",
|
|
1248
|
+
test: "node --import tsx --test test/*.test.ts"
|
|
1249
|
+
},
|
|
1250
|
+
dependencies: {
|
|
1251
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
1252
|
+
"pdfjs-dist": "^4.10.38",
|
|
1253
|
+
zod: "^3.24.1"
|
|
1254
|
+
},
|
|
1255
|
+
devDependencies: {
|
|
1256
|
+
"@types/node": "^22.19.21",
|
|
1257
|
+
typescript: "^5.7.2",
|
|
1258
|
+
tsx: "^4.19.2",
|
|
1259
|
+
esbuild: "^0.24.2"
|
|
1260
|
+
},
|
|
1261
|
+
bin: {
|
|
1262
|
+
"opentakeoff-mcp": "dist/server.js"
|
|
1263
|
+
},
|
|
1264
|
+
main: "dist/server.js",
|
|
1265
|
+
files: [
|
|
1266
|
+
"dist",
|
|
1267
|
+
"README.md"
|
|
1268
|
+
],
|
|
1269
|
+
repository: {
|
|
1270
|
+
type: "git",
|
|
1271
|
+
url: "git+https://github.com/Kentucky-ai/opentakeoff.git",
|
|
1272
|
+
directory: "mcp"
|
|
1273
|
+
},
|
|
1274
|
+
homepage: "https://github.com/Kentucky-ai/opentakeoff/tree/main/mcp",
|
|
1275
|
+
keywords: [
|
|
1276
|
+
"mcp",
|
|
1277
|
+
"model-context-protocol",
|
|
1278
|
+
"takeoff",
|
|
1279
|
+
"construction",
|
|
1280
|
+
"estimating",
|
|
1281
|
+
"pdf",
|
|
1282
|
+
"quantity-takeoff",
|
|
1283
|
+
"flooring"
|
|
1284
|
+
]
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1206
1287
|
// server.ts
|
|
1207
1288
|
function buildServer(session = new Session()) {
|
|
1208
|
-
const server = new McpServer({ name: "opentakeoff", version:
|
|
1289
|
+
const server = new McpServer({ name: "opentakeoff", version: package_default.version });
|
|
1209
1290
|
registerTools(server, session);
|
|
1210
1291
|
return server;
|
|
1211
1292
|
}
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// module that defers the server (and its hoisted externals) behind a dynamic
|
|
6
6
|
// import. Same belt as src/hush.ts, one module earlier.
|
|
7
7
|
console.log = console.error.bind(console);
|
|
8
|
-
|
|
9
8
|
// pdf.js 4.x needs Promise.withResolvers (Node 22+); polyfill here so the
|
|
10
9
|
// engines floor stays Node 20 — must exist before pdfjs-dist evaluates.
|
|
11
10
|
if (typeof Promise.withResolvers !== "function") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"mcpName": "io.github.kentucky-ai/opentakeoff",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
6
7
|
"license": "Apache-2.0",
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
"scripts": {
|
|
11
12
|
"dev": "node --import tsx server.ts",
|
|
12
13
|
"start": "node dist/server.js",
|
|
13
|
-
"build": "esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js &&
|
|
14
|
+
"build": "esbuild server.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server-core.js && node scripts/finish-build.mjs",
|
|
14
15
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
15
16
|
"typecheck": "tsc --noEmit",
|
|
16
17
|
"test": "node --import tsx --test test/*.test.ts"
|
|
@@ -50,4 +51,4 @@
|
|
|
50
51
|
"quantity-takeoff",
|
|
51
52
|
"flooring"
|
|
52
53
|
]
|
|
53
|
-
}
|
|
54
|
+
}
|