keyshot-mcp 0.4.1 → 0.5.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 CHANGED
@@ -28,6 +28,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
28
28
  - Inspect a `.bip` or supported KeyShot scene file.
29
29
  - Render a scene to an image.
30
30
  - Batch render multiple named cameras from one scene.
31
+ - Automatically discover and render every camera in one scene.
31
32
  - Import a model into a scene.
32
33
  - Apply a material to an object.
33
34
  - Create or update a camera.
@@ -67,7 +68,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
67
68
  npm install -g keyshot-mcp
68
69
  ```
69
70
 
70
- The current release line is `0.4.1`. The published package is the easiest option
71
+ The current release line is `0.5.0`. The published package is the easiest option
71
72
  when you only want to use the MCP server. You still need KeyShot Studio and a
72
73
  local KeyShot license.
73
74
 
@@ -78,21 +79,41 @@ npm install
78
79
  npm run build
79
80
  ```
80
81
 
82
+ ### Three MCP configuration options
83
+
84
+ Use exactly one of these approaches:
85
+
86
+ **Global npm installation** (`npm install -g keyshot-mcp`):
87
+
88
+ ```json
89
+ {"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
90
+ ```
91
+
92
+ **No installation, run with npx**:
93
+
94
+ ```json
95
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
96
+ ```
97
+
98
+ **Run from a cloned source directory** (after `npm install && npm run build`):
99
+
100
+ ```json
101
+ {"mcpServers":{"keyshot":{"command":"node","args":["/absolute/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
102
+ ```
103
+
81
104
  ## Copy-Paste Setup Prompt for an Agent
82
105
 
83
106
  If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
84
107
 
85
108
  ```text
86
- Install the KeyShot MCP server from https://github.com/truman-t3/keyshot-mcp.
109
+ Install KeyShot MCP 0.5.0 from npm and configure it in my MCP client.
87
110
 
88
111
  Please:
89
- 1. Clone the repository or use my local copy if it already exists.
90
- 2. Run npm install and npm run build.
91
- 3. Find my KeyShot headless executable path.
92
- 4. Add a keyshot MCP server entry to my MCP client configuration.
93
- 5. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
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.
112
+ 1. Find my KeyShot headless executable path.
113
+ 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.5.0.
114
+ 3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
115
+ 4. Test the setup by running the keyshot_status tool.
116
+ 5. Tell me the exact config that was added and whether the status check passed.
96
117
 
97
118
  Do not store license keys or account passwords. Use my existing local KeyShot license configuration.
98
119
  ```
@@ -182,12 +203,22 @@ List the cameras in this KeyShot scene, create or update a camera named "Hero",
182
203
  save the scene to a new file, and render one preview from that camera.
183
204
  ```
184
205
 
206
+ ```text
207
+ Find every camera in this KeyShot scene and render all views to the all-cameras
208
+ output folder. Continue if one camera fails and report the result for each view.
209
+ ```
210
+
185
211
  ## Environment Variables
186
212
 
187
213
  - `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
188
214
  - `KEYSHOT_OUTPUT_DIR`: default output folder for renders.
189
215
  - `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
190
216
  - `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
217
+ - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`: allow output paths outside `KEYSHOT_OUTPUT_DIR`. Default: `false`.
218
+
219
+ By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
220
+ are placed there automatically. Set the compatibility switch to `true` only when
221
+ an external output location is intentional; input scenes and models are unrestricted.
191
222
 
192
223
  `samples` and `maxTimeSeconds` select different KeyShot render modes. Provide one
193
224
  or the other, not both.
@@ -199,6 +230,7 @@ or the other, not both.
199
230
  - `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
200
231
  - `keyshot_render`: render a single image
201
232
  - `keyshot_batch_render`
233
+ - `keyshot_render_all_cameras`: discover and render every camera in a scene; continues after individual failures by default
202
234
  - `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
203
235
  - `keyshot_import_model`
204
236
  - `keyshot_apply_material`
@@ -208,6 +240,15 @@ or the other, not both.
208
240
  - `keyshot_set_environment`
209
241
  - `keyshot_save_scene`
210
242
 
243
+ ### Render every camera automatically
244
+
245
+ `keyshot_render_all_cameras` discovers camera names from the open scene and renders
246
+ them in one KeyShot headless session. `continueOnError` defaults to `true`, so a
247
+ failed camera is reported without blocking the remaining views. Safe duplicate
248
+ filenames receive `-2`, `-3`, and later suffixes. Existing files are preserved
249
+ unless `overwrite` is set to `true`. KeyShot's internal, non-activatable
250
+ `last_active` placeholder is reported and skipped.
251
+
211
252
  ### Material preset library
212
253
 
213
254
  `keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
