@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 +53 -0
- package/bin/obenor-mcp.mjs +148 -0
- package/bundle/kernel-worker.mjs +24823 -0
- package/bundle/obenor-kernel-mt.mjs +2 -0
- package/bundle/obenor-kernel.mjs +2 -0
- package/bundle/server.mjs +49474 -0
- package/package.json +44 -0
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');
|