keyshot-mcp 0.2.1 → 0.4.1

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
@@ -61,6 +61,18 @@ This project does not include KeyShot, does not bypass licensing, and does not s
61
61
 
62
62
  ## Install
63
63
 
64
+ ### Install the published npm package
65
+
66
+ ```bash
67
+ npm install -g keyshot-mcp
68
+ ```
69
+
70
+ The current release line is `0.4.1`. The published package is the easiest option
71
+ when you only want to use the MCP server. You still need KeyShot Studio and a
72
+ local KeyShot license.
73
+
74
+ ### Install from source
75
+
64
76
  ```bash
65
77
  npm install
66
78
  npm run build
@@ -165,6 +177,11 @@ Import the model file at /path/to/model.step, apply a brushed metal material to
165
177
  Inspect this KeyShot scene and summarize the available objects, cameras, materials, and renderable outputs.
166
178
  ```
167
179
 
180
+ ```text
181
+ List the cameras in this KeyShot scene, create or update a camera named "Hero",
182
+ save the scene to a new file, and render one preview from that camera.
183
+ ```
184
+
168
185
  ## Environment Variables
169
186
 
170
187
  - `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
@@ -172,18 +189,41 @@ Inspect this KeyShot scene and summarize the available objects, cameras, materia
172
189
  - `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
173
190
  - `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
174
191
 
192
+ `samples` and `maxTimeSeconds` select different KeyShot render modes. Provide one
193
+ or the other, not both.
194
+
175
195
  ## MCP Tools
176
196
 
177
197
  - `keyshot_status`
178
198
  - `keyshot_inspect_scene`
199
+ - `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
179
200
  - `keyshot_render`: render a single image
180
201
  - `keyshot_batch_render`
202
+ - `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
181
203
  - `keyshot_import_model`
182
204
  - `keyshot_apply_material`
205
+ - `keyshot_list_material_presets`: list presets from the material preset library
206
+ - `keyshot_apply_material_preset`: apply a named preset from the library to an object
183
207
  - `keyshot_set_camera`
184
208
  - `keyshot_set_environment`
185
209
  - `keyshot_save_scene`
186
210
 
211
+ ### Material preset library
212
+
213
+ `keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
214
+ looks instead of remembering exact KeyShot material names. Default location:
215
+ `presets/materials.json` (override with the `KEYSHOT_MATERIAL_PRESETS` env var).
216
+
217
+ ```json
218
+ {
219
+ "Brushed Steel": { "materialName": "Steel Brushed", "description": "metal parts" },
220
+ "Clear Glass": { "materialPath": "C:/materials/glass_clear.mtl" }
221
+ }
222
+ ```
223
+
224
+ Each preset must have a `materialName` or a `materialPath`. Use
225
+ `keyshot_list_material_presets` to see what is available.
226
+
187
227
  ## MCP Prompts and Resources
188
228
 
189
229
  - Prompt: `keyshot_product_render`
@@ -205,13 +245,13 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
205
245
  ## Roadmap
206
246
 
207
247
  - [ ] Auto-discover and batch render all cameras in a scene
208
- - [ ] Material preset library
248
+ - [x] Material preset library
209
249
  - [ ] Camera preset templates
210
- - [ ] Render queue status
250
+ - [x] Sequential render queue
211
251
  - [ ] Safer output directory restrictions
212
252
  - [ ] More tested KeyShot versions
213
253
  - [ ] macOS compatibility verification
214
- - [ ] Claude Desktop / Cursor / Codex config examples
254
+ - [x] Claude Desktop / Cursor / Codex config examples
215
255
 
216
256
  ## License
217
257
 
@@ -278,6 +318,17 @@ MIT
278
318
 
279
319
  ## 安装
280
320
 
321
+ ### 安装已发布的 npm 包
322
+
323
+ ```bash
324
+ npm install -g keyshot-mcp
325
+ ```
326
+
327
+ 当前发布版本为 `0.4.1`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
328
+ 电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
329
+
330
+ ### 从源码安装
331
+
281
332
  ```bash
282
333
  npm install
283
334
  npm run build
@@ -382,25 +433,52 @@ examples/codex.example.json
382
433
  检查当前 KeyShot 场景,并总结场景中的对象、相机、材质和可渲染输出。
383
434
  ```
384
435
 
436
+ ```text
437
+ 列出当前 KeyShot 场景中的相机,创建或更新一个名为“Hero”的相机,
438
+ 把场景保存为新文件,并用这个相机渲染一张预览图。
439
+ ```
440
+
385
441
  ## 环境变量
386
442
 
387
443
  - `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
388
444
  - `KEYSHOT_OUTPUT_DIR`:默认渲染输出文件夹。
389
445
  - `KEYSHOT_LICENSE_ARGS`:可选的 KeyShot 无界面许可证参数,默认留空。
390
446
  - `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