@@ -244,15 +285,26 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
244
285
 
245
286
  ## Roadmap
246
287
 
247
- - [ ] Auto-discover and batch render all cameras in a scene
288
+ - [x] Auto-discover and batch render all cameras in a scene
248
289
  - [x] Material preset library
249
290
  - [ ] Camera preset templates
250
291
  - [x] Sequential render queue
251
- - [ ] Safer output directory restrictions
292
+ - [x] Safer output directory restrictions
252
293
  - [ ] More tested KeyShot versions
253
294
  - [ ] macOS compatibility verification
254
295
  - [x] Claude Desktop / Cursor / Codex config examples
255
296
 
297
+ ## Real KeyShot demo
298
+
299
+ ![KeyShot MCP generated cube demo](assets/demo/keyshot-mcp-demo.png)
300
+
301
+ The demo uses only the generated geometry in `examples/demo`. To reproduce the
302
+ status, import, inspect, two-camera creation, save, discovery, and PNG-render workflow locally:
303
+
304
+ ```bash
305
+ npm run smoke:keyshot
306
+ ```
307
+
256
308
  ## License
257
309
 
258
310
  MIT
@@ -285,6 +337,7 @@ MIT
285
337
  - 检查 `.bip` 或 KeyShot 支持的场景文件。
286
338
  - 把场景渲染成图片。
287
339
  - 从同一个场景批量渲染多个指定相机。
340
+ - 自动发现并渲染场景中的全部相机。
288
341
  - 把模型导入场景。
289
342
  - 给对象替换材质。
290
343
  - 创建或更新相机。
@@ -324,7 +377,7 @@ MIT
324
377
  npm install -g keyshot-mcp
325
378
  ```
326
379
 
327
- 当前发布版本为 `0.4.1`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
380
+ 当前发布版本为 `0.5.0`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
328
381
  电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
329
382
 
330
383
  ### 从源码安装
@@ -334,21 +387,41 @@ npm install
334
387
  npm run build
335
388
  ```
336
389
 
390
+ ### 三种独立配置方式
391
+
392
+ 下面三种方式选择一种即可:
393
+
394
+ **全局 npm 安装**(先运行 `npm install -g keyshot-mcp`):
395
+
396
+ ```json
397
+ {"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
398
+ ```
399
+
400
+ **免安装,直接使用 npx**:
401
+
402
+ ```json
403
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
404
+ ```
405
+
406
+ **使用本地源码**(先运行 `npm install && npm run build`):
407
+
408
+ ```json
409
+ {"mcpServers":{"keyshot":{"command":"node","args":["C:/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
410
+ ```
411
+
337
412
  ## 复制给 Agent 的安装提示词
338
413
 
339
414
  如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
340
415
 
341
416
  ```text
342
- 请帮我安装 KeyShot MCP Server:https://github.com/truman-t3/keyshot-mcp。
417
+ 请帮我安装 KeyShot MCP 0.5.0,并添加到我的 MCP 客户端。
343
418
 
344
419
  请你:
345
- 1. 克隆这个仓库;如果本地已有,就使用本地版本。
346
- 2. 运行 npm install npm run build
347
- 3. 查找我电脑上的 KeyShot headless 可执行文件路径。
348
- 4. keyshot MCP server 添加到我的 MCP 客户端配置里。
349
- 5. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
350
- 6. 用 keyshot_status 工具测试是否配置成功。
351
- 7. 最后告诉我实际添加的配置,以及状态检查是否通过。
420
+ 1. 查找我电脑上的 KeyShot headless 可执行文件路径。
421
+ 2. 使用 npx -y keyshot-mcp@0.5.0 添加 keyshot MCP server
422
+ 3. KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
423
+ 4. keyshot_status 工具测试是否配置成功。
424
+ 5. 最后告诉我实际添加的配置,以及状态检查是否通过。
352
425
 
353
426
  不要保存许可证密钥、账号密码或授权信息。直接使用我电脑上已有的 KeyShot 本地授权配置。
354
427
  ```
