@obenor/mcp 0.1.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/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @obenor/mcp
2
+
3
+ Build parametric solid models in [Obenor3D](https://3d.obenor.com) from an MCP
4
+ client — Codex, Claude Code, or anything else that speaks the protocol.
5
+
6
+ ```
7
+ codex mcp add obenor3d --env OBENOR_LINK=<token> -- npx -y @obenor/mcp
8
+ ```
9
+
10
+ `<token>` is the eight characters after `/s/` in an **Edit** share link. Make
11
+ one from the Share sheet in the app; the sheet also writes this line out for
12
+ you, with the token already in it.
13
+
14
+ ## What it is
15
+
16
+ A full OpenCASCADE modelling kernel, driven as MCP tools: sketches, extrudes,
17
+ booleans, fillets, sheet metal, surfacing, drawings, materials and offscreen
18
+ renders. It edits the same document the browser has open, so a model built here
19
+ appears on screen as it is built, and edits made on screen are adopted before
20
+ the next tool runs.
21
+
22
+ ## The kernel is fetched, not shipped
23
+
24
+ The geometry kernel is 24 MB of WebAssembly. Rather than put it in the tarball,
25
+ the first run fetches it from the deployment you are connected to — by content
26
+ hash, into `~/.obenor/kernel` — and every run after that is instant. `npx` is
27
+ therefore a ~800 kB download rather than a 50 MB one, and a new kernel does not
28
+ need a new release of this package.
29
+
30
+ Point `OBENOR_ORIGIN` at your own deployment if you are not using
31
+ `https://3d.obenor.com`; the kernel comes from wherever the app does.
32
+
33
+ ## Hosted alternative
34
+
35
+ If you would rather install nothing at all, the app also speaks MCP over HTTP:
36
+
37
+ ```
38
+ codex mcp add obenor3d --url https://3d.obenor.com/api/agent/mcp --bearer-token <token>
39
+ ```
40
+
41
+ That runs every tool in the browser tab that has the model open, so the model
42
+ has to be open — and it cannot read files on your machine. This package can do
43
+ both: it works with no browser running, and it can read the blueprint sitting in
44
+ your downloads folder.
45
+
46
+ ## Environment
47
+
48
+ | | |
49
+ |---|---|
50
+ | `OBENOR_LINK` | Edit share-link token. Required to mirror a model. |
51
+ | `OBENOR_ORIGIN` | Defaults to `https://3d.obenor.com`. |
52
+ | `OBENOR_KERNEL_CACHE` | Defaults to `~/.obenor/kernel`. |
53
+ | `OBENOR_BAKE` | `0` to stop baking meshes and a poster on publish. |
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The published entry point: `npx -y @obenor/mcp`.
4
+ *
5
+ * ── what this exists to avoid ──────────────────────────────────────────────
6
+ *
7
+ * The kernel is 24 MB of wasm, and there are two builds of it because -pthread
8
+ * changes the ABI. Putting them in the tarball makes a 50 MB package that has
9
+ * to be republished every time the geometry changes, and makes the first `npx`
10
+ * a long silent wait on whatever connection the person has.
11
+ *
12
+ * So the tarball carries the server and the emscripten glue - about a megabyte
13
+ * - and the binary is fetched on first run from the origin the session is
14
+ * already talking to, then cached under ~/.obenor/kernel. Second run is
15
+ * instant. And because the fetch is BY CONTENT HASH, the copy on disk can never
16
+ * be paired with a different build of the glue: a new kernel is a new directory
17
+ * rather than an overwrite, which is the failure `build-id.ts` was written to
18
+ * prevent inside the browser and the same failure out here.
19
+ *
20
+ * ── why the origin is the source ───────────────────────────────────────────
21
+ *
22
+ * `/k/<hash>/obenor-kernel.wasm` is already served by the app - it is how the
23
+ * browser gets its kernel - so there is no second artefact store to operate and
24
+ * no way for the two to disagree. A self-hosted install points OBENOR_ORIGIN at
25
+ * itself and gets its own kernel, which is the behaviour somebody running their
26
+ * own deployment would otherwise have to be told is impossible.
27
+ */
28
+ import { createWriteStream } from 'node:fs';
29
+ import { mkdir, rename, stat, unlink } from 'node:fs/promises';
30
+ import { homedir } from 'node:os';
31
+ import { dirname, join } from 'node:path';
32
+ import { Readable } from 'node:stream';
33
+ import { pipeline } from 'node:stream/promises';
34
+
35
+ const ORIGIN = (process.env['OBENOR_ORIGIN'] ?? 'https://3d.obenor.com').replace(/\/+$/u, '');
36
+ const CACHE = process.env['OBENOR_KERNEL_CACHE'] ?? join(homedir(), '.obenor', 'kernel');
37
+
38
+
39
+ /** stderr, never stdout: stdout is the MCP transport and a byte on it is a protocol error. */
40
+ const say = (line) => { process.stderr.write(`obenor: ${line}\n`); };
41
+
42
+ const exists = async (path) => {
43
+ try {
44
+ const info = await stat(path);
45
+ return info.isFile() && info.size > 0;
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+
51
+ /**
52
+ * WHICH kernel this deployment serves, asked rather than assumed.
53
+ *
54
+ * The obvious thing is to bake the hash into the tarball at publish time. It is
55
+ * also wrong: the hash changes whenever the geometry kernel is rebuilt, so a
56
+ * baked one would mean an npm republish per kernel change - which is the exact
57
+ * cost this whole design exists to avoid, reintroduced at the last step. The
58
+ * deployment knows what it is serving; this asks it.
59
+ */
60
+ async function currentKernelId() {
61
+ const response = await fetch(`${ORIGIN}/api/health`);
62
+ if (!response.ok) {
63
+ throw new Error(`${ORIGIN}/api/health answered ${response.status}, so this cannot find out `
64
+ + 'which kernel to fetch. Set OBENOR_ORIGIN if the app is not the deployed one.');
65
+ }
66
+ const body = await response.json();
67
+ const id = body?.kernel;
68
+ if (typeof id !== 'string' || !/^[0-9a-f]{6,}$/u.test(id)) {
69
+ throw new Error('that origin does not report a kernel build, so it is either an older '
70
+ + 'deployment or not an Obenor3D one.');
71
+ }
72
+ return id;
73
+ }
74
+
75
+ /**
76
+ * One artefact, cached under the hash the deployment named.
77
+ *
78
+ * Downloaded to a temporary name and renamed into place, because a half-written
79
+ * wasm that looks complete is the worst outcome available here: emscripten does
80
+ * not validate it, `_malloc` resolves to some other function, and every
81
+ * operation afterwards dies with a bogus out-of-memory a long way from the
82
+ * cause. A rename is atomic on every filesystem this runs on.
83
+ */
84
+ async function ensure(id, name) {
85
+ const dir = join(CACHE, id);
86
+ const path = join(dir, name);
87
+ if (await exists(path)) return path;
88
+
89
+ await mkdir(dir, { recursive: true });
90
+ const url = `${ORIGIN}/k/${id}/${name}`;
91
+ say(`fetching the kernel from ${url} (once - it is cached in ${dir})`);
92
+ const response = await fetch(url);
93
+ if (!response.ok || response.body === null) {
94
+ throw new Error(
95
+ `could not fetch the kernel: ${url} answered ${response.status}. `
96
+ + 'Set OBENOR_ORIGIN if the app is not the deployed one.');
97
+ }
98
+ const temp = `${path}.${process.pid}.part`;
99
+ try {
100
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(temp));
101
+ await rename(temp, path);
102
+ } catch (error) {
103
+ await unlink(temp).catch(() => {});
104
+ throw error;
105
+ }
106
+ say(`kernel cached (${name})`);
107
+ return path;
108
+ }
109
+
110
+ /*
111
+ * Both builds, because which one loads is decided inside the kernel from
112
+ * whether this Node has SharedArrayBuffer and enough of a thread pool, and the
113
+ * decision happens after this point. Fetching only the one we predict would put
114
+ * a network round trip in the middle of `Kernel.load` on the day the prediction
115
+ * is wrong.
116
+ */
117
+ let kernelId;
118
+ try {
119
+ kernelId = await currentKernelId();
120
+ } catch (error) {
121
+ say(error.message);
122
+ process.exit(1);
123
+ }
124
+
125
+ const wanted = ['obenor-kernel.wasm', 'obenor-kernel-mt.wasm'];
126
+ const located = new Map();
127
+ for (const name of wanted) {
128
+ try {
129
+ located.set(name, await ensure(kernelId, name));
130
+ } catch (error) {
131
+ /* One of the two is enough to start: a machine without threads never asks
132
+ * for the mt build, and a fetch that failed for it must not stop a session
133
+ * that was only ever going to use the other. */
134
+ say(`${name} unavailable: ${error.message}`);
135
+ }
136
+ }
137
+ if (located.size === 0) {
138
+ say('no kernel could be fetched, so there is nothing to build geometry with.');
139
+ process.exit(1);
140
+ }
141
+
142
+ /* Handed to `Kernel.load` through the environment, because the server module
143
+ * constructs its own kernel deep inside a worker and threading a parameter down
144
+ * to it would mean a signature change in four packages for one deployment
145
+ * concern. */
146
+ process.env['OBENOR_KERNEL_DIR'] = dirname(located.values().next().value);
147
+
148
+ await import('../bundle/server.mjs');