447
+ - `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
448
+
449
+ `samples` 和 `maxTimeSeconds` 是两种不同的 KeyShot 渲染模式,请二选一,不要同时传入。
391
450
 
392
451
  ## MCP 工具
393
452
 
394
453
  - `keyshot_status`:检查 KeyShot 是否能启动。
395
454
  - `keyshot_inspect_scene`:检查场景内容。
455
+ - `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
396
456
  - `keyshot_render`:渲染单张图片。
397
457
  - `keyshot_batch_render`:批量渲染多个相机视角。
458
+ - `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
398
459
  - `keyshot_import_model`:导入模型。
399
460
  - `keyshot_apply_material`:替换材质。
461
+ - `keyshot_list_material_presets`:列出材质预设库中的预设。
462
+ - `keyshot_apply_material_preset`:把预设库中的某个命名材质应用到物体上。
400
463
  - `keyshot_set_camera`:设置相机。
401
464
  - `keyshot_set_environment`:设置环境。
402
465
  - `keyshot_save_scene`:保存场景。
403
466
 
467
+ ### 材质预设库
468
+
469
+ `keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
470
+ 而不必记住 KeyShot 里精确的材质名。默认位置:`presets/materials.json`
471
+ (可用环境变量 `KEYSHOT_MATERIAL_PRESETS` 覆盖)。
472
+
473
+ ```json
474
+ {
475
+ "拉丝钢": { "materialName": "Steel Brushed", "description": "金属件默认材质" },
476
+ "透明玻璃": { "materialPath": "C:/materials/glass_clear.mtl" }
477
+ }
478
+ ```
479
+
480
+ 每个预设必须包含 `materialName` 或 `materialPath`。用 `keyshot_list_material_presets` 查看有哪些预设。
481
+
404
482
  ## MCP 提示词和资源
405
483
 
406
484
  - 提示词:`keyshot_product_render`
@@ -422,13 +500,30 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
422
500
  ## 路线图
423
501
 
424
502
  - [ ] 自动发现并批量渲染场景中的所有相机
425
- - [ ] 材质预设库
503
+ - [x] 材质预设库
426
504
  - [ ] 相机预设模板
427
- - [ ] 渲染队列状态
505
+ - [x] 顺序渲染队列
428
506
  - [ ] 更安全的输出目录限制
429
507
  - [ ] 测试更多 KeyShot 版本
430
508
  - [ ] 验证 macOS 兼容性
431
- - [ ] 补充 Claude Desktop / Cursor / Codex 配置示例
509
+ - [x] 补充 Claude Desktop / Cursor / Codex 配置示例
510
+
511
+ ## 测试
512
+
513
+ 项目带了两套测试,不依赖真实的 KeyShot 也能跑:
514
+
515
+ - **TypeScript 测试(Vitest)**:覆盖配置读取、参数校验(schemas)、结果封装,以及用"假 KeyShot"跑通一次成功路径并验证 `work/tmp` 临时文件会被清理。
516
+
517
+ ```bash
518
+ npm install
519
+ npm test
520
+ ```
521
+
522
+ - **Python bridge 测试(unittest)**:用假的 `lux` 对象验证 `import_model` 会真正打开基础场景、`set_camera` 在拿不到相机对象时不会崩溃并回退到 `lux` 级 API。
523
+
524
+ ```bash
525
+ python tests/test_bridge.py
526
+ ```
432
527
 
433
528
  ## 开源协议
434
529
 
package/dist/config.js CHANGED
@@ -13,6 +13,7 @@ export function getConfig() {
13
13
  keyshotTimeoutMs: parsePositiveInt(process.env.KEYSHOT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
14
14
  tmpDir: path.join(projectRoot, "work", "tmp"),
15
15
  bridgeScriptPath: path.join(projectRoot, "scripts", "keyshot_bridge.py"),
16
+ materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
16
17
  };
17
18
  }