@@ -438,6 +511,11 @@ examples/codex.example.json
438
511
  把场景保存为新文件,并用这个相机渲染一张预览图。
439
512
  ```
440
513
 
514
+ ```text
515
+ 自动找出这个 KeyShot 场景里的全部相机,把所有视角渲染到 all-cameras 文件夹。
516
+ 某个相机失败时继续处理其他相机,最后告诉我每个视角的结果。
517
+ ```
518
+
441
519
  ## 环境变量
442
520
 
443
521
  - `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
@@ -445,6 +523,10 @@ examples/codex.example.json
445
523
  - `KEYSHOT_LICENSE_ARGS`:可选的 KeyShot 无界面许可证参数,默认留空。
446
524
  - `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
447
525
  - `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
526
+ - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
527
+
528
+ 默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
529
+ 只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
448
530
 
449
531
  `samples` 和 `maxTimeSeconds` 是两种不同的 KeyShot 渲染模式,请二选一,不要同时传入。
450
532
 
@@ -455,6 +537,7 @@ examples/codex.example.json
455
537
  - `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
456
538
  - `keyshot_render`:渲染单张图片。
457
539
  - `keyshot_batch_render`:批量渲染多个相机视角。
540
+ - `keyshot_render_all_cameras`:自动发现并渲染场景中的全部相机,默认单个视角失败后继续。
458
541
  - `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
459
542
  - `keyshot_import_model`:导入模型。
460
543
  - `keyshot_apply_material`:替换材质。
@@ -464,6 +547,13 @@ examples/codex.example.json
464
547
  - `keyshot_set_environment`:设置环境。
465
548
  - `keyshot_save_scene`:保存场景。
466
549
 
550
+ ### 自动渲染全部相机
551
+
552
+ `keyshot_render_all_cameras` 会在同一次 KeyShot 无界面运行中自动读取场景相机并逐个渲染。
553
+ `continueOnError` 默认是 `true`,因此单个相机失败不会阻止其他视角;同名安全文件会自动添加
554
+ `-2`、`-3` 等编号。除非设置 `overwrite: true`,否则不会覆盖已有图片。KeyShot 内部不能激活的
555
+ `last_active` 占位项会被列出并跳过。
556
+
467
557
  ### 材质预设库
468
558
 
