@parkhill/mcp-server-for-rhino 0.3.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert McNeel & Associates
4
+ Copyright (c) 2026 Parkhill (modifications: Parkhill fork with native geometry tools)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @parkhill/mcp-server-for-rhino
2
+
3
+ An MCP server for modeling in **Rhino 3D** through AI assistants (Claude Desktop, Claude Code, Cursor, and others). It covers Grasshopper, and it also builds geometry directly in the Rhino document.
4
+
5
+ This is Parkhill's fork of [McNeel's Rhino MCP](https://github.com/mcneel/RhinoMCP), MIT licensed. The fork adds native geometry tools and installs side by side with the official plugin.
6
+
7
+ ## How it works
8
+
9
+ This npm package is a small launcher. The MCP server itself runs inside a Rhino plugin, the **Parkhill-Rhino-MCP** yak package. The launcher finds that plugin's router and connects your AI client to it.
10
+
11
+ ```
12
+ AI client ──stdio──▶ npx @parkhill/mcp-server-for-rhino ──▶ router (in the yak) ──HTTP──▶ Rhino
13
+ ```
14
+
15
+ If the Rhino plugin isn't installed yet, the server starts with a single `install_parkhill_rhino_mcp` tool. That tool installs the plugin from a local `.yak` file (see below).
16
+
17
+ ## Requirements
18
+
19
+ - Rhino 8 (Windows or macOS), or Rhino 9 WIP
20
+ - Node.js 18+
21
+
22
+ ## 1. Install the Rhino plugin
23
+
24
+ Download `parkhill-rhino-mcp-<version>-rh8_*-win.yak` from the [releases page](https://github.com/Parkhill-Smith-Cooper/RhinoMCP/releases), or get it from your team share. Then use one of these:
25
+
26
+ - Drag the `.yak` onto an open Rhino window and restart Rhino.
27
+ - Set `PARKHILL_RHINO_MCP_YAK` to the `.yak` file, or to the folder that holds it, in the MCP config below. Then ask the assistant to "install the Rhino plugin". The launcher extracts it for you.
28
+
29
+ ## 2. Add the MCP server to your client
30
+
31
+ **Claude Desktop** (`claude_desktop_config.json`):
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "rhino-parkhill": {
37
+ "command": "npx",
38
+ "args": ["-y", "@parkhill/mcp-server-for-rhino", "--default-version", "8"],
39
+ "env": {
40
+ "PARKHILL_RHINO_MCP_YAK": "\\\\server\\share\\rhino-mcp"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ **Claude Code:**
48
+
49
+ ```sh
50
+ claude mcp add rhino-parkhill -- npx -y @parkhill/mcp-server-for-rhino --default-version 8
51
+ ```
52
+
53
+ The `env` block is optional. It's only needed for the automatic plugin install.
54
+
55
+ Start Rhino, or let the server launch Rhino for you, and ask the assistant to model something.
56
+
57
+ ## Tools
58
+
59
+ **Native geometry**
60
+
61
+ | Tool | Operations |
62
+ |---|---|
63
+ | `create_curve` | line, polyline, curve, interpcrv, rectangle, circle, arc, ellipse |
64
+ | `create_solid` | plane, box, cylinder, sphere |
65
+ | `create_surface` | planar_srf, edge_srf, extrude_crv, extrude_srf, loft, sweep1, sweep2, revolve, cap |
66
+ | `transform_objects` | move, rotate, scale, scale1d, mirror, array_linear, array_rect, array_polar |
67
+ | `edit_geometry` | break, join, trim, split, extend, explode, offset, offset_srf |
68
+ | `boolean_objects` | union, difference, split, boolean_intersection, intersect |
69
+ | `group_objects` | group, ungroup |
70
+
71
+ Every call:
72
+ - is a single undo step in Rhino
73
+ - returns the ids of the objects it created
74
+ - accepts an optional `layer` and `name` when it creates objects
75
+
76
+ **Everything from upstream**
77
+ - `run_command`, `run_python`, `run_csharp`
78
+ - selection and object queries, viewport capture and camera
79
+ - document open, save and close
80
+ - Grasshopper 1 and 2 canvas tools
81
+ - multi-Rhino slot management
82
+
83
+ ## Options
84
+
85
+ | Flag / env | Meaning |
86
+ |---|---|
87
+ | `--default-version 8\|9\|WIP` | Which Rhino to launch when none is running (default 8) |
88
+ | `PARKHILL_RHINO_MCP_YAK` | Path to a `.yak` file, or a folder of them, used by the install fallback |
89
+ | `PARKHILL_RHINO_MCP_RHINO_EXE_8` / `_9` / `_WIP` | Custom Rhino executable path |
90
+ | `PARKHILL_RHINO_MCP_STARTUP_TIMEOUT` | Seconds to wait for a spawned Rhino (default 120) |
91
+
92
+ ## Coexisting with McNeel's plugin
93
+
94
+ The Parkhill plugin uses its own names and state, so both plugins can be installed at the same time:
95
+ - plugin id and command names (`PHMCPStart`, `PHMCPSpawn`, …)
96
+ - ports, starting at 10600
97
+ - state in `%LOCALAPPDATA%\Parkhill\rhino-mcp`
98
+ - MCP config key `rhino-parkhill`
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@parkhill/mcp-server-for-rhino",
3
+ "version": "0.3.0",
4
+ "description": "MCP server for modeling in Rhino 3D (native geometry + Grasshopper) through AI assistants. Parkhill fork of McNeel's Rhino MCP.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-server-for-rhino": "router-launcher.mjs"
8
+ },
9
+ "files": [
10
+ "router-launcher.mjs",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "sync": "node sync.mjs",
16
+ "prepack": "node sync.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "os": [
22
+ "win32",
23
+ "darwin"
24
+ ],
25
+ "keywords": [
26
+ "mcp",
27
+ "model-context-protocol",
28
+ "rhino",
29
+ "rhino3d",
30
+ "grasshopper",
31
+ "cad",
32
+ "claude"
33
+ ],
34
+ "license": "MIT",
35
+ "author": "Parkhill",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/Parkhill-Smith-Cooper/RhinoMCP.git",
39
+ "directory": "npm"
40
+ },
41
+ "homepage": "https://github.com/Parkhill-Smith-Cooper/RhinoMCP#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/Parkhill-Smith-Cooper/RhinoMCP/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ // Locate the parkhill-rhino-mcp-router binary inside an installed Parkhill-Rhino-MCP yak
3
+ // and spawn it with our stdio passed through. Yak layout:
4
+ // <packages>/<rhino-ver>/Parkhill-Rhino-MCP/<pkg-ver>/router/<rid>/parkhill-rhino-mcp-router[.exe]
5
+ // Canonical copy lives here in shared/; the packaging paths each stage their own
6
+ // copy of this file at build time.
7
+
8
+ import { statSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, renameSync } from "node:fs";
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import { join, basename } from "node:path";
11
+ import { homedir, constants as osConstants } from "node:os";
12
+ import { createInterface } from "node:readline";
13
+
14
+ function resolveRid() {
15
+ if (process.platform === "darwin") return "osx-arm64";
16
+ if (process.platform === "win32") return process.arch === "arm64" ? "win-arm64" : "win-x64";
17
+ return null;
18
+ }
19
+
20
+ function packagesRoot() {
21
+ if (process.platform === "darwin") {
22
+ return join(homedir(), "Library", "Application Support", "McNeel", "Rhinoceros", "packages");
23
+ }
24
+ if (process.platform === "win32") {
25
+ return process.env.APPDATA ? join(process.env.APPDATA, "McNeel", "Rhinoceros", "packages") : null;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function isDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } }
31
+ function isFile(p) { try { return statSync(p).isFile(); } catch { return false; } }
32
+ function listDirs(p) { try { return readdirSync(p).filter(n => isDir(join(p, n))); } catch { return []; } }
33
+
34
+ const PACKAGE_NAME = "Parkhill-Rhino-MCP";
35
+ // Path to a .yak file, or a folder of them (a network share, a downloaded
36
+ // GitHub release); the newest one matching the Rhino version wins.
37
+ const LOCAL_YAK_ENV = "PARKHILL_RHINO_MCP_YAK";
38
+ const RELEASES_URL = "https://github.com/Parkhill-Smith-Cooper/RhinoMCP/releases";
39
+
40
+ // Inspect every installed yak and return the full candidate list (search order)
41
+ // plus the first one whose router binary exists. Callers log the whole list so
42
+ // "why did it pick X" / "why didn't it find anything" is answerable from the
43
+ // MCP server log alone.
44
+ function findRouter() {
45
+ const rid = resolveRid();
46
+ const root = packagesRoot();
47
+ if (!rid || !root) return { rid, root, considered: [], picked: null };
48
+
49
+ const exe = process.platform === "win32" ? "parkhill-rhino-mcp-router.exe" : "parkhill-rhino-mcp-router";
50
+
51
+ // Numeric-aware reverse sort: 0.10.0 ranks above 0.1.0(lexical would invert).
52
+ const byVersionDesc = (a, b) => b.localeCompare(a, undefined, { numeric: true });
53
+
54
+ const considered = [];
55
+ for (const ver of ["9.0", "8.0"]) {
56
+ const base = join(root, ver, PACKAGE_NAME);
57
+ if (!isDir(base)) continue;
58
+ for (const pkgver of listDirs(base).sort(byVersionDesc)) {
59
+ considered.push({ ver, pkgver, path: join(base, pkgver, "router", rid, exe) });
60
+ }
61
+ }
62
+
63
+ const picked = considered.find(c => isFile(c.path)) ?? null;
64
+ return { rid, root, exe, considered, picked };
65
+ }
66
+
67
+ const r = findRouter();
68
+
69
+ process.stderr.write(`rhino-mcp-launcher: platform=${process.platform}/${process.arch} rid=${r.rid ?? "?"} root=${r.root ?? "?"}\n`);
70
+
71
+ if (!r.rid || !r.root) {
72
+ process.stderr.write(`rhino-mcp-launcher: unsupported platform\n`);
73
+ process.exit(1);
74
+ }
75
+
76
+ // spawn() can fail two different ways depending on platform:
77
+ // POSIX — returns a ChildProcess; ENOENT/EACCES surface as an async `error` event.
78
+ // Win32 — throws *synchronously* (e.g. `spawn UNKNOWN` on a file with bad PE format).
79
+ // Handle both so the launcher always exits cleanly with code 1 + "spawn failed"
80
+ // rather than dumping an unhandled stack trace.
81
+ function spawnFailed(err) {
82
+ process.stderr.write(`rhino-mcp-launcher: spawn failed: ${err.message}\n`);
83
+ process.exit(1);
84
+ }
85
+
86
+ let child;
87
+ if (r.considered.length === 0) {
88
+ process.stderr.write(`rhino-mcp-launcher: no Parkhill-Rhino-MCP yak installed under ${r.root}\n`);
89
+ runInstallFallback("no Parkhill-Rhino-MCP yak is installed");
90
+ } else if (!r.picked) {
91
+ const summary = r.considered.map(c => `${c.ver}/${c.pkgver}`).join(", ");
92
+ process.stderr.write(`rhino-mcp-launcher: candidates [${summary}]\n`);
93
+ process.stderr.write(`rhino-mcp-launcher: no ${r.exe} found for ${r.rid} in any installed yak\n`);
94
+ runInstallFallback(`installed Parkhill-Rhino-MCP yak has no router/${r.rid}/${r.exe}`);
95
+ } else {
96
+ const summary = r.considered.map(c => `${c.ver}/${c.pkgver}${c === r.picked ? "*" : ""}`).join(", ");
97
+ process.stderr.write(`rhino-mcp-launcher: candidates [${summary}] (* = picked)\n`);
98
+ process.stderr.write(`rhino-mcp-launcher: exec ${r.picked.path}\n`);
99
+
100
+ try {
101
+ child = spawn(r.picked.path, process.argv.slice(2), {
102
+ stdio: ["pipe", "pipe", "inherit"],
103
+ windowsHide: true,
104
+ });
105
+ } catch (err) {
106
+ spawnFailed(err);
107
+ }
108
+ if (child) {
109
+ process.stdin.pipe(child.stdin);
110
+ // Filter blank lines — Claude Desktop fails the server on a bare newline.
111
+ const out = createInterface({ input: child.stdout });
112
+ out.on("line", line => {
113
+ if (line.trim() === "") return;
114
+ process.stdout.write(line + "\n");
115
+ });
116
+ }
117
+ }
118
+
119
+ // `error` and `close` can both fire for the same failure (ENOENT,
120
+ // non-executable on POSIX). Gate so we don't mask a spawn failure as exit 0.
121
+ let terminating = false;
122
+
123
+ if (child) {
124
+ child.on("error", err => {
125
+ if (terminating) return;
126
+ terminating = true;
127
+ spawnFailed(err);
128
+ });
129
+
130
+ // `close` (not `exit`) so stdio fully drains — `exit` can truncate the
131
+ // router's final MCP frame on a fast shutdown.
132
+ child.on("close", (code, signal) => {
133
+ if (terminating) return;
134
+ terminating = true;
135
+ if (signal) {
136
+ process.exit(128 + (osConstants.signals[signal] ?? 15));
137
+ } else {
138
+ process.exit(code ?? 0);
139
+ }
140
+ });
141
+ }
142
+
143
+ // When the router can't be found, we don't just die — we'd surface a red dot
144
+ // in Claude Desktop's MCP list and the user has no way to recover from chat.
145
+ // Instead, start a *placeholder* MCP server that advertises one tool:
146
+ // install_parkhill_rhino_mcp. The user asks Claude to connect to Rhino, the
147
+ // model finds only this tool, and the user gets a chat-native consent prompt
148
+ // to install the yak into their configured Rhino version.
149
+ function parseDefaultVersion(args) {
150
+ for (let i = 0; i < args.length - 1; i++) {
151
+ if (args[i] === "--default-version" || args[i] === "-v") return args[i + 1];
152
+ }
153
+ return "8";
154
+ }
155
+
156
+ function resolveYak(version) {
157
+ // Test override: skip platform-specific probing and use a caller-supplied
158
+ // path. Lets the launcher tests simulate "Rhino is installed" / "Rhino is
159
+ // missing" deterministically without depending on what's on the CI runner.
160
+ const override = process.env.PARKHILL_RHINO_MCP_FAKE_YAK_PATH;
161
+ if (override != null) return override && isFile(override) ? override : null;
162
+
163
+ // "9", "BETA", and "WIP" are the same Rhino 9 family and probe the same
164
+ // installs in order: prefer the release, then BETA, then WIP.
165
+ const isRhino9 = version === "9" || version === "BETA" || version === "WIP";
166
+ if (process.platform === "darwin") {
167
+ const apps =
168
+ version === "8" ? ["Rhino 8.app"]
169
+ : isRhino9 ? ["Rhino 9.app", "RhinoBETA.app", "RhinoWIP.app"]
170
+ : [];
171
+ for (const app of apps) {
172
+ const p = `/Applications/${app}/Contents/Resources/bin/yak`;
173
+ if (isFile(p)) return p;
174
+ }
175
+ return null;
176
+ }
177
+ if (process.platform === "win32") {
178
+ const dirs =
179
+ version === "8" ? ["Rhino 8"]
180
+ : isRhino9 ? ["Rhino 9", "Rhino 9 BETA", "Rhino 9 WIP"]
181
+ : [];
182
+ for (const dir of dirs) {
183
+ const p = join("C:\\Program Files", dir, "System", "Yak.exe");
184
+ if (isFile(p)) return p;
185
+ }
186
+ return null;
187
+ }
188
+ return null;
189
+ }
190
+
191
+ // The Parkhill plugin is not on McNeel's public package server, so the install
192
+ // fallback installs a local .yak file instead of running `yak install <name>`.
193
+ function rhinoPackagesDir(version) {
194
+ return version === "8" ? "8.0" : "9.0";
195
+ }
196
+
197
+ function resolveLocalYak(version) {
198
+ const configured = process.env[LOCAL_YAK_ENV];
199
+ if (!configured) return null;
200
+ if (isFile(configured)) return configured;
201
+ if (!isDir(configured)) return null;
202
+ // yak build names packages <name>-<ver>-rh<major>_<minor>-<platform>.yak.
203
+ const tag = `rh${version === "8" ? "8" : "9"}`;
204
+ const platform = process.platform === "darwin" ? "mac" : "win";
205
+ const byVersionDesc = (a, b) => b.localeCompare(a, undefined, { numeric: true });
206
+ const candidates = readdirSync(configured)
207
+ .filter(n => n.toLowerCase().endsWith(".yak") && n.toLowerCase().startsWith(PACKAGE_NAME.toLowerCase()))
208
+ .filter(n => n.includes(tag) || !/rh\d/.test(n))
209
+ .filter(n => n.includes(platform) || n.includes("any") || !/-(win|mac)\.yak$/i.test(n))
210
+ .sort(byVersionDesc);
211
+ return candidates.length ? join(configured, candidates[0]) : null;
212
+ }
213
+
214
+ // bsdtar reads zip archives (a .yak is a zip). Windows 10+ ships it in
215
+ // System32; prefer that over a GNU tar that Git for Windows may put on PATH.
216
+ function resolveTar() {
217
+ if (process.platform === "win32") {
218
+ const sys = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe");
219
+ if (isFile(sys)) return sys;
220
+ }
221
+ return "tar";
222
+ }
223
+
224
+ // Mirrors what `yak install` leaves on disk:
225
+ // <packages>/<N>.0/<name>/<pkg-ver>/... and <packages>/<N>.0/<name>/manifest.txt = <pkg-ver>
226
+ function installLocalYak(yakPath, version) {
227
+ const root = packagesRoot();
228
+ if (!root) throw new Error("unsupported platform");
229
+ const base = join(root, rhinoPackagesDir(version), PACKAGE_NAME);
230
+ const staging = join(base, `.staging-${process.pid}`);
231
+ rmSync(staging, { recursive: true, force: true });
232
+ mkdirSync(staging, { recursive: true });
233
+ try {
234
+ const res = spawnSync(resolveTar(), ["-xf", yakPath, "-C", staging], { encoding: "utf8" });
235
+ if (res.status !== 0) {
236
+ throw new Error(`could not extract ${yakPath}: ${(res.stderr || res.error?.message || "").trim()}`);
237
+ }
238
+ const manifestPath = join(staging, "manifest.yml");
239
+ if (!isFile(manifestPath)) throw new Error(`${basename(yakPath)} has no manifest.yml; is it a ${PACKAGE_NAME} .yak?`);
240
+ const manifest = readFileSync(manifestPath, "utf8");
241
+ const name = /^name:\s*(\S+)/m.exec(manifest)?.[1];
242
+ const pkgVer = /^version:\s*(\S+)/m.exec(manifest)?.[1];
243
+ if (name !== PACKAGE_NAME) throw new Error(`${basename(yakPath)} is package '${name}', expected '${PACKAGE_NAME}'.`);
244
+ if (!pkgVer) throw new Error(`${basename(yakPath)} manifest.yml has no version.`);
245
+ const dest = join(base, pkgVer);
246
+ rmSync(dest, { recursive: true, force: true });
247
+ renameSync(staging, dest);
248
+ writeFileSync(join(base, "manifest.txt"), pkgVer);
249
+ return { dest, pkgVer };
250
+ } finally {
251
+ rmSync(staging, { recursive: true, force: true });
252
+ }
253
+ }
254
+
255
+ function runInstallFallback(reason) {
256
+ const version = parseDefaultVersion(process.argv.slice(2));
257
+
258
+ // If Rhino itself isn't installed, installing the plugin can't help. Detect
259
+ // that upfront and surface a different tool that points at the Rhino download page.
260
+ if (!resolveYak(version)) {
261
+ runRhinoMissingFallback(version);
262
+ return;
263
+ }
264
+
265
+ const localYak = resolveLocalYak(version);
266
+ const how = localYak
267
+ ? `A local package was found at ${localYak}. Ask the user whether to install it now; if they agree, call this tool with no arguments.`
268
+ : `No local package is configured. Tell the user to download the ${PACKAGE_NAME} .yak for Rhino ${version} from ${RELEASES_URL} ` +
269
+ `and either drag it onto the Rhino window, or set the ${LOCAL_YAK_ENV} environment variable of this MCP server to the .yak file (or its folder) and call this tool.`;
270
+
271
+ const tool = {
272
+ name: "install_parkhill_rhino_mcp",
273
+ description:
274
+ `The ${PACKAGE_NAME} plugin is not installed in Rhino ${version} (reason: ${reason}). ${how} ` +
275
+ `After install, restart Rhino if it is open and reload this connector (or restart the MCP client) so the real Rhino tools appear.`,
276
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
277
+ };
278
+
279
+ function doInstall() {
280
+ const yakPath = resolveLocalYak(version);
281
+ if (!yakPath) {
282
+ return {
283
+ isError: true,
284
+ content: [{
285
+ type: "text",
286
+ text: `No ${PACKAGE_NAME} .yak found. Download it from ${RELEASES_URL}, then drag it onto Rhino ${version}, ` +
287
+ `or set ${LOCAL_YAK_ENV} to the .yak file or its folder and try again.`,
288
+ }],
289
+ };
290
+ }
291
+ process.stderr.write(`rhino-mcp-launcher: installing ${yakPath} into Rhino ${version}
292
+ `);
293
+ try {
294
+ const { dest, pkgVer } = installLocalYak(yakPath, version);
295
+ return {
296
+ content: [{
297
+ type: "text",
298
+ text: `Installed ${PACKAGE_NAME} ${pkgVer} into ${dest}. Restart Rhino ${version} if it is open, ` +
299
+ `then reload this connector (or restart the MCP client) for the Rhino tools to appear.`,
300
+ }],
301
+ };
302
+ } catch (err) {
303
+ return { isError: true, content: [{ type: "text", text: `Install failed: ${err.message}` }] };
304
+ }
305
+ }
306
+
307
+ runPlaceholderMcp({
308
+ mode: `install-fallback mode (version=${version}, reason=${reason})`,
309
+ serverName: "parkhill-rhino-mcp-installer",
310
+ tool,
311
+ onCall: doInstall,
312
+ });
313
+ }
314
+
315
+ function runRhinoMissingFallback(version) {
316
+ const downloadUrl = "https://www.rhino3d.com/download";
317
+ const tool = {
318
+ name: "rhino_not_installed",
319
+ description:
320
+ `Rhino ${version} does not appear to be installed on this machine, so the Parkhill Rhino MCP connector cannot start. ` +
321
+ `Tell the user to install Rhino ${version} from ${downloadUrl}, then reload this connector ` +
322
+ `(or restart Claude Desktop). Calling this tool just repeats the same instructions.`,
323
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
324
+ };
325
+
326
+ runPlaceholderMcp({
327
+ mode: `rhino-missing-fallback mode (version=${version})`,
328
+ serverName: "parkhill-rhino-mcp-no-rhino",
329
+ tool,
330
+ onCall: () => ({
331
+ content: [{
332
+ type: "text",
333
+ text: `Rhino ${version} is not installed. Download it from ${downloadUrl} and reload this connector once the install finishes.`,
334
+ }],
335
+ }),
336
+ });
337
+ }
338
+
339
+ function runPlaceholderMcp({ mode, serverName, tool, onCall }) {
340
+ process.stderr.write(`rhino-mcp-launcher: entering ${mode}\n`);
341
+
342
+ const send = msg => process.stdout.write(JSON.stringify(msg) + "\n");
343
+ const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
344
+ const error = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
345
+
346
+ const rl = createInterface({ input: process.stdin });
347
+ rl.on("line", line => {
348
+ if (line.trim() === "") return;
349
+ let req;
350
+ try { req = JSON.parse(line); } catch { error(null, -32700, "Parse error"); return; }
351
+ const { id, method, params } = req;
352
+ // A request without an id is a notification — JSON-RPC 2.0 forbids any
353
+ // reply, including errors. (id: null is discouraged; treat it the same.)
354
+ if (id === undefined || id === null) return;
355
+ if (method === "initialize") {
356
+ reply(id, {
357
+ protocolVersion: params?.protocolVersion ?? "2024-11-05",
358
+ capabilities: { tools: {} },
359
+ serverInfo: { name: serverName, version: "0.0.1" },
360
+ });
361
+ } else if (method === "tools/list") {
362
+ reply(id, { tools: [tool] });
363
+ } else if (method === "tools/call") {
364
+ if (params?.name !== tool.name) {
365
+ error(id, -32601, `unknown tool: ${params?.name}`);
366
+ return;
367
+ }
368
+ reply(id, onCall());
369
+ } else {
370
+ error(id, -32601, `method not supported in ${mode}: ${method}`);
371
+ }
372
+ });
373
+ rl.on("close", () => process.exit(0));
374
+ // Keep alive — readline holds stdin open.
375
+ }
376
+
377
+ // Only SIGTERM is forwarded. SIGINT / SIGHUP from a controlling terminal
378
+ // already reach the router via process-group delivery — forwarding would
379
+ // send a *second* SIGINT, which .NET's host treats as "force quit" and
380
+ // skips the ApplicationStopping hook that closes spawned Rhinos. MCP-protocol
381
+ // shutdown flows through stdin EOF (inherited), not signals.
382
+ process.on("SIGTERM", () => { try { child?.kill("SIGTERM"); } catch { /* child already exited */ } });