@rui.branco/revit-mcp 1.0.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/lib/bridge.js ADDED
@@ -0,0 +1,161 @@
1
+ // HTTP client for the Revit-side bridge.
2
+ //
3
+ // Node cannot call the Revit API: it is in-process .NET inside revit.exe. So
4
+ // every tool call becomes a small JSON POST to the bridge add-in listening on
5
+ // localhost, which does the Revit work and answers with compact JSON.
6
+ //
7
+ // The whole point of this module is that the model never sees a raw socket
8
+ // error. Each failure mode gets a message that says what is broken and what to
9
+ // do about it.
10
+
11
+ import http from "http";
12
+
13
+ const DEFAULT_URL = "http://localhost:48884/revit-mcp";
14
+ const DEFAULT_TIMEOUT_MS = 30000;
15
+
16
+ // Raw POST. Resolves { statusCode, body } for ANY status code — status handling
17
+ // belongs to call(), which knows the endpoint and can phrase a useful error.
18
+ // Rejects only on transport failure (refused, reset, timeout).
19
+ function httpRequest(url, payload, timeoutMs) {
20
+ return new Promise((resolve, reject) => {
21
+ const target = new URL(url);
22
+ const data = JSON.stringify(payload);
23
+ const req = http.request(
24
+ {
25
+ hostname: target.hostname,
26
+ port: target.port || 80,
27
+ path: target.pathname + target.search,
28
+ method: "POST",
29
+ headers: {
30
+ "Content-Type": "application/json",
31
+ "Content-Length": Buffer.byteLength(data),
32
+ },
33
+ },
34
+ (res) => {
35
+ let body = "";
36
+ res.setEncoding("utf8");
37
+ res.on("data", (chunk) => (body += chunk));
38
+ res.on("end", () => resolve({ statusCode: res.statusCode, body }));
39
+ },
40
+ );
41
+ // Covers both "never connected" and "connected, then went quiet" — the
42
+ // second is what a modal dialog in Revit looks like from out here.
43
+ req.setTimeout(timeoutMs, () => {
44
+ const err = new Error(`no response within ${timeoutMs}ms`);
45
+ err.code = "ETIMEDOUT";
46
+ req.destroy(err);
47
+ });
48
+ req.on("error", reject);
49
+ req.write(data);
50
+ req.end();
51
+ });
52
+ }
53
+
54
+ // The bridge reports handler failures as a JSON body with an `error` key,
55
+ // either a bare string or { code, message, stack }. Returns null when the body
56
+ // is not an error payload.
57
+ function handlerError(parsed) {
58
+ if (!parsed || typeof parsed !== "object") return null;
59
+ const error = parsed.error;
60
+ if (!error) return null;
61
+ if (typeof error === "string") return { code: "", message: error, stack: "" };
62
+ return {
63
+ code: error.code || "",
64
+ message: error.message || "unknown error",
65
+ stack: error.stack || error.stackTrace || "",
66
+ };
67
+ }
68
+
69
+ // The only two codes that mean "this URL is not a route here". The router
70
+ // answers an unrecognised path with UNKNOWN_PATH and an unrecognised route name
71
+ // with UNKNOWN_ENDPOINT; every other 404 comes from a handler that ran and
72
+ // found nothing (ELEMENT_NOT_FOUND, PARAMETER_NOT_FOUND, ...). Those two cases
73
+ // need opposite advice, so the status code alone cannot decide it.
74
+ const ROUTE_MISSING_CODES = new Set(["UNKNOWN_PATH", "UNKNOWN_ENDPOINT"]);
75
+
76
+ function noHandlerMessage(endpoint, baseUrl, detail) {
77
+ return (
78
+ `Revit answered but has no handler for ${endpoint} (HTTP 404). Something is listening on ${baseUrl}, so the bridge add-in is not loaded or is an older build. Check the .addin manifest in %APPDATA%\\Autodesk\\Revit\\Addins\\<version>\\ and restart Revit.` +
79
+ (detail ? `\n${detail}` : "")
80
+ );
81
+ }
82
+
83
+ export function createBridge({ url, timeoutMs, request = httpRequest } = {}) {
84
+ // Trailing slashes make the joined path double up, and REVIT_MCP_URL is
85
+ // hand-typed often enough to be worth normalising here.
86
+ const baseUrl = (url || process.env.REVIT_MCP_URL || DEFAULT_URL).replace(
87
+ /\/+$/,
88
+ "",
89
+ );
90
+ const timeout =
91
+ timeoutMs ||
92
+ Number(process.env.REVIT_MCP_TIMEOUT) ||
93
+ DEFAULT_TIMEOUT_MS;
94
+
95
+ return {
96
+ baseUrl,
97
+ timeoutMs: timeout,
98
+
99
+ async call(endpoint, payload = {}) {
100
+ let response;
101
+ try {
102
+ response = await request(`${baseUrl}${endpoint}`, payload, timeout);
103
+ } catch (err) {
104
+ if (err.code === "ETIMEDOUT" || err.code === "ESOCKETTIMEDOUT") {
105
+ throw new Error(
106
+ `Revit did not answer ${endpoint} within ${timeout}ms. Revit is busy or blocked on a modal dialog — switch to Revit, dismiss any open dialog, and retry. Set REVIT_MCP_TIMEOUT (milliseconds) higher for long operations.`,
107
+ );
108
+ }
109
+ if (err.code === "ECONNREFUSED") {
110
+ throw new Error(
111
+ `Cannot reach Revit at ${baseUrl} — connection refused. Revit is not running, or the revit-mcp bridge add-in is not listening. Start Revit and confirm the add-in loaded, then retry. Override the address with REVIT_MCP_URL.`,
112
+ );
113
+ }
114
+ throw new Error(
115
+ `Cannot reach Revit at ${baseUrl} — ${err.code || "request failed"}: ${err.message}`,
116
+ );
117
+ }
118
+
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(response.body);
122
+ } catch {
123
+ // A 404 that is not even JSON is not this bridge answering.
124
+ if (response.statusCode === 404) {
125
+ throw new Error(noHandlerMessage(endpoint, baseUrl, ""));
126
+ }
127
+ throw new Error(
128
+ `Revit returned a non-JSON response for ${endpoint} (HTTP ${response.statusCode}): ${response.body.slice(0, 200)}`,
129
+ );
130
+ }
131
+
132
+ // A handler that blew up inside Revit — surface the Revit-side code,
133
+ // message and stack verbatim, that is the only place the real cause
134
+ // exists.
135
+ const failure = handlerError(parsed);
136
+
137
+ if (
138
+ response.statusCode === 404 &&
139
+ (!failure || ROUTE_MISSING_CODES.has(failure.code))
140
+ ) {
141
+ throw new Error(
142
+ noHandlerMessage(endpoint, baseUrl, failure ? failure.message : ""),
143
+ );
144
+ }
145
+
146
+ if (failure) {
147
+ throw new Error(
148
+ `Revit failed on ${endpoint}: ${failure.code ? `${failure.code} — ` : ""}${failure.message}${failure.stack ? `\n${failure.stack}` : ""}`,
149
+ );
150
+ }
151
+
152
+ if (response.statusCode >= 400) {
153
+ throw new Error(
154
+ `Revit returned HTTP ${response.statusCode} for ${endpoint}: ${response.body.slice(0, 200)}`,
155
+ );
156
+ }
157
+
158
+ return parsed;
159
+ },
160
+ };
161
+ }
@@ -0,0 +1,86 @@
1
+ // Detail linework and annotation: what a detail sheet is actually made of.
2
+ //
3
+ // Nothing here touches the model. A detail line and a text note are
4
+ // view-specific elements that exist in exactly one view, which is the whole
5
+ // point of a drafting view — see revit_create_drafting_view.
6
+ //
7
+ // Coordinates are the view's own plan coordinates in feet (Revit internal
8
+ // units). The elevation is supplied by the bridge, because Revit refuses a
9
+ // detail curve that is not in the plane of the view, and the response says
10
+ // which elevation it used.
11
+
12
+ import { z } from "zod";
13
+
14
+ const point2 = z.object({ x: z.number(), y: z.number() });
15
+
16
+ export function registerDetailTools(server, bridge) {
17
+ server.tool(
18
+ "revit_draw_detail_lines",
19
+ "Draw detail lines in a drafting view or a plan — the linework of a construction detail. Coordinates are x/y in feet (Revit internal units); the bridge puts them in the plane of the view for you. 'line_style' names a line style loaded in the document ('Thin Lines', 'Medium Lines', whatever the template has); an unknown one is not an error — the lines are drawn in the default style and the response lists 'availableLineStyles' so you can correct it. A section, an elevation or a 3D view is refused with VIEW_CANNOT_HOST_DETAIL. Pass every line in one call: the whole batch is one undo step, and a line Revit refuses comes back under 'failed' with its index while the rest are still drawn.",
20
+ {
21
+ view_id: z
22
+ .number()
23
+ .int()
24
+ .describe("Drafting view or plan view id, from revit_list_views or revit_create_drafting_view"),
25
+ lines: z
26
+ .array(
27
+ z.object({
28
+ start: point2.describe("Start point in feet"),
29
+ end: point2.describe("End point in feet"),
30
+ }),
31
+ )
32
+ .min(1)
33
+ .describe("Lines to draw, all in this one call"),
34
+ line_style: z
35
+ .string()
36
+ .min(1)
37
+ .optional()
38
+ .describe("Line style name, e.g. 'Thin Lines'. Unknown names fall back to the default and are reported."),
39
+ },
40
+ async ({ view_id, lines, line_style }) => {
41
+ try {
42
+ // The tool arguments stay snake_case like every other tool's; the bridge
43
+ // reads viewId / lineStyle — map them here rather than on the C# side.
44
+ const result = await bridge.call("/detail/lines", {
45
+ viewId: view_id,
46
+ lines,
47
+ lineStyle: line_style,
48
+ });
49
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
50
+ } catch (error) {
51
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
52
+ }
53
+ },
54
+ );
55
+
56
+ server.tool(
57
+ "revit_add_text_notes",
58
+ "Add text notes to a view — the annotation on a detail, a plan or a section. x/y are in feet (Revit internal units) and place the top-left corner of the note; the bridge puts them in the plane of the view. 'size' is the text height in feet on the PAPER (2.5 mm is 0.0082), and because Revit keeps text size on the type rather than the note, a size no loaded type carries gets a duplicated type — once per distinct size, reused on later calls. Schedules, sheets and view templates are refused with VIEW_CANNOT_HOST_TEXT. Pass every note in one call: the whole batch is one undo step, and a note Revit refuses comes back under 'failed' with its index.",
59
+ {
60
+ view_id: z.number().int().describe("View id from revit_list_views or revit_create_drafting_view"),
61
+ notes: z
62
+ .array(
63
+ z.object({
64
+ x: z.number().describe("X in feet"),
65
+ y: z.number().describe("Y in feet"),
66
+ text: z.string().min(1).describe("The text of the note"),
67
+ size: z
68
+ .number()
69
+ .positive()
70
+ .optional()
71
+ .describe("Text height in feet on the paper, e.g. 0.0082 for 2.5 mm. Omit for the default type."),
72
+ }),
73
+ )
74
+ .min(1)
75
+ .describe("Notes to place, all in this one call"),
76
+ },
77
+ async ({ view_id, notes }) => {
78
+ try {
79
+ const result = await bridge.call("/detail/text", { viewId: view_id, notes });
80
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
81
+ } catch (error) {
82
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
83
+ }
84
+ },
85
+ );
86
+ }
@@ -0,0 +1,57 @@
1
+ // Diagnostics: what the bridge answered on your behalf while you were not
2
+ // looking.
3
+ //
4
+ // Unattended operation only works because the bridge clicks Revit's modal
5
+ // dialogs and resolves its transaction warnings itself — otherwise Revit's main
6
+ // thread parks on a dialog nobody is there to read and every call after it
7
+ // times out. That is a trade, not a free win: a warning Revit raised and the
8
+ // bridge resolved may mean the model did something the caller never asked for.
9
+ // So suppressed is not swallowed — it all goes into a ring buffer on the Revit
10
+ // side, and these are the tools that read it and switch it off.
11
+
12
+ import { z } from "zod";
13
+
14
+ export function registerDiagnosticsTools(server, bridge) {
15
+ server.tool(
16
+ "revit_diagnostics",
17
+ "Read what the bridge suppressed: the Revit dialogs it answered automatically and the transaction warnings it resolved, oldest first, plus whether auto-dismiss is currently on. CHECK THIS AFTER EVERY BATCH OF WRITES. A silently resolved warning usually means Revit changed something you did not ask for — walls joined differently, an element deleted as a side effect of another — and this buffer is the only record of it. An empty dialogs/failures list means the writes went through cleanly.",
18
+ {},
19
+ async () => {
20
+ try {
21
+ const result = await bridge.call("/diagnostics");
22
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
23
+ } catch (error) {
24
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
25
+ }
26
+ },
27
+ );
28
+
29
+ server.tool(
30
+ "revit_set_auto_dismiss",
31
+ "Turn automatic dialog dismissal on or off, and optionally empty the diagnostics buffer. It is ON by default and that is what makes unattended runs possible: Revit's dialogs get an answer (OK, or Cancel for anything that sounds destructive) instead of parking Revit until a human clicks. Turn it OFF when somebody is working in Revit at the same time and should answer their own dialogs — with it off, a dialog makes calls fail with REVIT_BUSY until it is dismissed by hand. Clearing the buffer before a batch of writes makes the following revit_diagnostics show only that batch.",
32
+ {
33
+ enabled: z
34
+ .boolean()
35
+ .describe(
36
+ "true = the bridge answers Revit's dialogs itself (the default); false = dialogs wait for a human and calls time out meanwhile",
37
+ ),
38
+ clear: z
39
+ .boolean()
40
+ .default(false)
41
+ .describe("Also empty the dialog/warning buffer that revit_diagnostics reads"),
42
+ },
43
+ async ({ enabled, clear }) => {
44
+ try {
45
+ // The tool argument stays snake_case-free and plain, but the bridge
46
+ // reads `autoDismiss` — map it here rather than on the C# side.
47
+ const result = await bridge.call("/diagnostics/config", {
48
+ autoDismiss: enabled,
49
+ clear,
50
+ });
51
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
52
+ } catch (error) {
53
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
54
+ }
55
+ },
56
+ );
57
+ }
@@ -0,0 +1,149 @@
1
+ // Document-level tools: the lifecycle of the document itself — new, open,
2
+ // save, save as, close.
3
+ //
4
+ // Unlike the write tools none of these is a model edit or an undo step —
5
+ // creating, saving, opening and closing a document cannot happen inside a Revit
6
+ // transaction — and revit_new_project / revit_open_project are the tools that
7
+ // work with no document open, which is exactly the state Revit is in when
8
+ // somebody asks for a new project.
9
+
10
+ import { z } from "zod";
11
+
12
+ // Revit ships its templates per version under ProgramData. The metric/English
13
+ // one is the sane default: metric units with English level and parameter names,
14
+ // so everything else in this MCP reads the way the user expects.
15
+ export const DEFAULT_TEMPLATE_PATH =
16
+ "C:\\ProgramData\\Autodesk\\RVT 2027\\Templates\\Default_M_ENG.rte";
17
+
18
+ export function registerDocumentTools(server, bridge) {
19
+ server.tool(
20
+ "revit_new_project",
21
+ "Create a new Revit project from a template and open it, without using the Revit UI. Works when no document is open. The default template is the metric one with English names. Rebuilding a project belongs at the same save_path with overwrite true — do not iterate into a new filename.",
22
+ {
23
+ save_path: z
24
+ .string()
25
+ .min(1)
26
+ .describe(
27
+ "Full path of the .rvt file to create, e.g. 'C:\\\\Projects\\\\House.rvt'. Fails if the file already exists unless overwrite is true; a missing parent folder is created.",
28
+ ),
29
+ template_path: z
30
+ .string()
31
+ .min(1)
32
+ .default(DEFAULT_TEMPLATE_PATH)
33
+ .describe(
34
+ `Full path of the .rte project template (default ${DEFAULT_TEMPLATE_PATH} — metric, English names)`,
35
+ ),
36
+ overwrite: z
37
+ .boolean()
38
+ .optional()
39
+ .describe(
40
+ "Rebuild the project in place: DELETES the existing save_path and the Revit backups beside it (House.0001.rvt, House.0002.rvt) and closes it in Revit first if it is open, then builds the project again at that same path. Defaults to false, which fails with FILE_EXISTS instead. A file something else still holds comes back as FILE_LOCKED — nothing is ever built under a different name.",
41
+ ),
42
+ },
43
+ async ({ save_path, template_path, overwrite }) => {
44
+ try {
45
+ // The tool arguments stay snake_case like every other tool's, but the
46
+ // bridge reads `savePath`/`templatePath` — map it here rather than on
47
+ // the C# side.
48
+ const payload = {
49
+ savePath: save_path,
50
+ templatePath: template_path,
51
+ };
52
+
53
+ // Left off the body entirely unless the caller said something, so the
54
+ // bridge's own default — never overwrite — is what answers for everyone
55
+ // who does not ask.
56
+ if (overwrite !== undefined) {
57
+ payload.overwrite = overwrite;
58
+ }
59
+
60
+ const result = await bridge.call("/document/new", payload);
61
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
62
+ } catch (error) {
63
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
64
+ }
65
+ },
66
+ );
67
+
68
+ server.tool(
69
+ "revit_save",
70
+ "Save the active document in place. A model that has never been saved has no path to save to and comes back as NOT_SAVEABLE — use revit_save_as for that one.",
71
+ {},
72
+ async () => {
73
+ try {
74
+ const result = await bridge.call("/document/save");
75
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
76
+ } catch (error) {
77
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
78
+ }
79
+ },
80
+ );
81
+
82
+ server.tool(
83
+ "revit_save_as",
84
+ "Save the active document to a new path and carry on working in it. An existing file is an error unless overwrite is true; a missing parent folder is created.",
85
+ {
86
+ save_path: z
87
+ .string()
88
+ .min(1)
89
+ .describe(
90
+ "Full path of the .rvt file to write, e.g. 'C:\\\\Projects\\\\House.rvt'. Must not exist unless overwrite is true.",
91
+ ),
92
+ overwrite: z
93
+ .boolean()
94
+ .default(false)
95
+ .describe("Replace save_path if it already exists. Default false: an existing file fails with FILE_EXISTS."),
96
+ },
97
+ async ({ save_path, overwrite }) => {
98
+ try {
99
+ // The tool arguments stay snake_case like every other tool's, but the
100
+ // bridge reads `savePath` — map it here rather than on the C# side.
101
+ const result = await bridge.call("/document/save-as", {
102
+ savePath: save_path,
103
+ overwrite,
104
+ });
105
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
106
+ } catch (error) {
107
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
108
+ }
109
+ },
110
+ );
111
+
112
+ server.tool(
113
+ "revit_open_project",
114
+ "Open an existing .rvt file and make it the active document, without using the Revit UI. Works when no document is open. A path that does not exist comes back as FILE_NOT_FOUND.",
115
+ {
116
+ path: z
117
+ .string()
118
+ .min(1)
119
+ .describe("Full path of the existing .rvt file to open, e.g. 'C:\\\\Projects\\\\House.rvt'"),
120
+ },
121
+ async ({ path }) => {
122
+ try {
123
+ const result = await bridge.call("/document/open", { path });
124
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
125
+ } catch (error) {
126
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
127
+ }
128
+ },
129
+ );
130
+
131
+ server.tool(
132
+ "revit_close_project",
133
+ "Close the active document. Unsaved changes are DISCARDED unless save is true, so save first if the work matters. Closing when nothing is open is not an error — it answers {\"closed\":false}. Revit will not close the active document outright, so the bridge makes another open document active first — a blank scratch project under %TEMP% when this was the only one — and the response reports what became active in activePath / activeTitle / activeIsScratch.",
134
+ {
135
+ save: z
136
+ .boolean()
137
+ .default(false)
138
+ .describe("Save the document before closing it. Default false: unsaved changes are discarded."),
139
+ },
140
+ async ({ save }) => {
141
+ try {
142
+ const result = await bridge.call("/document/close", { save });
143
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
144
+ } catch (error) {
145
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
146
+ }
147
+ },
148
+ );
149
+ }