469
559
  `keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
@@ -499,15 +589,26 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
499
589
 
500
590
  ## 路线图
501
591
 
502
- - [ ] 自动发现并批量渲染场景中的所有相机
592
+ - [x] 自动发现并批量渲染场景中的所有相机
503
593
  - [x] 材质预设库
504
594
  - [ ] 相机预设模板
505
595
  - [x] 顺序渲染队列
506
- - [ ] 更安全的输出目录限制
596
+ - [x] 更安全的输出目录限制
507
597
  - [ ] 测试更多 KeyShot 版本
508
598
  - [ ] 验证 macOS 兼容性
509
599
  - [x] 补充 Claude Desktop / Cursor / Codex 配置示例
510
600
 
601
+ ## 真实 KeyShot Demo
602
+
603
+ ![KeyShot MCP 自动生成的立方体 Demo](assets/demo/keyshot-mcp-demo.png)
604
+
605
+ Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
606
+ 设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建两个相机、自动发现、保存和真实 PNG 渲染:
607
+
608
+ ```bash
609
+ npm run smoke:keyshot
610
+ ```
611
+
511
612
  ## 测试
512
613
 
513
614
  项目带了两套测试,不依赖真实的 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 { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
9
+ import { VERSION } from "./version.js";
10
+ import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, 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: "0.4.1",
14
+ version: VERSION,
14
15
  });
15
16
  function errorMessage(error) {
16
17
  return error instanceof Error ? error.message : String(error);
@@ -71,6 +72,10 @@ server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyS
71
72
  const parsed = batchRenderInputSchema.parse(args);
72
73
  return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
73
74
  });
75
+ server.tool("keyshot_render_all_cameras", "Discover every camera in one KeyShot scene and render each view into an output directory.", renderAllCamerasSchema.shape, async (args) => {
76
+ const parsed = renderAllCamerasInputSchema.parse(args);
77
+ return toolResponse(await runKeyShotSerialized(config, { operation: "render_all_cameras", ...parsed }));
78
+ });
74
79
  server.tool("keyshot_import_model", "Import a model into an optional base scene and save the resulting scene.", importModelSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, {
75
80
  operation: "import_model",
76
81
  ...args,
@@ -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
- const exeExists = await exists(config.keyshotHeadlessExe);
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
- ...request,
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
- ...(request.scenePath ? [request.scenePath] : []),
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
- function spawnWithTimeout(command, args, timeoutMs) {
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.kill("SIGTERM");
127
+ terminateProcessTree(child.pid, "SIGTERM");
112
128
  setTimeout(() => {
113
- if (!settled)
114
- child.kill("SIGKILL");
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 += String(chunk);
134
+ stdout = appendBounded(stdout, String(chunk));
119
135
  });
120
136
  child.stderr?.on("data", (chunk) => {
121
- stderr += String(chunk);
137
+ stderr = appendBounded(stderr, String(chunk));
122
138
  });
123
139
  child.on("error", (error) => {
124
- stderr += errorMessage(error);
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/schemas.js CHANGED
@@ -36,6 +36,21 @@ export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(valu
36
36
  message: "Choose either samples or maxTimeSeconds, not both.",
37
37
  path: ["maxTimeSeconds"],
38
38
  });
39
+ export const renderAllCamerasSchema = z.object({
40
+ scenePath: z.string().min(1),
41
+ outputDir: z.string().min(1),
42
+ width: z.number().int().positive().optional(),
43
+ height: z.number().int().positive().optional(),
44
+ samples: z.number().int().positive().optional(),
45
+ maxTimeSeconds: z.number().positive().optional(),
46
+ format: imageFormat.optional(),
47
+ overwrite: z.boolean().optional(),
48
+ continueOnError: z.boolean().default(true),
49
+ });
50
+ export const renderAllCamerasInputSchema = renderAllCamerasSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
51
+ message: "Choose either samples or maxTimeSeconds, not both.",
52
+ path: ["maxTimeSeconds"],
53
+ });
39
54
  export const importModelSchema = z.object({
40
55
  modelPath: z.string().min(1),
41
56
  baseScenePath: optionalPath,
@@ -0,0 +1 @@
1
+ export const VERSION = "0.5.0";
@@ -0,0 +1,10 @@
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 two MCP
8
+ cameras, automatically discovers them, and renders both views under
9
+ `outputs/demo/all-cameras`. Generated `.bip` and PNG files remain under
10
+ `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.1",
3
+ "version": "0.5.0",
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,107 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { getConfig } from "../dist/config.js";
6
+ import { runKeyShotSerialized } from "../dist/runner.js";
7
+
8
+ const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
9
+ const config = getConfig();
10
+ const modelPath = path.join(repoRoot, "examples", "demo", "keyshot-mcp-cube.obj");
11
+
12
+ async function run(label, request) {
13
+ process.stdout.write(`${label}... `);
14
+ const result = await runKeyShotSerialized(config, request);
15
+ if (!result.ok) {
16
+ process.stdout.write("failed\n");
17
+ throw new Error(`${label}: ${result.error}\n${result.warnings.join("\n")}`);
18
+ }
19
+ process.stdout.write("ok\n");
20
+ return result;
21
+ }
22
+
23
+ const status = await run("1/7 KeyShot status", { operation: "status" });
24
+ const imported = await run("2/7 Import and save generated OBJ", {
25
+ operation: "import_model",
26
+ modelPath,
27
+ outputScenePath: "demo/keyshot-mcp-demo-import.bip",
28
+ });
29
+ const importedScene = imported.outputFiles[0];
30
+ if (!importedScene) throw new Error("Import did not return a saved scene path.");
31
+
32
+ const inspected = await run("3/7 Inspect imported scene", {
33
+ operation: "inspect_scene",
34
+ scenePath: importedScene,
35
+ });
36
+ const objects = inspected.data?.objects;
37
+ if (!Array.isArray(objects) || objects.length === 0) {
38
+ throw new Error("Imported scene did not contain any inspectable objects.");
39
+ }
40
+
41
+ const firstCameraResult = await run("4/7 Create first camera and save scene", {
42
+ operation: "set_camera",
43
+ scenePath: importedScene,
44
+ cameraName: "MCP Front",
45
+ position: [4.5, 3.5, 4.5],
46
+ lookAt: [0, 0, 0],
47
+ up: [0, 1, 0],
48
+ outputScenePath: "demo/keyshot-mcp-demo-camera-front.bip",
49
+ });
50
+ const firstCameraScene = firstCameraResult.outputFiles[0];
51
+ if (!firstCameraScene) throw new Error("First camera operation did not return a saved scene path.");
52
+
53
+ const secondCameraResult = await run("5/7 Create second camera and save scene", {
54
+ operation: "set_camera",
55
+ scenePath: firstCameraScene,
56
+ cameraName: "MCP Angle",
57
+ position: [6.0, 2.5, 3.0],
58
+ lookAt: [0, 0, 0],
59
+ up: [0, 1, 0],
60
+ outputScenePath: "demo/keyshot-mcp-demo-camera-all.bip",
61
+ });
62
+ const cameraScene = secondCameraResult.outputFiles[0];
63
+ if (!cameraScene) throw new Error("Second camera operation did not return a saved scene path.");
64
+
65
+ const rendered = await run("6/7 Discover and render every camera", {
66
+ operation: "render_all_cameras",
67
+ scenePath: cameraScene,
68
+ outputDir: "demo/all-cameras",
69
+ width: 640,
70
+ height: 480,
71
+ maxTimeSeconds: 8,
72
+ format: "png",
73
+ overwrite: true,
74
+ continueOnError: true,
75
+ });
76
+
77
+ const renderData = rendered.data;
78
+ if (!renderData || renderData.failed !== 0 || renderData.succeeded < 2) {
79
+ throw new Error("All-camera render did not produce at least two successful camera views.");
80
+ }
81
+
82
+ const namedResults = renderData.results.filter(
83
+ (entry) => entry.camera === "MCP Front" || entry.camera === "MCP Angle",
84
+ );
85
+ if (namedResults.length !== 2 || namedResults.some((entry) => !entry.ok)) {
86
+ throw new Error("The two MCP demo cameras were not both rendered successfully.");
87
+ }
88
+ const hashes = await Promise.all(namedResults.map(async (entry) =>
89
+ crypto.createHash("sha256").update(await fs.readFile(entry.outputPath)).digest("hex")
90
+ ));
91
+ if (hashes[0] === hashes[1]) {
92
+ throw new Error("The two demo camera renders are identical; expected different viewpoints.");
93
+ }
94
+ const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await fs.stat(entry.outputPath)).size));
95
+ if (minimumSizes.some((size) => size < 10000)) {
96
+ throw new Error("A demo camera render is unexpectedly small and may be blank.");
97
+ }
98
+ process.stdout.write("7/7 Verify two different camera images... ok\n");
99
+
100
+ console.log(JSON.stringify({
101
+ keyshotVersion: status.data?.version ?? null,
102
+ objectCount: objects.length,
103
+ scenePath: cameraScene,
104
+ cameraCount: renderData.total,
105
+ renderedImages: rendered.outputFiles,
106
+ verifiedCameras: namedResults.map((entry) => entry.camera),
107
+ }, null, 2));
@@ -37,6 +37,8 @@ def main():
37
37
  data = render(payload, output_files, warnings)