18
19
  function parsePositiveInt(value, fallback) {
package/dist/index.js CHANGED
@@ -2,14 +2,19 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { getConfig } from "./config.js";
5
- import { toolResponse } from "./result.js";
5
+ import { toolResponse, localFailure } from "./result.js";
6
6
  import { runKeyShotSerialized } from "./runner.js";
7
- import { applyMaterialSchema, applyMaterialInputSchema, batchRenderSchema, importModelSchema, renderSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
7
+ import { runRenderQueue } from "./queue.js";
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";
8
10
  const config = getConfig();
9
11
  const server = new McpServer({
10
12
  name: "keyshot-mcp",
11
- version: "0.2.1",
13
+ version: "0.4.1",
12
14
  });
15
+ function errorMessage(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
13
18
  server.registerResource("keyshot-workflow", "keyshot://workflow", {
14
19
  title: "KeyShot MCP Workflow",
15
20
  description: "How this MCP server connects AI agents to KeyShot headless scripting.",
@@ -53,17 +58,67 @@ server.registerPrompt("keyshot_product_render", {
53
58
  }));
54
59
  server.tool("keyshot_status", "Check KeyShot headless availability and script startup.", {}, async () => toolResponse(await runKeyShotSerialized(config, { operation: "status" })));
55
60
  server.tool("keyshot_inspect_scene", "Open a KeyShot scene and return available objects, cameras, materials and scene metadata.", scenePathSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "inspect_scene", ...args })));
56
- server.tool("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "render", ...args })));
57
- server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyShot scene into an output directory.", batchRenderSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...args })));
61
+ server.tool("keyshot_list_cameras", "Open a KeyShot scene and return the list of available camera names (useful before batch rendering).", listCamerasSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "list_cameras", ...args })));
62
+ server.tool("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => {
63
+ const parsed = renderInputSchema.parse(args);
64
+ return toolResponse(await runKeyShotSerialized(config, { operation: "render", ...parsed }));
65
+ });
66
+ server.tool("keyshot_render_queue", "Render several jobs sequentially. Stops at the first failure unless continueOnError is set.", renderQueueSchema.shape, async (args) => {
67
+ const parsed = renderQueueInputSchema.parse(args);
68
+ return toolResponse(await runRenderQueue(config, parsed.jobs, { continueOnError: parsed.continueOnError ?? false }));
69
+ });
70
+ server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyShot scene into an output directory.", batchRenderSchema.shape, async (args) => {
71
+ const parsed = batchRenderInputSchema.parse(args);
72
+ return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
73
+ });
58
74
  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, {
59
75
  operation: "import_model",
60
- scenePath: args.baseScenePath,
61
76
  ...args,
62
77
  })));
63
78
  server.tool("keyshot_apply_material", "Apply a material by name or material file to a scene object and save the resulting scene.", applyMaterialInputSchema.shape, async (args) => {
64
79
  const parsed = applyMaterialSchema.parse(args);
65
80
  return toolResponse(await runKeyShotSerialized(config, { operation: "apply_material", ...parsed }));
66
81
  });
