keyshot-mcp 0.4.1 → 0.4.2
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 +91 -20
- package/assets/demo/keyshot-mcp-demo.png +0 -0
- package/dist/config.js +4 -0
- package/dist/index.js +2 -1
- package/dist/output-paths.js +55 -0
- package/dist/runner.js +61 -12
- package/dist/version.js +1 -0
- package/examples/demo/README.md +9 -0
- package/examples/demo/keyshot-mcp-cube.obj +23 -0
- package/package.json +8 -2
- package/scripts/keyshot-smoke.mjs +67 -0
- package/scripts/keyshot_bridge.py +13 -5
- package/server.json +31 -0
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
67
67
|
npm install -g keyshot-mcp
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
The current release line is `0.4.
|
|
70
|
+
The current release line is `0.4.2`. The published package is the easiest option
|
|
71
71
|
when you only want to use the MCP server. You still need KeyShot Studio and a
|
|
72
72
|
local KeyShot license.
|
|
73
73
|
|
|
@@ -78,21 +78,41 @@ npm install
|
|
|
78
78
|
npm run build
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
### Three MCP configuration options
|
|
82
|
+
|
|
83
|
+
Use exactly one of these approaches:
|
|
84
|
+
|
|
85
|
+
**Global npm installation** (`npm install -g keyshot-mcp`):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**No installation, run with npx**:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Run from a cloned source directory** (after `npm install && npm run build`):
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{"mcpServers":{"keyshot":{"command":"node","args":["/absolute/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
101
|
+
```
|
|
102
|
+
|
|
81
103
|
## Copy-Paste Setup Prompt for an Agent
|
|
82
104
|
|
|
83
105
|
If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
|
|
84
106
|
|
|
85
107
|
```text
|
|
86
|
-
Install
|
|
108
|
+
Install KeyShot MCP 0.4.2 from npm and configure it in my MCP client.
|
|
87
109
|
|
|
88
110
|
Please:
|
|
89
|
-
1.
|
|
90
|
-
2.
|
|
91
|
-
3.
|
|
92
|
-
4.
|
|
93
|
-
5.
|
|
94
|
-
6. Test the setup by running the keyshot_status tool.
|
|
95
|
-
7. Tell me the exact config that was added and whether the status check passed.
|
|
111
|
+
1. Find my KeyShot headless executable path.
|
|
112
|
+
2. Add a keyshot MCP server using npx -y keyshot-mcp@0.4.2.
|
|
113
|
+
3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
|
|
114
|
+
4. Test the setup by running the keyshot_status tool.
|
|
115
|
+
5. Tell me the exact config that was added and whether the status check passed.
|
|
96
116
|
|
|
97
117
|
Do not store license keys or account passwords. Use my existing local KeyShot license configuration.
|
|
98
118
|
```
|
|
@@ -188,6 +208,11 @@ save the scene to a new file, and render one preview from that camera.
|
|
|
188
208
|
- `KEYSHOT_OUTPUT_DIR`: default output folder for renders.
|
|
189
209
|
- `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
|
|
190
210
|
- `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
|
|
211
|
+
- `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`: allow output paths outside `KEYSHOT_OUTPUT_DIR`. Default: `false`.
|
|
212
|
+
|
|
213
|
+
By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
|
|
214
|
+
are placed there automatically. Set the compatibility switch to `true` only when
|
|
215
|
+
an external output location is intentional; input scenes and models are unrestricted.
|
|
191
216
|
|
|
192
217
|
`samples` and `maxTimeSeconds` select different KeyShot render modes. Provide one
|
|
193
218
|
or the other, not both.
|
|
@@ -248,11 +273,22 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
|
|
|
248
273
|
- [x] Material preset library
|
|
249
274
|
- [ ] Camera preset templates
|
|
250
275
|
- [x] Sequential render queue
|
|
251
|
-
- [
|
|
276
|
+
- [x] Safer output directory restrictions
|
|
252
277
|
- [ ] More tested KeyShot versions
|
|
253
278
|
- [ ] macOS compatibility verification
|
|
254
279
|
- [x] Claude Desktop / Cursor / Codex config examples
|
|
255
280
|
|
|
281
|
+
## Real KeyShot demo
|
|
282
|
+
|
|
283
|
+

|
|
284
|
+
|
|
285
|
+
The demo uses only the generated geometry in `examples/demo`. To reproduce the
|
|
286
|
+
status, import, inspect, camera, save, and PNG-render workflow locally:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
npm run smoke:keyshot
|
|
290
|
+
```
|
|
291
|
+
|
|
256
292
|
## License
|
|
257
293
|
|
|
258
294
|
MIT
|
|
@@ -324,7 +360,7 @@ MIT
|
|
|
324
360
|
npm install -g keyshot-mcp
|
|
325
361
|
```
|
|
326
362
|
|
|
327
|
-
当前发布版本为 `0.4.
|
|
363
|
+
当前发布版本为 `0.4.2`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
|
|
328
364
|
电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
|
|
329
365
|
|
|
330
366
|
### 从源码安装
|
|
@@ -334,21 +370,41 @@ npm install
|
|
|
334
370
|
npm run build
|
|
335
371
|
```
|
|
336
372
|
|
|
373
|
+
### 三种独立配置方式
|
|
374
|
+
|
|
375
|
+
下面三种方式选择一种即可:
|
|
376
|
+
|
|
377
|
+
**全局 npm 安装**(先运行 `npm install -g keyshot-mcp`):
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**免安装,直接使用 npx**:
|
|
384
|
+
|
|
385
|
+
```json
|
|
386
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**使用本地源码**(先运行 `npm install && npm run build`):
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{"mcpServers":{"keyshot":{"command":"node","args":["C:/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
393
|
+
```
|
|
394
|
+
|
|
337
395
|
## 复制给 Agent 的安装提示词
|
|
338
396
|
|
|
339
397
|
如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
|
|
340
398
|
|
|
341
399
|
```text
|
|
342
|
-
请帮我安装 KeyShot MCP
|
|
400
|
+
请帮我安装 KeyShot MCP 0.4.2,并添加到我的 MCP 客户端。
|
|
343
401
|
|
|
344
402
|
请你:
|
|
345
|
-
1.
|
|
346
|
-
2.
|
|
347
|
-
3.
|
|
348
|
-
4.
|
|
349
|
-
5.
|
|
350
|
-
6. 用 keyshot_status 工具测试是否配置成功。
|
|
351
|
-
7. 最后告诉我实际添加的配置,以及状态检查是否通过。
|
|
403
|
+
1. 查找我电脑上的 KeyShot headless 可执行文件路径。
|
|
404
|
+
2. 使用 npx -y keyshot-mcp@0.4.2 添加 keyshot MCP server。
|
|
405
|
+
3. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
|
|
406
|
+
4. 用 keyshot_status 工具测试是否配置成功。
|
|
407
|
+
5. 最后告诉我实际添加的配置,以及状态检查是否通过。
|
|
352
408
|
|
|
353
409
|
不要保存许可证密钥、账号密码或授权信息。直接使用我电脑上已有的 KeyShot 本地授权配置。
|
|
354
410
|
```
|
|
@@ -445,6 +501,10 @@ examples/codex.example.json
|
|
|
445
501
|
- `KEYSHOT_LICENSE_ARGS`:可选的 KeyShot 无界面许可证参数,默认留空。
|
|
446
502
|
- `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
|
|
447
503
|
- `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
|
|
504
|
+
- `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
|
|
505
|
+
|
|
506
|
+
默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
|
|
507
|
+
只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
|
|
448
508
|
|
|
449
509
|
`samples` 和 `maxTimeSeconds` 是两种不同的 KeyShot 渲染模式,请二选一,不要同时传入。
|
|
450
510
|
|
|
@@ -503,11 +563,22 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
|
|
|
503
563
|
- [x] 材质预设库
|
|
504
564
|
- [ ] 相机预设模板
|
|
505
565
|
- [x] 顺序渲染队列
|
|
506
|
-
- [
|
|
566
|
+
- [x] 更安全的输出目录限制
|
|
507
567
|
- [ ] 测试更多 KeyShot 版本
|
|
508
568
|
- [ ] 验证 macOS 兼容性
|
|
509
569
|
- [x] 补充 Claude Desktop / Cursor / Codex 配置示例
|
|
510
570
|
|
|
571
|
+
## 真实 KeyShot Demo
|
|
572
|
+
|
|
573
|
+

|
|
574
|
+
|
|
575
|
+
Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
|
|
576
|
+
设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建相机、保存和真实 PNG 渲染:
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
npm run smoke:keyshot
|
|
580
|
+
```
|
|
581
|
+
|
|
511
582
|
## 测试
|
|
512
583
|
|
|
513
584
|
项目带了两套测试,不依赖真实的 KeyShot 也能跑:
|
|
Binary file
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@ export function getConfig() {
|
|
|
9
9
|
projectRoot,
|
|
10
10
|
keyshotHeadlessExe: process.env.KEYSHOT_HEADLESS_EXE ?? DEFAULT_KEYSHOT_EXE,
|
|
11
11
|
keyshotOutputDir,
|
|
12
|
+
keyshotAllowExternalOutputs: parseBoolean(process.env.KEYSHOT_ALLOW_EXTERNAL_OUTPUTS),
|
|
12
13
|
keyshotLicenseArgs: splitWindowsArgs(process.env.KEYSHOT_LICENSE_ARGS ?? ""),
|
|
13
14
|
keyshotTimeoutMs: parsePositiveInt(process.env.KEYSHOT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
|
|
14
15
|
tmpDir: path.join(projectRoot, "work", "tmp"),
|
|
@@ -16,6 +17,9 @@ export function getConfig() {
|
|
|
16
17
|
materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
|
|
17
18
|
};
|
|
18
19
|
}
|
|
20
|
+
function parseBoolean(value) {
|
|
21
|
+
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
|
22
|
+
}
|
|
19
23
|
function parsePositiveInt(value, fallback) {
|
|
20
24
|
if (!value)
|
|
21
25
|
return fallback;
|
package/dist/index.js
CHANGED
|
@@ -6,11 +6,12 @@ import { toolResponse, localFailure } from "./result.js";
|
|
|
6
6
|
import { runKeyShotSerialized } from "./runner.js";
|
|
7
7
|
import { runRenderQueue } from "./queue.js";
|
|
8
8
|
import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
9
10
|
import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
|
|
10
11
|
const config = getConfig();
|
|
11
12
|
const server = new McpServer({
|
|
12
13
|
name: "keyshot-mcp",
|
|
13
|
-
version:
|
|
14
|
+
version: VERSION,
|
|
14
15
|
});
|
|
15
16
|
function errorMessage(error) {
|
|
16
17
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const OUTPUT_FIELDS = ["outputPath", "outputDir", "outputScenePath"];
|
|
4
|
+
export async function normalizeOutputPaths(config, request) {
|
|
5
|
+
await fs.mkdir(config.keyshotOutputDir, { recursive: true });
|
|
6
|
+
const normalized = { ...request };
|
|
7
|
+
for (const field of OUTPUT_FIELDS) {
|
|
8
|
+
const value = request[field];
|
|
9
|
+
if (typeof value !== "string" || value.length === 0)
|
|
10
|
+
continue;
|
|
11
|
+
normalized[field] = await normalizeOutputPath(config, value);
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
export async function normalizeOutputPath(config, value) {
|
|
16
|
+
const candidate = path.resolve(config.keyshotOutputDir, value);
|
|
17
|
+
if (config.keyshotAllowExternalOutputs)
|
|
18
|
+
return candidate;
|
|
19
|
+
const outputRoot = await realpathWithMissingTail(config.keyshotOutputDir);
|
|
20
|
+
const resolvedCandidate = await realpathWithMissingTail(candidate);
|
|
21
|
+
if (!isWithin(outputRoot, resolvedCandidate)) {
|
|
22
|
+
throw new Error(`Output path must stay inside KEYSHOT_OUTPUT_DIR (${config.keyshotOutputDir}): ${value}. ` +
|
|
23
|
+
"Set KEYSHOT_ALLOW_EXTERNAL_OUTPUTS=true only if external writes are intentional.");
|
|
24
|
+
}
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
async function realpathWithMissingTail(inputPath) {
|
|
28
|
+
let current = path.resolve(inputPath);
|
|
29
|
+
const missing = [];
|
|
30
|
+
while (!(await exists(current))) {
|
|
31
|
+
const parent = path.dirname(current);
|
|
32
|
+
if (parent === current)
|
|
33
|
+
break;
|
|
34
|
+
missing.unshift(path.basename(current));
|
|
35
|
+
current = parent;
|
|
36
|
+
}
|
|
37
|
+
const realBase = await fs.realpath(current);
|
|
38
|
+
return path.resolve(realBase, ...missing);
|
|
39
|
+
}
|
|
40
|
+
function isWithin(root, candidate) {
|
|
41
|
+
const relative = path.relative(normalizeCase(root), normalizeCase(candidate));
|
|
42
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
43
|
+
}
|
|
44
|
+
function normalizeCase(value) {
|
|
45
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
46
|
+
}
|
|
47
|
+
async function exists(value) {
|
|
48
|
+
try {
|
|
49
|
+
await fs.lstat(value);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { localFailure } from "./result.js";
|
|
6
|
+
import { normalizeOutputPaths } from "./output-paths.js";
|
|
6
7
|
let queue = Promise.resolve();
|
|
7
8
|
export function runKeyShotSerialized(config, request) {
|
|
8
9
|
const run = queue.then(() => runKeyShot(config, request));
|
|
@@ -10,8 +11,7 @@ export function runKeyShotSerialized(config, request) {
|
|
|
10
11
|
return run;
|
|
11
12
|
}
|
|
12
13
|
async function runKeyShot(config, request) {
|
|
13
|
-
|
|
14
|
-
if (!exeExists) {
|
|
14
|
+
if (isPathLike(config.keyshotHeadlessExe) && !(await exists(config.keyshotHeadlessExe))) {
|
|
15
15
|
return localFailure(`KeyShot headless executable not found: ${config.keyshotHeadlessExe}`);
|
|
16
16
|
}
|
|
17
17
|
const bridgeExists = await exists(config.bridgeScriptPath);
|
|
@@ -23,11 +23,18 @@ async function runKeyShot(config, request) {
|
|
|
23
23
|
}
|
|
24
24
|
await fs.mkdir(config.tmpDir, { recursive: true });
|
|
25
25
|
await fs.mkdir(config.keyshotOutputDir, { recursive: true });
|
|
26
|
+
let normalizedRequest;
|
|
27
|
+
try {
|
|
28
|
+
normalizedRequest = await normalizeOutputPaths(config, request);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return localFailure(errorMessage(error));
|
|
32
|
+
}
|
|
26
33
|
const id = `${Date.now()}-${randomUUID()}`;
|
|
27
34
|
const argsPath = path.join(config.tmpDir, `${id}.args.json`);
|
|
28
35
|
const resultPath = path.join(config.tmpDir, `${id}.result.json`);
|
|
29
36
|
const payload = {
|
|
30
|
-
...
|
|
37
|
+
...normalizedRequest,
|
|
31
38
|
defaults: {
|
|
32
39
|
outputDir: config.keyshotOutputDir,
|
|
33
40
|
},
|
|
@@ -36,7 +43,7 @@ async function runKeyShot(config, request) {
|
|
|
36
43
|
const args = [
|
|
37
44
|
...config.keyshotLicenseArgs,
|
|
38
45
|
"-progress",
|
|
39
|
-
...(
|
|
46
|
+
...(normalizedRequest.scenePath ? [normalizedRequest.scenePath] : []),
|
|
40
47
|
"-script",
|
|
41
48
|
config.bridgeScriptPath,
|
|
42
49
|
argsPath,
|
|
@@ -58,6 +65,12 @@ async function runKeyShot(config, request) {
|
|
|
58
65
|
});
|
|
59
66
|
}
|
|
60
67
|
}
|
|
68
|
+
if (processResult.spawnError) {
|
|
69
|
+
return localFailure(`Could not start KeyShot headless: ${processResult.spawnError}`, {
|
|
70
|
+
keyshotStdoutTail: stdoutTail,
|
|
71
|
+
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
61
74
|
if (processResult.timedOut) {
|
|
62
75
|
return localFailure(`KeyShot timed out after ${config.keyshotTimeoutMs}ms`, {
|
|
63
76
|
data: parsed?.data ?? null,
|
|
@@ -96,9 +109,11 @@ async function cleanupTmp(paths) {
|
|
|
96
109
|
}
|
|
97
110
|
}));
|
|
98
111
|
}
|
|
99
|
-
|
|
112
|
+
export const MAX_CAPTURE_CHARS = 64 * 1024;
|
|
113
|
+
export function spawnWithTimeout(command, args, timeoutMs) {
|
|
100
114
|
return new Promise((resolve) => {
|
|
101
115
|
const child = spawn(command, args, {
|
|
116
|
+
detached: process.platform !== "win32",
|
|
102
117
|
windowsHide: true,
|
|
103
118
|
stdio: ["ignore", "pipe", "pipe"],
|
|
104
119
|
});
|
|
@@ -106,30 +121,64 @@ function spawnWithTimeout(command, args, timeoutMs) {
|
|
|
106
121
|
let stderr = "";
|
|
107
122
|
let settled = false;
|
|
108
123
|
let timedOut = false;
|
|
124
|
+
let spawnError = null;
|
|
109
125
|
const timer = setTimeout(() => {
|
|
110
126
|
timedOut = true;
|
|
111
|
-
child.
|
|
127
|
+
terminateProcessTree(child.pid, "SIGTERM");
|
|
112
128
|
setTimeout(() => {
|
|
113
|
-
if (!settled)
|
|
114
|
-
child.
|
|
129
|
+
if (!settled && process.platform !== "win32")
|
|
130
|
+
terminateProcessTree(child.pid, "SIGKILL");
|
|
115
131
|
}, 2500).unref();
|
|
116
132
|
}, timeoutMs);
|
|
117
133
|
child.stdout?.on("data", (chunk) => {
|
|
118
|
-
stdout
|
|
134
|
+
stdout = appendBounded(stdout, String(chunk));
|
|
119
135
|
});
|
|
120
136
|
child.stderr?.on("data", (chunk) => {
|
|
121
|
-
stderr
|
|
137
|
+
stderr = appendBounded(stderr, String(chunk));
|
|
122
138
|
});
|
|
123
139
|
child.on("error", (error) => {
|
|
124
|
-
|
|
140
|
+
spawnError = errorMessage(error);
|
|
141
|
+
stderr = appendBounded(stderr, spawnError);
|
|
125
142
|
});
|
|
126
143
|
child.on("close", (exitCode) => {
|
|
127
144
|
settled = true;
|
|
128
145
|
clearTimeout(timer);
|
|
129
|
-
resolve({ exitCode, stdout, stderr, timedOut });
|
|
146
|
+
resolve({ exitCode, stdout, stderr, timedOut, spawnError });
|
|
130
147
|
});
|
|
131
148
|
});
|
|
132
149
|
}
|
|
150
|
+
function terminateProcessTree(pid, signal) {
|
|
151
|
+
if (!pid)
|
|
152
|
+
return;
|
|
153
|
+
if (process.platform === "win32") {
|
|
154
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
stdio: "ignore",
|
|
157
|
+
});
|
|
158
|
+
killer.on("error", () => undefined);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
process.kill(-pid, signal);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, signal);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// The process may already have exited.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function appendBounded(current, addition) {
|
|
174
|
+
const combined = current + addition;
|
|
175
|
+
return combined.length <= MAX_CAPTURE_CHARS
|
|
176
|
+
? combined
|
|
177
|
+
: combined.slice(combined.length - MAX_CAPTURE_CHARS);
|
|
178
|
+
}
|
|
179
|
+
function isPathLike(command) {
|
|
180
|
+
return path.isAbsolute(command) || command.includes("/") || command.includes("\\");
|
|
181
|
+
}
|
|
133
182
|
async function exists(filePath) {
|
|
134
183
|
try {
|
|
135
184
|
await fs.access(filePath);
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.4.2";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# KeyShot smoke-test demo
|
|
2
|
+
|
|
3
|
+
`keyshot-mcp-cube.obj` is original, generated geometry included under this
|
|
4
|
+
repository's MIT license. It contains no customer model or third-party asset.
|
|
5
|
+
|
|
6
|
+
Run `npm run smoke:keyshot` after setting `KEYSHOT_HEADLESS_EXE`. The command
|
|
7
|
+
checks KeyShot, imports the cube, saves and inspects the scene, creates an MCP
|
|
8
|
+
camera, and renders `outputs/demo/keyshot-mcp-demo.png`. Generated `.bip` files
|
|
9
|
+
remain under `outputs/` and are ignored by Git.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# KeyShot MCP generated demo cube
|
|
2
|
+
# Original geometry released under the repository MIT license.
|
|
3
|
+
o KeyShot_MCP_Cube
|
|
4
|
+
v -1.0 -1.0 -1.0
|
|
5
|
+
v 1.0 -1.0 -1.0
|
|
6
|
+
v 1.0 1.0 -1.0
|
|
7
|
+
v -1.0 1.0 -1.0
|
|
8
|
+
v -1.0 -1.0 1.0
|
|
9
|
+
v 1.0 -1.0 1.0
|
|
10
|
+
v 1.0 1.0 1.0
|
|
11
|
+
v -1.0 1.0 1.0
|
|
12
|
+
vn 0.0 0.0 -1.0
|
|
13
|
+
vn 0.0 0.0 1.0
|
|
14
|
+
vn 0.0 -1.0 0.0
|
|
15
|
+
vn 0.0 1.0 0.0
|
|
16
|
+
vn -1.0 0.0 0.0
|
|
17
|
+
vn 1.0 0.0 0.0
|
|
18
|
+
f 1//1 4//1 3//1 2//1
|
|
19
|
+
f 5//2 6//2 7//2 8//2
|
|
20
|
+
f 1//3 2//3 6//3 5//3
|
|
21
|
+
f 4//4 8//4 7//4 3//4
|
|
22
|
+
f 1//5 5//5 8//5 4//5
|
|
23
|
+
f 2//6 3//6 7//6 6//6
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshot-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"mcpName": "io.github.truman-t3/keyshot-mcp",
|
|
4
5
|
"description": "Local and portable MCP server for KeyShot headless scripting.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"homepage": "https://github.com/truman-t3/keyshot-mcp#readme",
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
"start": "node ./dist/index.js",
|
|
21
22
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
22
23
|
"test": "vitest run",
|
|
23
|
-
"status": "node ./dist/cli/status.js"
|
|
24
|
+
"status": "node ./dist/cli/status.js",
|
|
25
|
+
"smoke:keyshot": "npm run build && node ./scripts/keyshot-smoke.mjs"
|
|
24
26
|
},
|
|
25
27
|
"keywords": [
|
|
26
28
|
"mcp",
|
|
@@ -33,8 +35,11 @@
|
|
|
33
35
|
"files": [
|
|
34
36
|
"dist",
|
|
35
37
|
"scripts/keyshot_bridge.py",
|
|
38
|
+
"scripts/keyshot-smoke.mjs",
|
|
36
39
|
"examples",
|
|
40
|
+
"assets/demo",
|
|
37
41
|
"presets",
|
|
42
|
+
"server.json",
|
|
38
43
|
"README.md",
|
|
39
44
|
"CONTRIBUTING.md",
|
|
40
45
|
"SECURITY.md",
|
|
@@ -44,6 +49,7 @@
|
|
|
44
49
|
"engines": {
|
|
45
50
|
"node": ">=20"
|
|
46
51
|
},
|
|
52
|
+
"packageManager": "pnpm@10.17.1",
|
|
47
53
|
"dependencies": {
|
|
48
54
|
"@modelcontextprotocol/sdk": "^1.18.1",
|
|
49
55
|
"zod": "^3.25.76"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { getConfig } from "../dist/config.js";
|
|
4
|
+
import { runKeyShotSerialized } from "../dist/runner.js";
|
|
5
|
+
|
|
6
|
+
const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
7
|
+
const config = getConfig();
|
|
8
|
+
const modelPath = path.join(repoRoot, "examples", "demo", "keyshot-mcp-cube.obj");
|
|
9
|
+
|
|
10
|
+
async function run(label, request) {
|
|
11
|
+
process.stdout.write(`${label}... `);
|
|
12
|
+
const result = await runKeyShotSerialized(config, request);
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
process.stdout.write("failed\n");
|
|
15
|
+
throw new Error(`${label}: ${result.error}\n${result.warnings.join("\n")}`);
|
|
16
|
+
}
|
|
17
|
+
process.stdout.write("ok\n");
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const status = await run("1/5 KeyShot status", { operation: "status" });
|
|
22
|
+
const imported = await run("2/5 Import and save generated OBJ", {
|
|
23
|
+
operation: "import_model",
|
|
24
|
+
modelPath,
|
|
25
|
+
outputScenePath: "demo/keyshot-mcp-demo-import.bip",
|
|
26
|
+
});
|
|
27
|
+
const importedScene = imported.outputFiles[0];
|
|
28
|
+
if (!importedScene) throw new Error("Import did not return a saved scene path.");
|
|
29
|
+
|
|
30
|
+
const inspected = await run("3/5 Inspect imported scene", {
|
|
31
|
+
operation: "inspect_scene",
|
|
32
|
+
scenePath: importedScene,
|
|
33
|
+
});
|
|
34
|
+
const objects = inspected.data?.objects;
|
|
35
|
+
if (!Array.isArray(objects) || objects.length === 0) {
|
|
36
|
+
throw new Error("Imported scene did not contain any inspectable objects.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cameraSceneResult = await run("4/5 Create camera and save scene", {
|
|
40
|
+
operation: "set_camera",
|
|
41
|
+
scenePath: importedScene,
|
|
42
|
+
cameraName: "MCP Demo",
|
|
43
|
+
position: [4.5, 3.5, 4.5],
|
|
44
|
+
lookAt: [0, 0, 0],
|
|
45
|
+
up: [0, 1, 0],
|
|
46
|
+
outputScenePath: "demo/keyshot-mcp-demo-camera.bip",
|
|
47
|
+
});
|
|
48
|
+
const cameraScene = cameraSceneResult.outputFiles[0];
|
|
49
|
+
if (!cameraScene) throw new Error("Camera operation did not return a saved scene path.");
|
|
50
|
+
|
|
51
|
+
const rendered = await run("5/5 Render real PNG", {
|
|
52
|
+
operation: "render",
|
|
53
|
+
scenePath: cameraScene,
|
|
54
|
+
camera: "MCP Demo",
|
|
55
|
+
outputPath: "demo/keyshot-mcp-demo.png",
|
|
56
|
+
width: 640,
|
|
57
|
+
height: 480,
|
|
58
|
+
maxTimeSeconds: 8,
|
|
59
|
+
format: "png",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
console.log(JSON.stringify({
|
|
63
|
+
keyshotVersion: status.data?.version ?? null,
|
|
64
|
+
objectCount: objects.length,
|
|
65
|
+
scenePath: cameraScene,
|
|
66
|
+
imagePath: rendered.outputFiles[0] ?? null,
|
|
67
|
+
}, null, 2));
|
|
@@ -307,12 +307,17 @@ def set_camera(payload, output_files, warnings):
|
|
|
307
307
|
|
|
308
308
|
if camera is None and hasattr(lux, "saveCamera"):
|
|
309
309
|
try:
|
|
310
|
-
|
|
310
|
+
# Current KeyShot versions expose cameras as names rather than camera
|
|
311
|
+
# objects. saveCamera creates the named camera and setCamera activates it.
|
|
312
|
+
call_variants(
|
|
313
|
+
"create named camera",
|
|
311
314
|
lambda: lux.saveCamera(camera_name),
|
|
312
315
|
lambda: lux.saveCamera(),
|
|
313
316
|
)
|
|
317
|
+
if hasattr(lux, "setCamera"):
|
|
318
|
+
call_variants("activate camera", lambda: lux.setCamera(camera_name))
|
|
314
319
|
except RuntimeError:
|
|
315
|
-
|
|
320
|
+
pass
|
|
316
321
|
|
|
317
322
|
if camera is not None:
|
|
318
323
|
# Object-level API is available: drive the camera object directly.
|
|
@@ -325,18 +330,21 @@ def set_camera(payload, output_files, warnings):
|
|
|
325
330
|
# camera object, so it cannot crash when camera creation is unsupported.
|
|
326
331
|
call_variants(
|
|
327
332
|
"set camera position",
|
|
328
|
-
lambda: lux.setCameraPosition(
|
|
333
|
+
lambda: lux.setCameraPosition(pos=tuple(position)),
|
|
329
334
|
lambda: lux.setCameraPosition(tuple(position)),
|
|
335
|
+
lambda: lux.setCameraPosition(camera_name, tuple(position)),
|
|
330
336
|
)
|
|
331
337
|
call_variants(
|
|
332
338
|
"set camera look-at",
|
|
339
|
+
lambda: lux.setCameraLookAt(pt=tuple(look_at)),
|
|
340
|
+
lambda: lux.setCameraLookAt(0, tuple(look_at)),
|
|
333
341
|
lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
|
|
334
|
-
lambda: lux.setCameraLookAt(tuple(look_at)),
|
|
335
342
|
)
|
|
336
343
|
call_variants(
|
|
337
344
|
"set camera up",
|
|
338
|
-
lambda: lux.setCameraUp(
|
|
345
|
+
lambda: lux.setCameraUp(up=tuple(up)),
|
|
339
346
|
lambda: lux.setCameraUp(tuple(up)),
|
|
347
|
+
lambda: lux.setCameraUp(camera_name, tuple(up)),
|
|
340
348
|
)
|
|
341
349
|
|
|
342
350
|
save_to(output_scene_path)
|
package/server.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.truman-t3/keyshot-mcp",
|
|
4
|
+
"title": "KeyShot MCP",
|
|
5
|
+
"description": "Control KeyShot Studio through local headless scripting with MCP.",
|
|
6
|
+
"websiteUrl": "https://github.com/truman-t3/keyshot-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"id": "1290741614",
|
|
9
|
+
"url": "https://github.com/truman-t3/keyshot-mcp",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"version": "0.4.2",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "npm",
|
|
16
|
+
"identifier": "keyshot-mcp",
|
|
17
|
+
"version": "0.4.2",
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio"
|
|
20
|
+
},
|
|
21
|
+
"environmentVariables": [
|
|
22
|
+
{
|
|
23
|
+
"name": "KEYSHOT_HEADLESS_EXE",
|
|
24
|
+
"description": "Absolute path to KeyShot headless, or a command available on PATH.",
|
|
25
|
+
"isRequired": true,
|
|
26
|
+
"isSecret": false
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|