38
38
  elif operation == "batch_render":
39
39
  data = batch_render(payload, output_files, warnings)
40
+ elif operation == "render_all_cameras":
41
+ data = render_all_cameras(payload, output_files, warnings)
40
42
  elif operation == "import_model":
41
43
  data = import_model(payload, output_files, warnings)
42
44
  elif operation == "apply_material":
@@ -50,7 +52,10 @@ def main():
50
52
  else:
51
53
  raise RuntimeError("Unsupported operation: %s" % operation)
52
54
 
53
- write_result(result_path, True, data, output_files, warnings, None)
55
+ operation_error = None
56
+ if operation == "render_all_cameras" and data.get("failed", 0) > 0:
57
+ operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
58
+ write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
54
59
  except Exception as exc:
55
60
  warnings.append(traceback.format_exc())
56
61
  write_result(result_path, False, None, output_files, warnings, str(exc))
@@ -99,6 +104,11 @@ def inspect_scene():
99
104
 
100
105
 
101
106
  def list_cameras():
107
+ names = camera_names()
108
+ return {"cameras": names, "count": len(names)}
109
+
110
+
111
+ def camera_names():
102
112
  raw = safe_list_call("getCameras")
103
113
  names = []
104
114
  for camera in raw:
@@ -113,8 +123,8 @@ def list_cameras():
113
123
  )