82
+ server.tool("keyshot_list_material_presets", "List material presets from the local preset library (presets/materials.json or KEYSHOT_MATERIAL_PRESETS).", listMaterialPresetsSchema.shape, async () => {
83
+ try {
84
+ const presets = await loadMaterialPresets(config);
85
+ return toolResponse({
86
+ ok: true,
87
+ data: { presets, count: presets.length, source: config.materialPresetsPath },
88
+ outputFiles: [],
89
+ warnings: presets.length === 0 ? ["No material presets found. Create presets/materials.json to add some."] : [],
90
+ keyshotStdoutTail: "",
91
+ error: null,
92
+ });
93
+ }
94
+ catch (error) {
95
+ return toolResponse(localFailure(errorMessage(error)));
96
+ }
97
+ });
98
+ server.tool("keyshot_apply_material_preset", "Apply a named material preset (from the preset library) to a scene object and save the resulting scene.", applyMaterialPresetInputSchema.shape, async (args) => {
99
+ const parsed = applyMaterialPresetSchema.parse(args);
100
+ let presets;
101
+ try {
102
+ presets = await loadMaterialPresets(config);
103
+ }
104
+ catch (error) {
105
+ return toolResponse(localFailure(errorMessage(error)));
106
+ }
107
+ const preset = findMaterialPreset(presets, parsed.presetName);
108
+ if (!preset) {
109
+ const available = presets.map((entry) => entry.name).join(", ") || "(none)";
110
+ return toolResponse(localFailure(`Material preset not found: "${parsed.presetName}". Available: ${available}`));
111
+ }
112
+ return toolResponse(await runKeyShotSerialized(config, {
113
+ operation: "apply_material",
114
+ scenePath: parsed.scenePath,
115
+ objectName: parsed.objectName,
116
+ objectPath: parsed.objectPath,
117
+ materialName: preset.materialName,
118
+ materialPath: preset.materialPath,
119
+ outputScenePath: parsed.outputScenePath,
120
+ }));
121
+ });
67
122
  server.tool("keyshot_set_camera", "Create or update a camera from position/look-at vectors and save the resulting scene.", setCameraSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_camera", ...args })));
68
123
  server.tool("keyshot_set_environment", "Set a scene environment by name or file when supported by KeyShot headless scripting.", setEnvironmentSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_environment", ...args })));
69
124
  server.tool("keyshot_save_scene", "Save a KeyShot scene to a new path.", saveSceneSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "save_scene", ...args })));
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs/promises";
2
+ /**
3
+ * Load the material preset library from disk.
4
+ *
5
+ * The registry file (see config.materialPresetsPath, overridable with the
6
+ * KEYSHOT_MATERIAL_PRESETS env var) may use either of two shapes:
7
+ *
8
+ * 1. Object keyed by preset name:
9
+ * { "Brushed Steel": { "materialName": "Steel Brushed", "description": "..." } }
10
+ *
11
+ * 2. Array of presets:
12
+ * [ { "name": "Brushed Steel", "materialName": "Steel Brushed" } ]
13
+ *
14
+ * A missing file is treated as an empty library (not an error), so the feature
15
+ * works out of the box before the user creates any presets.
16
+ */
17
+ export async function loadMaterialPresets(config) {
18
+ let raw;
19
+ try {
20
+ raw = await fs.readFile(config.materialPresetsPath, "utf8");
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ }
29
+ catch {
30
+ throw new Error(`Material presets file is not valid JSON: ${config.materialPresetsPath}`);
31
+ }
32
+ return normalizePresets(parsed);
33
+ }
34
+ export function normalizePresets(parsed) {
35
+ const entries = [];
36
+ if (Array.isArray(parsed)) {
37
+ for (const item of parsed) {
38
+ if (item && typeof item === "object") {
39
+ const record = item;
40
+ const name = typeof record.name === "string" ? record.name : undefined;
41
+ if (name)
42
+ entries.push(toPreset(name, record));
43
+ }
44
+ }
45
+ }
46
+ else if (parsed && typeof parsed === "object") {
47
+ for (const [name, value] of Object.entries(parsed)) {
48
+ if (value && typeof value === "object") {
49
+ entries.push(toPreset(name, value));
50
+ }
51
+ }
52
+ }
53
+ return entries.filter((preset) => preset.materialName || preset.materialPath);
54
+ }
55
+ function toPreset(name, record) {
56
+ return {
57
+ name,
58
+ materialName: typeof record.materialName === "string" ? record.materialName : undefined,
59
+ materialPath: typeof record.materialPath === "string" ? record.materialPath : undefined,
60
+ description: typeof record.description === "string" ? record.description : undefined,
61
+ };
62
+ }
63
+ export function findMaterialPreset(presets, name) {
64
+ const lower = name.toLowerCase();
65
+ return (presets.find((preset) => preset.name === name) ??
66
+ presets.find((preset) => preset.name.toLowerCase() === lower));
67
+ }
package/dist/queue.js ADDED
@@ -0,0 +1,70 @@
1
+ import { runKeyShotSerialized } from "./runner.js";
2
+ /**
3
+ * Run several render jobs one after another.
4
+ *
5
+ * Jobs execute sequentially (the underlying runner is already serialized, and a
6
+ * single KeyShot instance cannot render in parallel). By default the queue stops
7
+ * at the first failure; pass continueOnError to render every remaining job and
8
+ * collect all outcomes. Jobs that are skipped after an early stop are reported
9
+ * with skipped: true so the caller can see exactly what ran.
10
+ */
11
+ export async function runRenderQueue(config, jobs, options = {}, runFn = runKeyShotSerialized) {
12
+ const continueOnError = options.continueOnError ?? false;
13
+ const results = [];
14
+ const outputFiles = [];
15
+ const warnings = [];
16
+ let succeeded = 0;
17
+ let failed = 0;
18
+ let stopped = false;
19
+ for (let index = 0; index < jobs.length; index += 1) {
20
+ const job = jobs[index];
21
+ if (stopped) {
22
+ results.push({
23
+ index,
24
+ scenePath: job.scenePath,
25
+ camera: job.camera,
26
+ ok: false,
27
+ outputFiles: [],
28
+ error: null,
29
+ skipped: true,
30
+ });
31
+ continue;
32
+ }
33
+ const result = await runFn(config, { operation: "render", ...job });
34
+ results.push({
35
+ index,
36
+ scenePath: job.scenePath,
37
+ camera: job.camera,
38
+ ok: result.ok,
39
+ outputFiles: result.outputFiles ?? [],
40
+ error: result.error ?? null,
41
+ });
42
+ if (result.ok) {
43
+ succeeded += 1;
44
+ outputFiles.push(...(result.outputFiles ?? []));
45
+ }
46
+ else {
47
+ failed += 1;
48
+ if (result.error)
49
+ warnings.push(`Job ${index} failed: ${result.error}`);
50
+ if (!continueOnError)
51
+ stopped = true;
52
+ }
53
+ }
54
+ const skipped = results.filter((entry) => entry.skipped).length;
55
+ return {
56
+ ok: failed === 0,
57
+ data: {
58
+ total: jobs.length,
59
+ succeeded,
60
+ failed,
61
+ skipped,
62
+ continueOnError,
63
+ results,
64
+ },
65
+ outputFiles,
66
+ warnings,
67
+ keyshotStdoutTail: "",
68
+ error: failed === 0 ? null : `${failed} of ${jobs.length} render job(s) failed.`,
69
+ };
70
+ }
package/dist/runner.js CHANGED
@@ -42,46 +42,59 @@ async function runKeyShot(config, request) {
42
42
  argsPath,
43
43
  resultPath,
44
44
  ];
