@dmytro-prototypes/cadbridge-mcp 0.1.11 → 0.1.12

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 +192 -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,192 @@
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, 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
+ // Beside PackageContents.xml rather than inside Contents/: the signed bundle
69
+ // is nested one level down, and adding a file inside it would invalidate the
70
+ // seal that Gatekeeper and AutoCAD both check.
71
+ const STAMP = ".cadbridge-npm-install"
72
+
73
+ function installed(destination) {
74
+ try {
75
+ return JSON.parse(readFileSync(join(destination, STAMP), "utf8"))
76
+ } catch {
77
+ // Missing, unreadable or hand-edited all mean the same thing: this is not
78
+ // a copy we can prove we put there, so replace it.
79
+ return null
80
+ }
81
+ }
82
+
83
+ // A directory cannot be swapped the way installBinary swaps a file: rename
84
+ // onto a non-empty directory fails on both platforms. So stage a complete
85
+ // copy, move the old tree aside, move the new one in, and only then delete —
86
+ // and put the old tree back if the second move is the one that fails.
87
+ export function installPlugin(binary, {
88
+ platform = process.platform,
89
+ home = homedir(),
90
+ appData = process.env.APPDATA || join(home, "AppData", "Roaming"),
91
+ env = process.env,
92
+ } = {}) {
93
+ if (env.CADBRIDGE_SKIP_PLUGIN_INSTALL) return { status: "skipped" }
94
+
95
+ const root = pluginRoot({ platform, home, appData })
96
+ if (!root) return { status: "unsupported" }
97
+
98
+ const source = pluginSource(binary)
99
+ let version
100
+ try {
101
+ // PackageContents.xml is the file AutoCAD itself reads, so its absence
102
+ // means there is no loadable bundle here whatever else was shipped.
103
+ version = /AppVersion="([^"]*)"/.exec(readFileSync(join(source, "PackageContents.xml"), "utf8"))?.[1] ?? "unknown"
104
+ } catch {
105
+ // Older platform packages shipped the server alone. Nothing to install,
106
+ // and nothing worth saying about it.
107
+ return { status: "absent" }
108
+ }
109
+
110
+ const destination = join(root, "CADBridge.bundle")
111
+ const digest = hashTree(source)
112
+ if (installed(destination)?.sha256 === digest) return { status: "current", version, destination }
113
+
114
+ mkdirSync(root, { recursive: true })
115
+ // One temp directory holds both the new tree being assembled and the old one
116
+ // moved out of the way, so a single cleanup covers both and no name has to be
117
+ // derived from the pid — a pid-derived name collides with the leftovers of a
118
+ // run that was killed mid-swap, and a rename onto that stale directory fails
119
+ // with exactly the codes that mean "AutoCAD has it open", wedging every later
120
+ // launch behind advice that cannot help.
121
+ const staging = mkdtempSync(join(root, ".cadbridge-staging-"))
122
+ const displaced = join(staging, "displaced")
123
+ // Set only when the previous bundle could NOT be put back, in which case this
124
+ // directory holds the user's sole copy and must survive the cleanup below.
125
+ let stranded = null
126
+ try {
127
+ const staged = join(staging, "CADBridge.bundle")
128
+ cpSync(source, staged, { recursive: true })
129
+ writeFileSync(join(staged, STAMP), `${JSON.stringify({ sha256: digest, version }, null, 2)}\n`)
130
+
131
+ let moved = false
132
+ try {
133
+ renameSync(destination, displaced)
134
+ moved = true
135
+ } catch (error) {
136
+ if (error.code !== "ENOENT") throw error
137
+ }
138
+ try {
139
+ renameSync(staged, destination)
140
+ } catch (error) {
141
+ // Leave the working plugin in place rather than a half-installed one.
142
+ if (moved) {
143
+ try {
144
+ renameSync(displaced, destination)
145
+ } catch {
146
+ // Both moves failed, so the old bundle is still in the temp
147
+ // directory and is now the only copy there is. Keep it and say
148
+ // where, rather than deleting the user's working plugin.
149
+ stranded = displaced
150
+ }
151
+ }
152
+ throw error
153
+ }
154
+ return { status: "installed", version, destination }
155
+ } catch (error) {
156
+ // AutoCAD maps CADBridge.arx for the whole session (PackageContents.xml
157
+ // sets LoadOnAutoCADStartup), and Windows will not let a mapped module be
158
+ // renamed or deleted. That is the ordinary case of "the user started
159
+ // AutoCAD first", not a failure worth refusing to start the server over.
160
+ const status = !stranded && ["EBUSY", "EPERM", "EACCES", "ENOTEMPTY"].includes(error.code) ? "locked" : "failed"
161
+ return { status, version, destination, stranded, error }
162
+ } finally {
163
+ if (!stranded) rmSync(staging, { recursive: true, force: true })
164
+ }
165
+ }
166
+
167
+ // stdout belongs to the MCP protocol; every byte here goes to stderr, which
168
+ // clients surface in their server logs.
169
+ export function reportPluginInstall(result, write = message => process.stderr.write(`${message}\n`)) {
170
+ if (result.status === "installed") {
171
+ // The plugin's own version, which is not the server's and not this npm
172
+ // package's — the three are released independently and only
173
+ // `protocolVersion` has to agree. Naming it explicitly stops a bug report
174
+ // from guessing.
175
+ write(`CADBridge: installed AutoCAD plugin ${result.version} to ${result.destination}`)
176
+ write("CADBridge: restart AutoCAD to load it.")
177
+ } else if (result.status === "locked") {
178
+ write(
179
+ `CADBridge: could not update the AutoCAD plugin to ${result.version} — ` +
180
+ `${result.destination} is in use. Close AutoCAD and start this server again to finish the update.`,
181
+ )
182
+ } else if (result.status === "failed") {
183
+ write(`CADBridge: could not install the AutoCAD plugin to ${result.destination}: ${result.error.message}`)
184
+ if (result.stranded) {
185
+ // The one case where the user has to act: their previous bundle could
186
+ // not be put back, so name its location instead of leaving them with an
187
+ // empty ApplicationPlugins directory and no idea why.
188
+ write(`CADBridge: the previous plugin is still at ${result.stranded} — move it back to ${result.destination}.`)
189
+ }
190
+ }
191
+ return result
192
+ }
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.12",
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.12",
34
+ "@dmytro-prototypes/cadbridge-mcp-win32-x64": "0.1.12"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
38
+ },
39
+ "cadbridge": {
40
+ "server": "0.1.11",
41
+ "plugin": "0.1.11"
38
42
  }
39
43
  }