114
124
  if name is None:
115
125
  name = repr(camera)
116
- names.append(name)
117
- return {"cameras": names, "count": len(names)}
126
+ names.append(str(name))
127
+ return names
118
128
 
119
129
 
120
130
  def render(payload, output_files, warnings):
@@ -214,6 +224,100 @@ def batch_render(payload, output_files, warnings):
214
224
  }
215
225
 
216
226
 
227
+ def render_all_cameras(payload, output_files, warnings):
228
+ scene_path = payload.get("scenePath")
229
+ output_dir = payload.get("outputDir")
230
+ discovered_cameras = camera_names()
231
+ excluded_cameras = [name for name in discovered_cameras if name.lower() == "last_active"]
232
+ cameras = [name for name in discovered_cameras if name.lower() != "last_active"]
233
+ width = payload.get("width") or 1920
234
+ height = payload.get("height") or 1080
235
+ image_format = payload.get("format") or "png"
236
+ overwrite = bool(payload.get("overwrite", False))
237
+ continue_on_error = payload.get("continueOnError", True) is not False
238
+
239
+ if not scene_path:
240
+ raise RuntimeError("scenePath is required")
241
+ if not output_dir:
242
+ raise RuntimeError("outputDir is required")
243
+ if not cameras:
244
+ raise RuntimeError("No cameras were found in the scene.")
245
+
246
+ if excluded_cameras:
247
+ warnings.append("Skipped KeyShot internal camera placeholder(s): %s" % ", ".join(excluded_cameras))
248
+
249
+ os.makedirs(output_dir, exist_ok=True)
250
+ results = []
251
+ used_stems = set()
252
+ stopped = False
253
+
254
+ for index, camera in enumerate(cameras):
255
+ output_path = unique_camera_output_path(output_dir, camera, image_format, used_stems)
256
+ if stopped:
257
+ results.append(camera_render_result(index, camera, output_path, False, None, True))
258
+ continue
259
+
260
+ try:
261
+ if os.path.exists(output_path) and not overwrite:
262
+ raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
263
+
264
+ render_payload = dict(payload)
265
+ render_payload["camera"] = camera
266
+ render_payload["outputPath"] = output_path
267
+ render_payload["width"] = width
268
+ render_payload["height"] = height
269
+ render_payload["format"] = image_format
270
+ rendered = render(render_payload, output_files, warnings)
271
+ results.append(camera_render_result(index, camera, rendered.get("rendered"), True, None, False))
272
+ except Exception as exc:
273
+ error = str(exc)
274
+ warnings.append("Camera %s (%s) failed: %s" % (index, camera, error))
275
+ results.append(camera_render_result(index, camera, output_path, False, error, False))
276
+ if not continue_on_error:
277
+ stopped = True
278
+
279
+ succeeded = len([item for item in results if item["ok"]])
280
+ failed = len([item for item in results if not item["ok"] and not item["skipped"]])
281
+ skipped = len([item for item in results if item["skipped"]])
282
+ return {
283
+ "scenePath": scene_path,
284
+ "outputDir": output_dir,
285
+ "cameras": cameras,
286
+ "excludedCameras": excluded_cameras,
287
+ "total": len(cameras),
288
+ "succeeded": succeeded,
289
+ "failed": failed,
290
+ "skipped": skipped,
291
+ "continueOnError": continue_on_error,
292
+ "results": results,
293
+ "width": width,
294
+ "height": height,
295
+ "format": image_format,
296
+ }
297
+
298
+
299
+ def camera_render_result(index, camera, output_path, ok, error, skipped):
300
+ return {
301
+ "index": index,
302
+ "camera": camera,
303
+ "outputPath": output_path,
304
+ "ok": ok,
305
+ "error": error,
306
+ "skipped": skipped,
307
+ }
308
+
309
+
310
+ def unique_camera_output_path(output_dir, camera, image_format, used_stems):
311
+ base = safe_filename(camera)
312
+ stem = base
313
+ suffix = 2
314
+ while stem.lower() in used_stems:
315
+ stem = "%s-%s" % (base, suffix)
316
+ suffix += 1
317
+ used_stems.add(stem.lower())
318
+ return os.path.join(output_dir, "%s.%s" % (stem, image_format))
319
+
320
+
217
321
  def import_model(payload, output_files, warnings):