45
- const processResult = await spawnWithTimeout(config.keyshotHeadlessExe, args, config.keyshotTimeoutMs);
46
- const stdoutTail = tail(processResult.stdout, 6000);
47
- const stderrTail = tail(processResult.stderr, 6000);
48
- let parsed = null;
49
- if (await exists(resultPath)) {
50
- try {
51
- parsed = JSON.parse(await fs.readFile(resultPath, "utf8"));
45
+ try {
46
+ const processResult = await spawnWithTimeout(config.keyshotHeadlessExe, args, config.keyshotTimeoutMs);
47
+ const stdoutTail = tail(processResult.stdout, 6000);
48
+ const stderrTail = tail(processResult.stderr, 6000);
49
+ let parsed = null;
50
+ if (await exists(resultPath)) {
51
+ try {
52
+ parsed = JSON.parse(await fs.readFile(resultPath, "utf8"));
53
+ }
54
+ catch (error) {
55
+ return localFailure(`Could not parse KeyShot result JSON: ${errorMessage(error)}`, {
56
+ keyshotStdoutTail: stdoutTail,
57
+ warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
58
+ });
59
+ }
60
+ }
61
+ if (processResult.timedOut) {
62
+ return localFailure(`KeyShot timed out after ${config.keyshotTimeoutMs}ms`, {
63
+ data: parsed?.data ?? null,
64
+ outputFiles: parsed?.outputFiles ?? [],
65
+ warnings: [...(parsed?.warnings ?? []), ...(stderrTail ? [`stderr: ${stderrTail}`] : [])],
66
+ keyshotStdoutTail: stdoutTail,
67
+ });
52
68
  }
53
- catch (error) {
54
- return localFailure(`Could not parse KeyShot result JSON: ${errorMessage(error)}`, {
69
+ if (!parsed) {
70
+ return localFailure(`KeyShot did not produce a result file. Exit code: ${processResult.exitCode ?? "unknown"}`, {
55
71
  keyshotStdoutTail: stdoutTail,
56
72
  warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
57
73
  });
58
74
  }
75
+ parsed.keyshotStdoutTail = stdoutTail || parsed.keyshotStdoutTail || "";
76
+ if (stderrTail)
77
+ parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
78
+ if (processResult.exitCode !== 0 && parsed.ok) {
79
+ return {
80
+ ...parsed,
81
+ ok: false,
82
+ error: `KeyShot exited with code ${processResult.exitCode}`,
83
+ };
84
+ }
85
+ return parsed;
59
86
  }
60
- if (processResult.timedOut) {
61
- return localFailure(`KeyShot timed out after ${config.keyshotTimeoutMs}ms`, {
62
- data: parsed?.data ?? null,
63
- outputFiles: parsed?.outputFiles ?? [],
64
- warnings: [...(parsed?.warnings ?? []), ...(stderrTail ? [`stderr: ${stderrTail}`] : [])],
65
- keyshotStdoutTail: stdoutTail,
66
- });
67
- }
68
- if (!parsed) {
69
- return localFailure(`KeyShot did not produce a result file. Exit code: ${processResult.exitCode ?? "unknown"}`, {
70
- keyshotStdoutTail: stdoutTail,
71
- warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
72
- });
73
- }
74
- parsed.keyshotStdoutTail = stdoutTail || parsed.keyshotStdoutTail || "";
75
- if (stderrTail)
76
- parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
77
- if (processResult.exitCode !== 0 && parsed.ok) {
78
- return {
79
- ...parsed,
80
- ok: false,
81
- error: `KeyShot exited with code ${processResult.exitCode}`,
82
- };
87
+ finally {
88
+ // Best-effort cleanup so work/tmp does not accumulate args/result files.
89
+ await cleanupTmp([argsPath, resultPath]).catch(() => undefined);
83
90
  }
84
- return parsed;
91
+ }
92
+ async function cleanupTmp(paths) {
93
+ await Promise.all(paths.map(async (filePath) => {
94
+ if (await exists(filePath)) {
95
+ await fs.rm(filePath, { force: true });
96
+ }
97
+ }));
85
98
  }
86
99
  function spawnWithTimeout(command, args, timeoutMs) {
87
100
  return new Promise((resolve) => {
package/dist/schemas.js CHANGED
@@ -1,8 +1,12 @@
1
1
  import { z } from "zod";
2
2
  const optionalPath = z.string().min(1).optional();
3
+ const imageFormat = z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]);
3
4
  export const scenePathSchema = z.object({
4
5
  scenePath: z.string().min(1),
5
6
  });
7
+ export const listCamerasSchema = z.object({
8
+ scenePath: z.string().min(1),
9
+ });
6
10
  export const renderSchema = z.object({
7
11
  scenePath: z.string().min(1),
8
12
  outputPath: optionalPath,
@@ -13,6 +17,10 @@ export const renderSchema = z.object({
13
17
  camera: z.string().min(1).optional(),
14
18
  format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
15
19
  });
20
+ export const renderInputSchema = renderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
21
+ message: "Choose either samples or maxTimeSeconds, not both.",
22
+ path: ["maxTimeSeconds"],
23
+ });
16
24
  export const batchRenderSchema = z.object({
17
25
  scenePath: z.string().min(1),
18
26
  outputDir: z.string().min(1),
@@ -24,6 +32,10 @@ export const batchRenderSchema = z.object({
24
32
  format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
25
33
  overwrite: z.boolean().optional(),
26
34
  });
35
+ export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
36
+ message: "Choose either samples or maxTimeSeconds, not both.",
37
+ path: ["maxTimeSeconds"],
38
+ });
27
39
  export const importModelSchema = z.object({
28
40
  modelPath: z.string().min(1),
29
41
  baseScenePath: optionalPath,
@@ -63,3 +75,36 @@ export const saveSceneSchema = z.object({
63
75
  scenePath: z.string().min(1),
64
76
  outputScenePath: z.string().min(1),
65
77
  });
78
+ // --- Render queue ---
79
+ export const renderJobSchema = z.object({
80
+ scenePath: z.string().min(1),
81
+ outputPath: optionalPath,
82
+ camera: z.string().min(1).optional(),
83
+ width: z.number().int().positive().optional(),
84
+ height: z.number().int().positive().optional(),
85
+ samples: z.number().int().positive().optional(),
86
+ maxTimeSeconds: z.number().positive().optional(),
87
+ format: imageFormat.optional(),
88
+ });
89
+ export const renderJobInputSchema = renderJobSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
90
+ message: "Choose either samples or maxTimeSeconds, not both.",
91
+ path: ["maxTimeSeconds"],
92
+ });
93
+ export const renderQueueSchema = z.object({
94
+ jobs: z.array(renderJobSchema).min(1),
95
+ continueOnError: z.boolean().optional(),
96
+ });
97
+ export const renderQueueInputSchema = z.object({
98
+ jobs: z.array(renderJobInputSchema).min(1),
99
+ continueOnError: z.boolean().optional(),
100
+ });
101
+ // --- Material preset library ---
102
+ export const listMaterialPresetsSchema = z.object({});
103
+ export const applyMaterialPresetInputSchema = z.object({
104
+ scenePath: z.string().min(1),
105
+ presetName: z.string().min(1),
106
+ objectName: z.string().min(1).optional(),
107
+ objectPath: z.string().min(1).optional(),
108
+ outputScenePath: z.string().min(1),
109
+ });
110
+ export const applyMaterialPresetSchema = applyMaterialPresetInputSchema.refine((value) => value.objectName || value.objectPath, { message: "Provide objectName or objectPath." });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyshot-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.1",
4
4
  "description": "Local and portable MCP server for KeyShot headless scripting.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/truman-t3/keyshot-mcp#readme",
@@ -19,6 +19,7 @@
19
19
  "prepare": "tsc -p tsconfig.json",
20
20
  "start": "node ./dist/index.js",
21
21
  "check": "tsc -p tsconfig.json --noEmit",
22
+ "test": "vitest run",
22
23
  "status": "node ./dist/cli/status.js"
23
24
  },
24
25
  "keywords": [
@@ -33,6 +34,7 @@
33
34
  "dist",
34
35
  "scripts/keyshot_bridge.py",
35
36
  "examples",
37
+ "presets",
36
38
  "README.md",
37
39
  "CONTRIBUTING.md",
38
40
  "SECURITY.md",
@@ -48,6 +50,7 @@
48
50
  },
49
51
  "devDependencies": {
50
52
  "@types/node": "^22.17.2",
51
- "typescript": "^5.9.2"
53
+ "typescript": "^5.9.2",
54
+ "vitest": "^3.2.4"
52
55
  }
53
56
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "Brushed Steel": {
3
+ "materialName": "Steel Brushed",
4
+ "description": "Default brushed stainless steel look for metal parts."
5
+ },
6
+ "Matte Black Plastic": {
7
+ "materialName": "Plastic Matte Black",
8
+ "description": "Soft-touch matte black plastic for housings and enclosures."
9
+ },
10
+ "Clear Glass": {
11
+ "materialName": "Glass Clear",
12
+ "description": "Transparent glass for covers and lenses."
13
+ }
14
+ }
@@ -31,6 +31,8 @@ def main():
31
31
  data = status()
32
32
  elif operation == "inspect_scene":
33
33
  data = inspect_scene()
34
+ elif operation == "list_cameras":
35
+ data = list_cameras()
34
36
  elif operation == "render":
35
37
  data = render(payload, output_files, warnings)
36
38
  elif operation == "batch_render":
@@ -96,6 +98,25 @@ def inspect_scene():
96
98
  }
97
99
 
98
100
 
101
+ def list_cameras():
102
+ raw = safe_list_call("getCameras")
103
+ names = []
104
+ for camera in raw:
105
+ if isinstance(camera, str):
106
+ name = camera
107
+ else:
108
+ name = first_success(
109
+ lambda camera=camera: camera.getName(),
110
+ lambda camera=camera: camera.name(),
111
+ lambda camera=camera: camera.name,
112
+ default=None,
113
+ )
114
+ if name is None:
115
+ name = repr(camera)
116
+ names.append(name)
117
+ return {"cameras": names, "count": len(names)}
118
+
119
+
99
120
  def render(payload, output_files, warnings):
100
121
  output_path = payload.get("outputPath") or default_output_path(payload, "render", payload.get("format") or "png")
101
122
  ensure_parent(output_path)
@@ -116,16 +137,35 @@ def render(payload, output_files, warnings):
116
137
  samples = payload.get("samples")
117
138
  max_time = payload.get("maxTimeSeconds")
118
139
 
119
- call_variants(
120
- "render image",
121
- lambda: lux.renderImage(output_path, width, height),
122
- lambda: lux.renderImage(output_path, width, height, samples) if samples else None,
123
- lambda: lux.renderImage(output_path, width, height, samples, max_time) if samples and max_time else None,
124
- lambda: lux.renderImage(output_path),
125
- )
140
+ if samples is not None and max_time is not None:
141
+ raise RuntimeError("samples and maxTimeSeconds cannot be used together; choose one render mode.")
142
+
143
+ render_options = build_render_options(samples, max_time)
144
+
145
+ if render_options is None:
146
+ call_variants(
147
+ "render image",
148
+ lambda: lux.renderImage(output_path, width, height),
149
+ lambda: lux.renderImage(output_path),
150
+ )
151
+ else:
152
+ # Current KeyShot versions accept a RenderOptions object. Keep positional
153
+ # and keyword variants for older headless scripting builds.
154
+ call_variants(
155
+ "render image with options",
156
+ lambda: lux.renderImage(output_path, width, height, opts=render_options),
157
+ lambda: lux.renderImage(output_path, width, height, render_options),
158
+ lambda: lux.renderImage(output_path, opts=render_options),
159
+ )
126
160
 
127
161
  output_files.append(output_path)
128
- return {"rendered": output_path, "width": width, "height": height}
162
+ return {
163
+ "rendered": output_path,
164
+ "width": width,
165
+ "height": height,
166
+ "samples": samples,
167
+ "maxTimeSeconds": max_time,
168
+ }
129
169
 
130
170
 
131
171
  def batch_render(payload, output_files, warnings):
@@ -177,11 +217,19 @@ def batch_render(payload, output_files, warnings):
177
217
  def import_model(payload, output_files, warnings):
178
218
  model_path = payload.get("modelPath")
179
219
  output_scene_path = payload.get("outputScenePath")
220
+ base_scene_path = payload.get("baseScenePath")
180
221
  if not model_path or not os.path.exists(model_path):
181
222
  raise RuntimeError("Model file not found: %s" % model_path)
182
223
  if not output_scene_path:
183
224
  raise RuntimeError("outputScenePath is required")
184
225
 
226
+ if base_scene_path:
227
+ if not os.path.exists(base_scene_path):
228
+ raise RuntimeError("Base scene file not found: %s" % base_scene_path)
229
+ open_scene(base_scene_path, warnings)
230
+ else:
231
+ new_scene(warnings)
232
+
185
233
  call_variants(
186
234
  "import model",
187
235
  lambda: lux.importFile(model_path),
@@ -189,7 +237,33 @@ def import_model(payload, output_files, warnings):
189
237
  )
190
238
  save_to(output_scene_path)
191
239
  output_files.append(output_scene_path)
192
- return {"importedModel": model_path, "savedScene": output_scene_path}
240
+ return {
241
+ "importedModel": model_path,
242
+ "baseScene": base_scene_path,
243
+ "savedScene": output_scene_path,
244
+ }
245
+
246
+
247
+ def open_scene(scene_path, warnings):
248
+ call_variants(
249
+ "open base scene",
250
+ lambda: lux.openScene(scene_path),
251
+ lambda: lux.openFile(scene_path),
252
+ lambda: lux.loadScene(scene_path),
253
+ lambda: lux.openProject(scene_path),
254
+ )
255
+
256
+
257
+ def new_scene(warnings):
258
+ try:
259
+ call_variants(
260
+ "create new scene",
261
+ lambda: lux.newScene(),
262
+ lambda: lux.createScene(),
263
+ lambda: lux.newProject(),
264
+ )
265
+ except RuntimeError:
266
+ warnings.append("Started import into the current KeyShot scene (could not create a new scene).")
193
267
 
194
268
 
195
269
  def apply_material(payload, output_files, warnings):
@@ -225,34 +299,45 @@ def set_camera(payload, output_files, warnings):
225
299
  if position is None or look_at is None:
226
300
  raise RuntimeError("position and lookAt are required")
227
301
 
228
- camera = first_success(
302
+ camera = first_camera_object(
229
303
  lambda: lux.getCamera(camera_name),
230
304
  lambda: lux.newCamera(camera_name),
231
305
  lambda: lux.createCamera(camera_name),
232
- default=None,
233
306
  )
234
307
 
235
308
  if camera is None and hasattr(lux, "saveCamera"):
236
- camera = call_variants("save camera", lambda: lux.saveCamera(camera_name))
237
-
238
- call_variants(
239
- "set camera position",
240
- lambda: camera.setPosition(tuple(position)),
241
- lambda: lux.setCameraPosition(camera_name, tuple(position)),
242
- lambda: lux.setCameraPosition(tuple(position)),
243
- )
244
- call_variants(
245
- "set camera look-at",
246
- lambda: camera.setLookAt(tuple(look_at)),
247
- lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
248
- lambda: lux.setCameraLookAt(tuple(look_at)),
249
- )
250
- call_variants(
251
- "set camera up",
252
- lambda: camera.setUp(tuple(up)),
253
- lambda: lux.setCameraUp(camera_name, tuple(up)),
254
- lambda: lux.setCameraUp(tuple(up)),
255
- )
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
+ if camera is not None:
318
+ # Object-level API is available: drive the camera object directly.
319
+ call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
320
+ call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
321
+ call_variants("set camera up", lambda: camera.setUp(tuple(up)))
322
+ else:
323
+ # 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
+ )
331
+ call_variants(
332
+ "set camera look-at",
333
+ lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
334
+ lambda: lux.setCameraLookAt(tuple(look_at)),
335
+ )
336
+ call_variants(
337
+ "set camera up",
338
+ lambda: lux.setCameraUp(camera_name, tuple(up)),
339
+ lambda: lux.setCameraUp(tuple(up)),
340
+ )
256
341
 
257
342
  save_to(output_scene_path)
258
343
  output_files.append(output_scene_path)
@@ -386,13 +471,13 @@ def save_to(output_scene_path):
386
471
 
387
472
 
388
473
  def call_variants(label, *callbacks):
474
+ """Try each callback in order. The first one that does not raise wins, even
475
+ if it returns None (KeyShot setters typically return nothing). Only if every
476
+ callback raises do we report the combined errors."""
389
477
  errors = []
390
478
  for callback in callbacks:
391
479
  try:
392
- value = callback()
393
- if value is not None:
394
- return value
395
- return value
480
+ return callback()
396
481
  except Exception as exc:
397
482
  errors.append(str(exc))
398
483
  raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
@@ -407,6 +492,62 @@ def first_success(*callbacks, default=None):
407
492
  return default
408
493
 
409
494
 
495
+ def first_non_none(*callbacks, default=None):
496
+ for callback in callbacks:
497
+ try:
498
+ value = callback()
499
+ if value is not None:
500
+ return value
501
+ except Exception:
502
+ pass
503
+ return default
504
+
505
+
506
+ def first_camera_object(*callbacks):
507
+ for callback in callbacks:
508
+ try:
509
+ value = callback()
510
+ if is_camera_object(value):
511
+ return value
512
+ except Exception:
513
+ pass
514
+ return None
515
+
516
+
517
+ def is_camera_object(value):
518
+ if value is None or value is False or value is True:
519
+ return False
520
+ return any(hasattr(value, method) for method in ("setPosition", "setLookAt", "setUp"))
521
+
522
+
523
+ def build_render_options(samples, max_time):
524
+ if samples is None and max_time is None:
525
+ return None
526
+
527
+ get_options = getattr(lux, "getRenderOptions", None)
528
+ if get_options is None:
529
+ raise RuntimeError("KeyShot lux.getRenderOptions is not available for advanced render settings.")
530
+
531
+ options = get_options()
532
+ if options is None:
533
+ raise RuntimeError("KeyShot returned no RenderOptions object for advanced render settings.")
534
+
535
+ if samples is not None:
536
+ call_variants(
537
+ "set render samples",
538
+ lambda: options.setMaxSamplesRendering(samples),
539
+ lambda: options.setAdvancedRendering(samples),
540
+ )
541
+
542
+ if max_time is not None:
543
+ call_variants(
544
+ "set render max time",
545
+ lambda: options.setMaxTimeRendering(max_time),
546
+ )
547
+
548
+ return options
549
+
550
+
410
551
  def serialize_value(value):
411
552
  if value is None or isinstance(value, (str, int, float, bool)):
412
553
  return value