@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.
- package/bin/cli.js +17 -1
- package/bin/plugin.js +254 -0
- 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
|
-
|
|
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.
|
|
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.
|
|
34
|
-
"@dmytro-prototypes/cadbridge-mcp-win32-x64": "0.1.
|
|
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
|
}
|