218
322
  model_path = payload.get("modelPath")
219
323
  output_scene_path = payload.get("outputScenePath")
@@ -298,6 +402,7 @@ def set_camera(payload, output_files, warnings):
298
402
 
299
403
  if position is None or look_at is None:
300
404
  raise RuntimeError("position and lookAt are required")
405
+ direction = tuple(look_at[index] - position[index] for index in range(3))
301
406
 
302
407
  camera = first_camera_object(
303
408
  lambda: lux.getCamera(camera_name),
@@ -305,39 +410,53 @@ def set_camera(payload, output_files, warnings):
305
410
  lambda: lux.createCamera(camera_name),
306
411
  )
307
412
 
308
- if camera is None and hasattr(lux, "saveCamera"):
309
- try:
310
- camera = first_camera_object(
311
- lambda: lux.saveCamera(camera_name),
312
- lambda: lux.saveCamera(),
313
- )
314
- except RuntimeError:
315
- camera = None
316
-
317
413
  if camera is not None:
318
414
  # Object-level API is available: drive the camera object directly.
319
- call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
320
415
  call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
416
+ call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
417
+ if hasattr(camera, "setDirection"):
418
+ call_variants("set camera direction", lambda: camera.setDirection(direction))
321
419
  call_variants("set camera up", lambda: camera.setUp(tuple(up)))
322
420
  else:
323
421
  # No camera object could be obtained: fall back to the lux-level setters,
324
- # which address the camera by name. This path never dereferences a None
325
- # camera object, so it cannot crash when camera creation is unsupported.
326
- call_variants(
327
- "set camera position",
328
- lambda: lux.setCameraPosition(camera_name, tuple(position)),
329
- lambda: lux.setCameraPosition(tuple(position)),
330
- )
422
+ # which operate on the active camera. Existing named cameras are activated
423
+ # first. Missing cameras are saved under their new name after the transform
424
+ # is set; saveCamera is a snapshot operation, not a camera constructor.
425
+ if camera_name in camera_names() and hasattr(lux, "setCamera"):
426
+ call_variants("activate camera", lambda: lux.setCamera(camera_name))
331
427
  call_variants(
332
428
  "set camera look-at",
429
+ lambda: lux.setCameraLookAt(pt=tuple(look_at)),
430
+ lambda: lux.setCameraLookAt(0, tuple(look_at)),
333
431
  lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
334
- lambda: lux.setCameraLookAt(tuple(look_at)),
335
432
  )
433
+ call_variants(
434
+ "set camera position",
435
+ lambda: lux.setCameraPosition(pos=tuple(position)),
436
+ lambda: lux.setCameraPosition(tuple(position)),
437
+ lambda: lux.setCameraPosition(camera_name, tuple(position)),
438
+ )
439
+ if hasattr(lux, "setCameraDirection"):
440
+ call_variants(
441
+ "set camera direction",
442
+ lambda: lux.setCameraDirection(dir=direction),
443
+ lambda: lux.setCameraDirection(direction),
444
+ )
336
445
  call_variants(
337
446
  "set camera up",
338
- lambda: lux.setCameraUp(camera_name, tuple(up)),
447
+ lambda: lux.setCameraUp(up=tuple(up)),
339
448
  lambda: lux.setCameraUp(tuple(up)),
449
+ lambda: lux.setCameraUp(camera_name, tuple(up)),
340
450
  )
451
+ if hasattr(lux, "saveCamera"):
452
+ try:
453
+ call_variants(
454
+ "save named camera",
455
+ lambda: lux.saveCamera(),
456
+ lambda: lux.saveCamera(camera_name),
457
+ )
458
+ except RuntimeError:
459
+ warnings.append("The active camera was updated, but this KeyShot version could not save the named view.")
341
460
 
342
461
  save_to(output_scene_path)
343
462
  output_files.append(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.5.0",
13
+ "packages": [
14
+ {
15
+ "registryType": "npm",
16
+ "identifier": "keyshot-mcp",
17
+ "version": "0.5.0",
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
+ }