@dmytro-prototypes/cadbridge-mcp 0.1.11 → 0.1.13

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.
Files changed (3) hide show
  1. package/bin/cli.js +17 -1
  2. package/bin/plugin.js +254 -0
  3. package/package.json +7 -3
package/bin/cli.js CHANGED
@@ -16,6 +16,7 @@ import { createRequire } from "node:module"
16
16
  import { homedir } from "node:os"
17
17
  import { join } from "node:path"
18
18
  import { fileURLToPath } from "node:url"
19
+ import { installPlugin, reportPluginInstall } from "./plugin.js"
19
20
 
20
21
  const require = createRequire(import.meta.url)
21
22
 
@@ -107,7 +108,22 @@ export function installBinary(source, {
107
108
  }
108
109
 
109
110
  export function run(argv = process.argv.slice(2)) {
110
- const binary = installBinary(resolveBinary())
111
+ // The package path and the installed path are different directories, and the
112
+ // two callers below need different ones: the plugin bundle is a sibling of
113
+ // bin/ inside the npm package, while the executable is run from the stable
114
+ // per-user location. Passing the installed path to installPlugin looks for a
115
+ // bundle in a directory that never holds one.
116
+ const packaged = resolveBinary()
117
+ const binary = installBinary(packaged)
118
+
119
+ // Best effort, and deliberately never fatal: the plugin half only matters
120
+ // once AutoCAD restarts, so a locked or unwritable ApplicationPlugins
121
+ // directory must not stop the server half from starting now.
122
+ try {
123
+ reportPluginInstall(installPlugin(packaged))
124
+ } catch (error) {
125
+ process.stderr.write(`CADBridge: skipped the AutoCAD plugin install: ${error.message}\n`)
126
+ }
111
127
  // stdio: "inherit" — this process is a stdio MCP server; the client's pipes
112
128
  // must reach the real binary untouched. Nothing here may write to stdout.
113
129
  const child = spawn(binary, argv, { stdio: "inherit" })
package/bin/plugin.js ADDED
@@ -0,0 +1,254 @@
1
+ // Installs the AutoCAD plugin bundle that ships alongside the server binary.
2
+ //
3
+ // The server and the plugin are two halves of one product: the Rust binary
4
+ // speaks MCP to the client and ZeroMQ to CADBridge.arx, and a mismatched pair
5
+ // fails in ways that look like AutoCAD bugs. Shipping the bundle inside the
6
+ // same platform package and installing it on launch is what keeps the two
7
+ // halves the same age — before this, `npx` updated the server and left
8
+ // whatever plugin the user installed by hand months earlier.
9
+ //
10
+ // The bundle is copied, never built, and it arrives pre-signed: on macOS the
11
+ // inner .bundle is Developer ID signed, notarized and stapled, and the ticket
12
+ // is a plain file inside it, so an npm tarball round-trip preserves the whole
13
+ // assessment. npm also fetches over an HTTP client that sets neither
14
+ // com.apple.quarantine nor Mark-of-the-Web, so nothing here is ever gated by
15
+ // Gatekeeper or SmartScreen.
16
+ import { createHash } from "node:crypto"
17
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
18
+ import { homedir } from "node:os"
19
+ import { dirname, join, sep } from "node:path"
20
+
21
+ // Autodesk's per-user auto-load location. Anything under it with a
22
+ // PackageContents.xml is loaded at AutoCAD startup, no admin rights and no
23
+ // registry entry — and it is a trusted path, so SECURELOAD does not prompt.
24
+ export function pluginRoot({
25
+ platform = process.platform,
26
+ home = homedir(),
27
+ appData = process.env.APPDATA || join(home, "AppData", "Roaming"),
28
+ } = {}) {
29
+ if (platform === "win32") return join(appData, "Autodesk", "ApplicationPlugins")
30
+ if (platform === "darwin") return join(home, "Library", "Application Support", "Autodesk", "ApplicationPlugins")
31
+ return null
32
+ }
33
+
34
+ // The bundle sits next to bin/ in the platform package, so the binary this
35
+ // launcher already resolved locates it without a second require.resolve —
36
+ // which matters because the two must come from the same package version.
37
+ export function pluginSource(binary) {
38
+ return join(dirname(dirname(binary)), "plugin", "CADBridge.bundle")
39
+ }
40
+
41
+ function walk(root, prefix = "") {
42
+ const entries = []
43
+ // Sorted, so the digest depends on the tree's content and not on the order
44
+ // the filesystem happens to hand back.
45
+ for (const entry of readdirSync(join(root, prefix), { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
46
+ const relative = prefix ? join(prefix, entry.name) : entry.name
47
+ if (entry.isDirectory()) entries.push(...walk(root, relative))
48
+ else entries.push(relative)
49
+ }
50
+ return entries
51
+ }
52
+
53
+ // Content plus the executable bit: a bundle whose Mach-O lost +x on the way
54
+ // through a CI artifact is a different bundle, and AutoCAD's failure to load
55
+ // it says nothing about why.
56
+ export function hashTree(root) {
57
+ const digest = createHash("sha256")
58
+ for (const relative of walk(root)) {
59
+ // POSIX separators regardless of host, so a tree packed on Windows and the
60
+ // same tree on macOS agree.
61
+ digest.update(relative.split(sep).join("/"))
62
+ digest.update(statSync(join(root, relative)).mode & 0o111 ? "x" : "-")
63
+ digest.update(readFileSync(join(root, relative)))
64
+ }
65
+ return digest.digest("hex")
66
+ }
67
+
68
+ // AutoCAD releases installed on this machine, newest first. LT is excluded
69
+ // deliberately: it shares the naming but cannot load ObjectARX modules at all,
70
+ // so counting it would report coverage that can never happen.
71
+ //
72
+ // Detection only ever adds information. The bundle is installed either way —
73
+ // it is inert without AutoCAD, and a false negative here (an install in a
74
+ // non-standard location) must not be able to withhold the plugin from someone
75
+ // who does have AutoCAD.
76
+ export function installedAutoCAD({ platform = process.platform, roots } = {}) {
77
+ const search = roots ?? (platform === "win32"
78
+ ? [process.env["ProgramFiles"] || "C:\\Program Files", process.env["ProgramW6432"]].filter(Boolean).map(p => join(p, "Autodesk"))
79
+ : ["/Applications/Autodesk"])
80
+
81
+ const found = new Set()
82
+ for (const root of search) {
83
+ let entries
84
+ try {
85
+ entries = readdirSync(root, { withFileTypes: true })
86
+ } catch {
87
+ continue
88
+ }
89
+ for (const entry of entries) {
90
+ // "AutoCAD 2026" yes, "AutoCAD LT 2026" no.
91
+ const match = /^AutoCAD (\d{4})$/.exec(entry.name)
92
+ if (match) found.add(match[1])
93
+ }
94
+ }
95
+ return [...found].sort().reverse()
96
+ }
97
+
98
+ // Which AutoCAD releases the bundle about to be installed actually serves.
99
+ // Read out of the manifest itself rather than from plugin/series.json, which
100
+ // is a build-time file and is not shipped: the manifest is both what AutoCAD
101
+ // obeys and the only copy that reaches a user's machine. Its per-release
102
+ // Components blocks are generated by scripts/package-contents.mjs, which names
103
+ // the release in each Description.
104
+ export function coveredReleases(manifest) {
105
+ return [...manifest.matchAll(/<Components Description="CADBridge for AutoCAD (\d{4})"/g)].map(([, release]) => release)
106
+ }
107
+
108
+ // Beside PackageContents.xml rather than inside Contents/: the signed bundle
109
+ // is nested one level down, and adding a file inside it would invalidate the
110
+ // seal that Gatekeeper and AutoCAD both check.
111
+ const STAMP = ".cadbridge-npm-install"
112
+
113
+ function installed(destination) {
114
+ try {
115
+ return JSON.parse(readFileSync(join(destination, STAMP), "utf8"))
116
+ } catch {
117
+ // Missing, unreadable or hand-edited all mean the same thing: this is not
118
+ // a copy we can prove we put there, so replace it.
119
+ return null
120
+ }
121
+ }
122
+
123
+ // A directory cannot be swapped the way installBinary swaps a file: rename
124
+ // onto a non-empty directory fails on both platforms. So stage a complete
125
+ // copy, move the old tree aside, move the new one in, and only then delete —
126
+ // and put the old tree back if the second move is the one that fails.
127
+ export function installPlugin(binary, {
128
+ platform = process.platform,
129
+ home = homedir(),
130
+ appData = process.env.APPDATA || join(home, "AppData", "Roaming"),
131
+ env = process.env,
132
+ // Injectable so a test does not depend on what AutoCAD happens to be
133
+ // installed on the machine running it.
134
+ autocad,
135
+ } = {}) {
136
+ if (env.CADBRIDGE_SKIP_PLUGIN_INSTALL) return { status: "skipped" }
137
+
138
+ const root = pluginRoot({ platform, home, appData })
139
+ if (!root) return { status: "unsupported" }
140
+
141
+ const source = pluginSource(binary)
142
+ let version
143
+ let manifest
144
+ try {
145
+ // PackageContents.xml is the file AutoCAD itself reads, so its absence
146
+ // means there is no loadable bundle here whatever else was shipped.
147
+ manifest = readFileSync(join(source, "PackageContents.xml"), "utf8")
148
+ version = /AppVersion="([^"]*)"/.exec(manifest)?.[1] ?? "unknown"
149
+ } catch {
150
+ // Older platform packages shipped the server alone. Nothing to install,
151
+ // and nothing worth saying about it.
152
+ return { status: "absent" }
153
+ }
154
+
155
+ const destination = join(root, "CADBridge.bundle")
156
+ const digest = hashTree(source)
157
+ const covers = { manifest, installedAutoCAD: autocad ?? installedAutoCAD({ platform }) }
158
+ if (installed(destination)?.sha256 === digest) return { status: "current", version, destination, ...covers }
159
+
160
+ mkdirSync(root, { recursive: true })
161
+ // One temp directory holds both the new tree being assembled and the old one
162
+ // moved out of the way, so a single cleanup covers both and no name has to be
163
+ // derived from the pid — a pid-derived name collides with the leftovers of a
164
+ // run that was killed mid-swap, and a rename onto that stale directory fails
165
+ // with exactly the codes that mean "AutoCAD has it open", wedging every later
166
+ // launch behind advice that cannot help.
167
+ const staging = mkdtempSync(join(root, ".cadbridge-staging-"))
168
+ const displaced = join(staging, "displaced")
169
+ // Set only when the previous bundle could NOT be put back, in which case this
170
+ // directory holds the user's sole copy and must survive the cleanup below.
171
+ let stranded = null
172
+ try {
173
+ const staged = join(staging, "CADBridge.bundle")
174
+ cpSync(source, staged, { recursive: true })
175
+ writeFileSync(join(staged, STAMP), `${JSON.stringify({ sha256: digest, version }, null, 2)}\n`)
176
+
177
+ let moved = false
178
+ try {
179
+ renameSync(destination, displaced)
180
+ moved = true
181
+ } catch (error) {
182
+ if (error.code !== "ENOENT") throw error
183
+ }
184
+ try {
185
+ renameSync(staged, destination)
186
+ } catch (error) {
187
+ // Leave the working plugin in place rather than a half-installed one.
188
+ if (moved) {
189
+ try {
190
+ renameSync(displaced, destination)
191
+ } catch {
192
+ // Both moves failed, so the old bundle is still in the temp
193
+ // directory and is now the only copy there is. Keep it and say
194
+ // where, rather than deleting the user's working plugin.
195
+ stranded = displaced
196
+ }
197
+ }
198
+ throw error
199
+ }
200
+ return { status: "installed", version, destination, ...covers }
201
+ } catch (error) {
202
+ // AutoCAD maps CADBridge.arx for the whole session (PackageContents.xml
203
+ // sets LoadOnAutoCADStartup), and Windows will not let a mapped module be
204
+ // renamed or deleted. That is the ordinary case of "the user started
205
+ // AutoCAD first", not a failure worth refusing to start the server over.
206
+ const status = !stranded && ["EBUSY", "EPERM", "EACCES", "ENOTEMPTY"].includes(error.code) ? "locked" : "failed"
207
+ return { status, version, destination, stranded, error, ...covers }
208
+ } finally {
209
+ if (!stranded) rmSync(staging, { recursive: true, force: true })
210
+ }
211
+ }
212
+
213
+ // stdout belongs to the MCP protocol; every byte here goes to stderr, which
214
+ // clients surface in their server logs.
215
+ export function reportPluginInstall(result, write = message => process.stderr.write(`${message}\n`)) {
216
+ // An AutoCAD that is installed but that the bundle does not serve is the one
217
+ // thing the user cannot discover for themselves: AutoCAD ignores a
218
+ // non-matching Components block in silence, so CADBridge would simply never
219
+ // appear there with no clue why. Reported on any status, because the bundle
220
+ // being already current does not make the gap go away.
221
+ if (result.manifest && result.installedAutoCAD?.length) {
222
+ const covered = coveredReleases(result.manifest)
223
+ const missing = result.installedAutoCAD.filter(release => !covered.includes(release))
224
+ if (missing.length) {
225
+ write(
226
+ `CADBridge: no plugin build for AutoCAD ${missing.join(", ")} — ` +
227
+ `this bundle serves ${covered.join(", ") || "no installed release"}. ` +
228
+ `CADBridge will not load there.`,
229
+ )
230
+ }
231
+ }
232
+ if (result.status === "installed") {
233
+ // The plugin's own version, which is not the server's and not this npm
234
+ // package's — the three are released independently and only
235
+ // `protocolVersion` has to agree. Naming it explicitly stops a bug report
236
+ // from guessing.
237
+ write(`CADBridge: installed AutoCAD plugin ${result.version} to ${result.destination}`)
238
+ write("CADBridge: restart AutoCAD to load it.")
239
+ } else if (result.status === "locked") {
240
+ write(
241
+ `CADBridge: could not update the AutoCAD plugin to ${result.version} — ` +
242
+ `${result.destination} is in use. Close AutoCAD and start this server again to finish the update.`,
243
+ )
244
+ } else if (result.status === "failed") {
245
+ write(`CADBridge: could not install the AutoCAD plugin to ${result.destination}: ${result.error.message}`)
246
+ if (result.stranded) {
247
+ // The one case where the user has to act: their previous bundle could
248
+ // not be put back, so name its location instead of leaving them with an
249
+ // empty ApplicationPlugins directory and no idea why.
250
+ write(`CADBridge: the previous plugin is still at ${result.stranded} — move it back to ${result.destination}.`)
251
+ }
252
+ }
253
+ return result
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmytro-prototypes/cadbridge-mcp",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "mcpName": "net.dmytro-prototypes/cadbridge-mcp-autocad",
5
5
  "description": "Drive a live AutoCAD session from an AI client through MCP",
6
6
  "type": "module",
@@ -30,10 +30,14 @@
30
30
  "LICENSE.txt"
31
31
  ],
32
32
  "optionalDependencies": {
33
- "@dmytro-prototypes/cadbridge-mcp-darwin-arm64": "0.1.11",
34
- "@dmytro-prototypes/cadbridge-mcp-win32-x64": "0.1.11"
33
+ "@dmytro-prototypes/cadbridge-mcp-darwin-arm64": "0.1.13",
34
+ "@dmytro-prototypes/cadbridge-mcp-win32-x64": "0.1.13"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
38
+ },
39
+ "cadbridge": {
40
+ "server": "0.1.11",
41
+ "plugin": "0.1.12"
38
42
  }
39
43
  }