keyshot-mcp 0.7.0 → 0.8.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 @@ the local computer.
28
28
  - Apply materials directly or through a local material preset library.
29
29
  - Select environments, change brightness, and rotate the active environment.
30
30
  - Save edited scenes to a controlled output directory.
31
+ - Prepare and render a product from a model or existing scene in one tool call.
31
32
 
32
33
  ### Requirements and compatibility
33
34
 
@@ -40,7 +41,7 @@ the local computer.
40
41
 
41
42
  ### Install
42
43
 
43
- The current release is `0.7.0`.
44
+ The current release is `0.8.0`.
44
45
 
45
46
  #### Option 1: run with npx
46
47
 
@@ -51,7 +52,7 @@ This requires no global npm installation:
51
52
  "mcpServers": {
52
53
  "keyshot": {
53
54
  "command": "npx",
54
- "args": ["-y", "keyshot-mcp@0.7.0"],
55
+ "args": ["-y", "keyshot-mcp@0.8.0"],
55
56
  "env": {
56
57
  "KEYSHOT_HEADLESS_EXE": "C:/Program Files/KeyShot Studio/bin/keyshot_headless.exe"
57
58
  }
@@ -63,7 +64,7 @@ This requires no global npm installation:
63
64
  #### Option 2: install globally
64
65
 
65
66
  ```bash
66
- npm install -g keyshot-mcp@0.7.0
67
+ npm install -g keyshot-mcp@0.8.0
67
68
  ```
68
69
 
69
70
  ```json
@@ -112,14 +113,14 @@ The following prompt can be pasted into a coding agent that is allowed to edit
112
113
  the MCP client configuration:
113
114
 
114
115
  ```text
115
- Install KeyShot MCP 0.7.0 and configure it in my MCP client.
116
+ Install KeyShot MCP 0.8.0 and configure it in my MCP client.
116
117
 
117
118
  1. Find the local KeyShot headless executable.
118
119
  2. Add an MCP server named "keyshot" that runs:
119
- npx -y keyshot-mcp@0.7.0
120
+ npx -y keyshot-mcp@0.8.0
120
121
  3. Set KEYSHOT_HEADLESS_EXE to the executable path.
121
122
  4. Keep KEYSHOT_ALLOW_EXTERNAL_OUTPUTS disabled.
122
- 5. Restart or reload the MCP client, then call keyshot_status.
123
+ 5. Restart or reload the MCP client, call keyshot_status, then use keyshot_product_render for a one-click product render.
123
124
  6. Do not upload or publish any KeyShot scenes, models, renders, or license data.
124
125
  ```
125
126
 
@@ -144,6 +145,7 @@ scene, model, material, and environment paths may be located elsewhere.
144
145
  | Tool | Purpose |
145
146
  | --- | --- |
146
147
  | `keyshot_status` | Verify that KeyShot headless can start and report its version. |
148
+ | `keyshot_product_render` | Prepare, save, and render a model or existing scene in one headless process. |
147
149
  | `keyshot_inspect_scene` | Inspect objects, cameras, materials, model sets, and references. |
148
150
  | `keyshot_list_cameras` | List available camera names. |
149
151
  | `keyshot_render` | Render one image. |
@@ -160,9 +162,48 @@ scene, model, material, and environment paths may be located elsewhere.
160
162
  | `keyshot_set_environment` | Select or adjust an environment, brightness, or rotation. |
161
163
  | `keyshot_save_scene` | Save a scene to a new output path. |
162
164
 
163
- The server also exposes a `keyshot_product_render` MCP prompt and a
165
+ The server exposes 17 tools, a `keyshot_product_render` MCP prompt, and a
164
166
  `keyshot://workflow` resource.
165
167
 
168
+ ### One-click product render
169
+
170
+ In an MCP client, a designer can use a natural-language request:
171
+
172
+ ```text
173
+ Turn C:\models\speaker.obj into a product render. Center it, place it on the
174
+ ground, use the Isometric camera preset, save the KeyShot scene, and render a
175
+ 1600 x 1200 PNG with 128 samples.
176
+ ```
177
+
178
+ The `keyshot_product_render` tool accepts either `modelPath` or `scenePath`. New
179
+ models default to centered and grounded geometry with a `Product Hero`
180
+ isometric camera. Existing scenes keep their current camera, materials, and
181
+ environment unless explicit changes are requested.
182
+
183
+ ```json
184
+ {
185
+ "modelPath": "C:/models/speaker.obj",
186
+ "outputScenePath": "speaker-product.bip",
187
+ "outputPath": "speaker-product.png",
188
+ "materialAssignments": [
189
+ { "objectName": "Body", "presetName": "Brushed Steel" }
190
+ ],
191
+ "cameraPresetName": "Isometric",
192
+ "focalLength": 55,
193
+ "brightness": 1.2,
194
+ "rotation": 45,
195
+ "width": 1600,
196
+ "height": 1200,
197
+ "samples": 128
198
+ }
199
+ ```
200
+
201
+ Use `renderMode: "allCameras"` with `outputDir` to render every named camera.
202
+ Generated names are derived from the source filename when output paths are
203
+ omitted. Existing files are protected by default; set `overwrite: true` only
204
+ when replacement is intentional. Material assignments always require an
205
+ explicit object and never overwrite the entire model implicitly.
206
+
166
207
  ### Product composition examples
167
208
 
168
209
  Import a model and prepare its initial composition:
@@ -222,7 +263,8 @@ them.
222
263
  The repository includes a smoke test built from generated cube geometry in
223
264
  `examples/demo`. It verifies startup, import composition, scene inspection,
224
265
  camera presets, focal length, field of view, camera distance, environment
225
- rotation, scene saving, camera discovery, and real PNG rendering:
266
+ rotation, scene saving, camera discovery, one-click model rendering, one-click
267
+ existing-scene rendering, and real PNG output:
226
268
 
227
269
  ```bash
228
270
  npm run smoke:keyshot
@@ -254,18 +296,6 @@ Linux.
254
296
  - Add depth-of-field and additional lens controls when stable headless APIs are
255
297
  available.
256
298
 
257
- ### Star history
258
-
259
- <picture>
260
- <source media="(prefers-color-scheme: dark)" srcset="assets/star-history-dark.svg">
261
- <source media="(prefers-color-scheme: light)" srcset="assets/star-history-light.svg">
262
- <img alt="GitHub star history for truman-t3/keyshot-mcp" src="assets/star-history-light.svg">
263
- </picture>
264
-
265
- The chart uses the actual `starred_at` date for each GitHub star and displays a
266
- cumulative step line. A weekly GitHub Action updates it only when detailed
267
- stargazer dates are available.
268
-
269
299
  ### License and security
270
300
 
271
301
  Released under the [MIT License](LICENSE). See [SECURITY.md](SECURITY.md) for
@@ -273,6 +303,18 @@ security guidance and [CONTRIBUTING.md](CONTRIBUTING.md) for development notes.
273
303
  Do not commit KeyShot licenses, private scenes, customer assets, or unpublished
274
304
  renders.
275
305
 
306
+ ### Trademark and project status
307
+
308
+ KeyShot is a trademark of KeyShot ApS and/or KeyShot Inc. This is an independent,
309
+ open-source community project and is not affiliated with, endorsed by, or
310
+ sponsored by KeyShot.
311
+
312
+ This project provides only an MCP integration. It does not include KeyShot
313
+ Studio, KeyShot assets, or a KeyShot license. Users must install and license
314
+ KeyShot Studio separately and comply with the applicable KeyShot terms. Do not
315
+ use this project to bypass licensing, share credentials, or redistribute KeyShot
316
+ software or proprietary resources.
317
+
276
318
  ---
277
319
 
278
320
  ## 中文
@@ -291,6 +333,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
291
333
  - 直接应用材质,或使用本地材质预设库。
292
334
  - 选择环境、调整亮度并旋转当前环境。
293
335
  - 将修改后的场景保存到受控输出目录。
336
+ - 通过一次工具调用完成模型或现有场景的产品构图与渲染。
294
337
 
295
338
  ### 运行要求与兼容性
296
339
 
@@ -302,7 +345,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
302
345
 
303
346
  ### 安装
304
347
 
305
- 当前版本为 `0.7.0`。
348
+ 当前版本为 `0.8.0`。
306
349
 
307
350
  #### 方式一:使用 npx
308
351
 
@@ -313,7 +356,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
313
356
  "mcpServers": {
314
357
  "keyshot": {
315
358
  "command": "npx",
316
- "args": ["-y", "keyshot-mcp@0.7.0"],
359
+ "args": ["-y", "keyshot-mcp@0.8.0"],
317
360
  "env": {
318
361
  "KEYSHOT_HEADLESS_EXE": "C:/Program Files/KeyShot Studio/bin/keyshot_headless.exe"
319
362
  }
@@ -325,7 +368,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
325
368
  #### 方式二:全局安装
326
369
 
327
370
  ```bash
328
- npm install -g keyshot-mcp@0.7.0
371
+ npm install -g keyshot-mcp@0.8.0
329
372
  ```
330
373
 
331
374
  ```json
@@ -373,14 +416,14 @@ pnpm build
373
416
  下面的提示词适用于有权限修改 MCP 客户端配置的编程 Agent:
374
417
 
375
418
  ```text
376
- 请帮我安装 KeyShot MCP 0.7.0,并添加到我的 MCP 客户端。
419
+ 请帮我安装 KeyShot MCP 0.8.0,并添加到我的 MCP 客户端。
377
420
 
378
421
  1. 查找本机 KeyShot headless 可执行文件。
379
422
  2. 添加名为 keyshot 的 MCP server,运行:
380
- npx -y keyshot-mcp@0.7.0
423
+ npx -y keyshot-mcp@0.8.0
381
424
  3. 将 KEYSHOT_HEADLESS_EXE 设置为可执行文件路径。
382
425
  4. 保持 KEYSHOT_ALLOW_EXTERNAL_OUTPUTS 关闭。
383
- 5. 重启或重新加载 MCP 客户端,然后调用 keyshot_status
426
+ 5. 重启或重新加载 MCP 客户端,调用 keyshot_status,然后使用 keyshot_product_render 一键完成产品出图。
384
427
  6. 不要上传或发布任何 KeyShot 场景、模型、渲染图或许可证数据。
385
428
  ```
386
429
 
@@ -404,6 +447,7 @@ pnpm build
404
447
  | 工具 | 用途 |
405
448
  | --- | --- |
406
449
  | `keyshot_status` | 检查 KeyShot headless 是否可启动并读取版本。 |
450
+ | `keyshot_product_render` | 在一个 headless 进程中完成模型或场景准备、保存和渲染。 |
407
451
  | `keyshot_inspect_scene` | 检查对象、相机、材质、模型集和外部引用。 |
408
452
  | `keyshot_list_cameras` | 列出场景中的相机名称。 |
409
453
  | `keyshot_render` | 渲染一张图片。 |
@@ -420,7 +464,43 @@ pnpm build
420
464
  | `keyshot_set_environment` | 选择环境并调整亮度或旋转角度。 |
421
465
  | `keyshot_save_scene` | 将场景保存到新的输出路径。 |
422
466
 
423
- 服务还提供 `keyshot_product_render` MCP 提示词和 `keyshot://workflow` 资源。
467
+ 服务共提供 17 个工具,并提供 `keyshot_product_render` MCP 提示词和
468
+ `keyshot://workflow` 资源。
469
+
470
+ ### 一键产品出图
471
+
472
+ 设计师可以直接在支持 MCP 的 Agent 中描述需求:
473
+
474
+ ```text
475
+ 把 C:\models\speaker.obj 做成产品渲染图。自动居中、贴地,使用 Isometric
476
+ 相机预设,保存 KeyShot 场景,并用 128 采样渲染一张 1600 x 1200 PNG。
477
+ ```
478
+
479
+ `keyshot_product_render` 可以接收 `modelPath` 或 `scenePath`。新模型默认自动居中、
480
+ 贴地并创建名为 `Product Hero` 的等轴测相机;已有场景默认保留当前相机、材质和环境,
481
+ 只有明确提供参数时才修改。
482
+
483
+ ```json
484
+ {
485
+ "modelPath": "C:/models/speaker.obj",
486
+ "outputScenePath": "speaker-product.bip",
487
+ "outputPath": "speaker-product.png",
488
+ "materialAssignments": [
489
+ { "objectName": "Body", "presetName": "Brushed Steel" }
490
+ ],
491
+ "cameraPresetName": "Isometric",
492
+ "focalLength": 55,
493
+ "brightness": 1.2,
494
+ "rotation": 45,
495
+ "width": 1600,
496
+ "height": 1200,
497
+ "samples": 128
498
+ }
499
+ ```
500
+
501
+ 将 `renderMode` 设为 `allCameras` 并提供 `outputDir`,即可渲染场景中的全部命名相机。
502
+ 省略输出路径时会根据源文件名自动生成。默认不会覆盖已有文件,只有明确设置
503
+ `overwrite: true` 才会替换。材质指定必须包含明确的对象,不会隐式覆盖整个模型。
424
504
 
425
505
  ### 产品构图示例
426
506
 
@@ -474,7 +554,7 @@ pnpm build
474
554
 
475
555
  仓库提供基于 `examples/demo` 生成立方体几何体的 smoke test,用于验证 KeyShot
476
556
  启动、导入构图、场景检查、相机预设、焦距、视野角、相机距离、环境旋转、场景保存、
477
- 相机发现和真实 PNG 渲染:
557
+ 相机发现、模型一键出图、现有场景一键渲染全部相机和真实 PNG 输出:
478
558
 
479
559
  ```bash
480
560
  npm run smoke:keyshot
@@ -503,19 +583,17 @@ CI 在 Windows 和 Ubuntu 上使用 Node.js 20、24 运行。Linux CI 验证 MCP
503
583
  - 验证 macOS 安装和 headless 行为。
504
584
  - 在 headless API 稳定支持后增加景深和更多镜头控制。
505
585
 
506
- ### 星标趋势
507
-
508
- <picture>
509
- <source media="(prefers-color-scheme: dark)" srcset="assets/star-history-dark.svg">
510
- <source media="(prefers-color-scheme: light)" srcset="assets/star-history-light.svg">
511
- <img alt="truman-t3/keyshot-mcp 的 GitHub 星标趋势" src="assets/star-history-light.svg">
512
- </picture>
513
-
514
- 趋势图使用每颗 GitHub Star 的真实 `starred_at` 日期绘制累计阶梯线。GitHub Actions
515
- 每周检查一次,并且只在能够取得逐星日期时更新。
516
-
517
586
  ### 许可证与安全
518
587
 
519
588
  项目采用 [MIT License](LICENSE)。安全说明见 [SECURITY.md](SECURITY.md),开发说明
520
589
  见 [CONTRIBUTING.md](CONTRIBUTING.md)。请勿提交 KeyShot 许可证、私有场景、客户素材
521
590
  或未公开渲染图。
591
+
592
+ ### 商标与项目性质
593
+
594
+ KeyShot 是 KeyShot ApS 和/或 KeyShot Inc. 的商标。本项目是独立的开源社区项目,
595
+ 与 KeyShot 官方无隶属、认可、赞助或其他合作关系。
596
+
597
+ 本项目仅提供 MCP 集成功能,不包含 KeyShot Studio、KeyShot 官方素材或 KeyShot
598
+ 许可证。用户必须自行安装并合法授权 KeyShot Studio,同时遵守适用的 KeyShot 条款。
599
+ 不得使用本项目绕过许可证、共享账号凭据,或重新分发 KeyShot 软件及其专有资源。
package/dist/index.js CHANGED
@@ -7,8 +7,9 @@ import { runKeyShotSerialized } from "./runner.js";
7
7
  import { runRenderQueue } from "./queue.js";
8
8
  import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
9
9
  import { loadCameraPresets, findCameraPreset } from "./camera-presets.js";
10
+ import { prepareProductRenderRequest } from "./product-render.js";
10
11
  import { VERSION } from "./version.js";
11
- import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, applyCameraPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, listCameraPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraInputSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
12
+ import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, applyCameraPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, listCameraPresetsSchema, productRenderInputSchema, productRenderSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraInputSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
12
13
  const config = getConfig();
13
14
  const server = new McpServer({
14
15
  name: "keyshot-mcp",
@@ -39,6 +40,7 @@ server.registerPrompt("keyshot_product_render", {
39
40
  title: "Render a KeyShot product scene",
40
41
  description: "Create a practical prompt for rendering or batch-rendering a KeyShot product scene.",
41
42
  argsSchema: {
43
+ modelPath: scenePathSchema.shape.scenePath.optional(),
42
44
  scenePath: scenePathSchema.shape.scenePath.optional(),
43
45
  goal: scenePathSchema.shape.scenePath.optional(),
44
46
  },
@@ -50,15 +52,29 @@ server.registerPrompt("keyshot_product_render", {
50
52
  type: "text",
51
53
  text: [
52
54
  "Use KeyShot MCP to prepare a product render.",
53
- args.scenePath ? `Scene path: ${args.scenePath}` : "Ask me for the KeyShot scene path first.",
55
+ args.modelPath
56
+ ? `Model path: ${args.modelPath}`
57
+ : args.scenePath
58
+ ? `Scene path: ${args.scenePath}`
59
+ : "Ask me for either a model path or a KeyShot scene path first.",
54
60
  args.goal ? `Goal: ${args.goal}` : "Inspect the scene, choose a suitable camera, then render a PNG preview.",
55
- "Start with keyshot_status, then keyshot_inspect_scene, then render or batch render as needed.",
61
+ "Call the keyshot_product_render tool for the complete workflow. Use the lower-level tools only when individual steps need manual control.",
56
62
  ].join("\n"),
57
63
  },
58
64
  },
59
65
  ],
60
66
  }));
61
67
  server.tool("keyshot_status", "Check KeyShot headless availability and script startup.", {}, async () => toolResponse(await runKeyShotSerialized(config, { operation: "status" })));
68
+ server.tool("keyshot_product_render", "Prepare and render a product from either a model file or an existing KeyShot scene in one headless process.", productRenderInputSchema.shape, async (args) => {
69
+ try {
70
+ const parsed = productRenderSchema.parse(args);
71
+ const request = await prepareProductRenderRequest(config, parsed);
72
+ return toolResponse(await runKeyShotSerialized(config, request));
73
+ }
74
+ catch (error) {
75
+ return toolResponse(localFailure(errorMessage(error)));
76
+ }
77
+ });
62
78
  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 })));
63
79
  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 })));
64
80
  server.tool("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => {
@@ -0,0 +1,83 @@
1
+ import path from "node:path";
2
+ import { findCameraPreset, loadCameraPresets } from "./camera-presets.js";
3
+ import { findMaterialPreset, loadMaterialPresets } from "./presets.js";
4
+ export async function prepareProductRenderRequest(config, input) {
5
+ const sourcePath = input.modelPath ?? input.scenePath;
6
+ const stem = safeStem(sourcePath);
7
+ const format = input.format ?? "png";
8
+ const renderMode = input.renderMode ?? "single";
9
+ const request = {
10
+ ...input,
11
+ operation: "product_render",
12
+ renderMode,
13
+ format,
14
+ outputScenePath: input.outputScenePath ?? `${stem}-product.bip`,
15
+ overwrite: input.overwrite ?? false,
16
+ continueOnError: input.continueOnError ?? true,
17
+ };
18
+ if (renderMode === "single") {
19
+ request.outputPath = input.outputPath ?? `${stem}-product.${format}`;
20
+ delete request.outputDir;
21
+ }
22
+ else {
23
+ request.outputDir = input.outputDir ?? `${stem}-renders`;
24
+ delete request.outputPath;
25
+ }
26
+ if (input.modelPath) {
27
+ request.centerGeometry = input.centerGeometry ?? true;
28
+ request.snapToGround = input.snapToGround ?? true;
29
+ request.adjustCameraLookAt = input.adjustCameraLookAt ?? true;
30
+ request.adjustEnvironment = input.adjustEnvironment ?? true;
31
+ }
32
+ request.materialAssignments = await resolveMaterialAssignments(config, input.materialAssignments ?? []);
33
+ const presetName = input.cameraPresetName ?? (input.modelPath ? "Isometric" : undefined);
34
+ if (presetName) {
35
+ const presets = await loadCameraPresets(config);
36
+ const preset = findCameraPreset(presets, presetName);
37
+ if (!preset) {
38
+ const available = presets.map((entry) => entry.name).join(", ") || "(none)";
39
+ throw new Error(`Camera preset not found: "${presetName}". Available: ${available}`);
40
+ }
41
+ request.cameraPresetName = preset.name;
42
+ request.cameraName = input.cameraName ?? (input.modelPath ? "Product Hero" : preset.name);
43
+ if (preset.type === "standard") {
44
+ request.standardView = preset.standardView;
45
+ }
46
+ else {
47
+ request.position = preset.position;
48
+ request.lookAt = preset.lookAt;
49
+ request.up = preset.up;
50
+ }
51
+ }
52
+ else if (input.modelPath) {
53
+ request.cameraName = input.cameraName ?? "Product Hero";
54
+ }
55
+ return request;
56
+ }
57
+ async function resolveMaterialAssignments(config, assignments) {
58
+ if (assignments.length === 0)
59
+ return [];
60
+ const needsPresets = assignments.some((assignment) => assignment.presetName !== undefined);
61
+ const presets = needsPresets ? await loadMaterialPresets(config) : [];
62
+ return assignments.map((assignment) => {
63
+ if (!assignment.presetName)
64
+ return { ...assignment };
65
+ const preset = findMaterialPreset(presets, assignment.presetName);
66
+ if (!preset) {
67
+ const available = presets.map((entry) => entry.name).join(", ") || "(none)";
68
+ throw new Error(`Material preset not found: "${assignment.presetName}". Available: ${available}`);
69
+ }
70
+ return {
71
+ objectName: assignment.objectName,
72
+ objectPath: assignment.objectPath,
73
+ presetName: preset.name,
74
+ materialName: preset.materialName,
75
+ materialPath: preset.materialPath,
76
+ };
77
+ });
78
+ }
79
+ function safeStem(sourcePath) {
80
+ const parsed = path.parse(sourcePath);
81
+ const normalized = parsed.name.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
82
+ return normalized || "product";
83
+ }
package/dist/runner.js CHANGED
@@ -30,6 +30,22 @@ async function runKeyShot(config, request) {
30
30
  catch (error) {
31
31
  return localFailure(errorMessage(error));
32
32
  }
33
+ if (normalizedRequest.operation === "product_render") {
34
+ for (const field of ["modelPath", "baseScenePath"]) {
35
+ const value = normalizedRequest[field];
36
+ if (typeof value === "string" && !(await exists(value))) {
37
+ return localFailure(`${field === "modelPath" ? "Model" : "Base scene"} file not found: ${value}`);
38
+ }
39
+ }
40
+ if (normalizedRequest.overwrite !== true) {
41
+ const protectedOutputs = [normalizedRequest.outputScenePath, normalizedRequest.outputPath];
42
+ for (const output of protectedOutputs) {
43
+ if (typeof output === "string" && await exists(output)) {
44
+ return localFailure(`Output already exists and overwrite is false: ${output}`);
45
+ }
46
+ }
47
+ }
48
+ }
33
49
  const id = `${Date.now()}-${randomUUID()}`;
34
50
  const argsPath = path.join(config.tmpDir, `${id}.args.json`);
35
51
  const resultPath = path.join(config.tmpDir, `${id}.result.json`);
package/dist/schemas.js CHANGED
@@ -74,6 +74,94 @@ export const applyMaterialSchema = applyMaterialInputSchema.refine((value) => va
74
74
  message: "Provide materialName or materialPath.",
75
75
  });
76
76
  const vector3 = z.tuple([z.number(), z.number(), z.number()]);
77
+ export const productMaterialAssignmentSchema = z.object({
78
+ objectName: z.string().min(1).optional(),
79
+ objectPath: z.string().min(1).optional(),
80
+ presetName: z.string().min(1).optional(),
81
+ materialName: z.string().min(1).optional(),
82
+ materialPath: z.string().min(1).optional(),
83
+ }).superRefine((value, context) => {
84
+ const targets = [value.objectName, value.objectPath].filter((item) => item !== undefined);
85
+ const materials = [value.presetName, value.materialName, value.materialPath].filter((item) => item !== undefined);
86
+ if (targets.length !== 1) {
87
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose exactly one objectName or objectPath." });
88
+ }
89
+ if (materials.length !== 1) {
90
+ context.addIssue({
91
+ code: z.ZodIssueCode.custom,
92
+ message: "Choose exactly one presetName, materialName, or materialPath.",
93
+ });
94
+ }
95
+ });
96
+ export const productRenderInputSchema = z.object({
97
+ modelPath: optionalPath,
98
+ scenePath: optionalPath,
99
+ baseScenePath: optionalPath,
100
+ outputScenePath: optionalPath,
101
+ renderMode: z.enum(["single", "allCameras"]).default("single"),
102
+ outputPath: optionalPath,
103
+ outputDir: optionalPath,
104
+ centerGeometry: z.boolean().optional(),
105
+ snapToGround: z.boolean().optional(),
106
+ adjustCameraLookAt: z.boolean().optional(),
107
+ adjustEnvironment: z.boolean().optional(),
108
+ materialAssignments: z.array(productMaterialAssignmentSchema).optional(),
109
+ cameraPresetName: z.string().min(1).optional(),
110
+ cameraName: z.string().min(1).optional(),
111
+ position: vector3.optional(),
112
+ lookAt: vector3.optional(),
113
+ up: vector3.optional(),
114
+ distance: z.number().positive().optional(),
115
+ fieldOfView: z.number().gt(0).lt(180).optional(),
116
+ focalLength: z.number().min(5).max(200).optional(),
117
+ environmentName: z.string().min(1).optional(),
118
+ environmentPath: z.string().min(1).optional(),
119
+ brightness: z.number().positive().optional(),
120
+ rotation: z.number().min(0).lt(360).optional(),
121
+ width: z.number().int().positive().optional(),
122
+ height: z.number().int().positive().optional(),
123
+ samples: z.number().int().positive().optional(),
124
+ maxTimeSeconds: z.number().positive().optional(),
125
+ format: imageFormat.optional(),
126
+ overwrite: z.boolean().default(false),
127
+ continueOnError: z.boolean().default(true),
128
+ });
129
+ export const productRenderSchema = productRenderInputSchema.superRefine((value, context) => {
130
+ if ((value.modelPath === undefined) === (value.scenePath === undefined)) {
131
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Provide exactly one modelPath or scenePath." });
132
+ }
133
+ if (value.baseScenePath !== undefined && value.modelPath === undefined) {
134
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "baseScenePath requires modelPath.", path: ["baseScenePath"] });
135
+ }
136
+ const importFields = ["centerGeometry", "snapToGround", "adjustCameraLookAt", "adjustEnvironment"];
137
+ if (value.scenePath !== undefined && importFields.some((field) => value[field] !== undefined)) {
138
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Import options can only be used with modelPath." });
139
+ }
140
+ if (value.renderMode === "single" && value.outputDir !== undefined) {
141
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "outputDir is only valid for allCameras mode.", path: ["outputDir"] });
142
+ }
143
+ if (value.renderMode === "allCameras" && value.outputPath !== undefined) {
144
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "outputPath is only valid for single mode.", path: ["outputPath"] });
145
+ }
146
+ if ((value.position === undefined) !== (value.lookAt === undefined)) {
147
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Provide position and lookAt together.", path: ["lookAt"] });
148
+ }
149
+ if (value.up !== undefined && value.position === undefined) {
150
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "up requires position and lookAt.", path: ["up"] });
151
+ }
152
+ if (value.cameraPresetName !== undefined && value.position !== undefined) {
153
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "cameraPresetName cannot be combined with a custom position." });
154
+ }
155
+ if (value.fieldOfView !== undefined && value.focalLength !== undefined) {
156
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose either fieldOfView or focalLength, not both.", path: ["focalLength"] });
157
+ }
158
+ if (value.environmentName !== undefined && value.environmentPath !== undefined) {
159
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose either environmentName or environmentPath, not both." });
160
+ }
161
+ if (value.samples !== undefined && value.maxTimeSeconds !== undefined) {
162
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose either samples or maxTimeSeconds, not both.", path: ["maxTimeSeconds"] });
163
+ }
164
+ });
77
165
  const setCameraBaseSchema = z.object({
78
166
  scenePath: z.string().min(1),
79
167
  cameraName: z.string().min(1).optional(),
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.7.0";
1
+ export const VERSION = "0.8.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyshot-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.truman-t3/keyshot-mcp",
5
5
  "description": "Local MCP server for KeyShot product visualization, scene editing, camera control, and rendering.",
6
6
  "type": "module",
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { findCameraPreset, loadCameraPresets } from "../dist/camera-presets.js";
6
6
  import { getConfig } from "../dist/config.js";
7
+ import { prepareProductRenderRequest } from "../dist/product-render.js";
7
8
  import { runKeyShotSerialized } from "../dist/runner.js";
8
9
 
9
10
  const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
@@ -36,8 +37,8 @@ async function applyStandardPreset(label, scenePath, presetName, outputScenePath
36
37
  });
37
38
  }
38
39
 
39
- const status = await run("1/11 KeyShot status", { operation: "status" });
40
- const imported = await run("2/11 Import, center, and ground generated OBJ", {
40
+ const status = await run("1/13 KeyShot status", { operation: "status" });
41
+ const imported = await run("2/13 Import, center, and ground generated OBJ", {
41
42
  operation: "import_model",
42
43
  modelPath,
43
44
  outputScenePath: "demo/keyshot-mcp-demo-import.bip",
@@ -49,7 +50,7 @@ const imported = await run("2/11 Import, center, and ground generated OBJ", {
49
50
  const importedScene = imported.outputFiles[0];
50
51
  if (!importedScene) throw new Error("Import did not return a saved scene path.");
51
52
 
52
- const inspected = await run("3/11 Inspect imported scene", {
53
+ const inspected = await run("3/13 Inspect imported scene", {
53
54
  operation: "inspect_scene",
54
55
  scenePath: importedScene,
55
56
  });
@@ -59,7 +60,7 @@ if (!Array.isArray(objects) || objects.length === 0) {
59
60
  }
60
61
 
61
62
  const firstCameraResult = await applyStandardPreset(
62
- "4/11 Apply Front camera preset",
63
+ "4/13 Apply Front camera preset",
63
64
  importedScene,
64
65
  "Front",
65
66
  "demo/keyshot-mcp-demo-preset-front.bip",
@@ -67,7 +68,7 @@ const firstCameraResult = await applyStandardPreset(
67
68
  const firstCameraScene = firstCameraResult.outputFiles[0];
68
69
  if (!firstCameraScene) throw new Error("Front preset did not return a saved scene path.");
69
70
 
70
- const focalLengthResult = await run("5/11 Set Front camera focal length", {
71
+ const focalLengthResult = await run("5/13 Set Front camera focal length", {
71
72
  operation: "set_camera",
72
73
  scenePath: firstCameraScene,
73
74
  cameraName: "Front",
@@ -77,7 +78,7 @@ const focalLengthResult = await run("5/11 Set Front camera focal length", {
77
78
  const focalLengthScene = focalLengthResult.outputFiles[0];
78
79
  if (!focalLengthScene) throw new Error("Focal-length update did not return a saved scene path.");
79
80
 
80
- const environmentResult = await run("6/11 Rotate the active environment", {
81
+ const environmentResult = await run("6/13 Rotate the active environment", {
81
82
  operation: "set_environment",
82
83
  scenePath: focalLengthScene,
83
84
  rotation: 45,
@@ -87,7 +88,7 @@ const environmentScene = environmentResult.outputFiles[0];
87
88
  if (!environmentScene) throw new Error("Environment update did not return a saved scene path.");
88
89
 
89
90
  const secondCameraResult = await applyStandardPreset(
90
- "7/11 Apply Isometric camera preset",
91
+ "7/13 Apply Isometric camera preset",
91
92
  environmentScene,
92
93
  "Isometric",
93
94
  "demo/keyshot-mcp-demo-presets.bip",
@@ -95,7 +96,7 @@ const secondCameraResult = await applyStandardPreset(
95
96
  const cameraScene = secondCameraResult.outputFiles[0];
96
97
  if (!cameraScene) throw new Error("Isometric preset did not return a saved scene path.");
97
98
 
98
- const fieldOfViewResult = await run("8/11 Set Isometric camera field of view and distance", {
99
+ const fieldOfViewResult = await run("8/13 Set Isometric camera field of view and distance", {
99
100
  operation: "set_camera",
100
101
  scenePath: cameraScene,
101
102
  cameraName: "Isometric",
@@ -106,7 +107,7 @@ const fieldOfViewResult = await run("8/11 Set Isometric camera field of view and
106
107
  const productCameraScene = fieldOfViewResult.outputFiles[0];
107
108
  if (!productCameraScene) throw new Error("Field-of-view update did not return a saved scene path.");
108
109
 
109
- const rendered = await run("9/11 Discover and render every camera", {
110
+ const rendered = await run("9/13 Discover and render every camera", {
110
111
  operation: "render_all_cameras",
111
112
  scenePath: productCameraScene,
112
113
  outputDir: "demo/all-cameras",
@@ -139,8 +140,42 @@ const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await
139
140
  if (minimumSizes.some((size) => size < 10000)) {
140
141
  throw new Error("A demo camera render is unexpectedly small and may be blank.");
141
142
  }
142
- process.stdout.write("10/11 Verify two different camera images... ok\n");
143
- process.stdout.write("11/11 Verify composition, lens, and environment operations... ok\n");
143
+ process.stdout.write("10/13 Verify two different camera images... ok\n");
144
+ process.stdout.write("11/13 Verify composition, lens, and environment operations... ok\n");
145
+
146
+ const oneClickModelRequest = await prepareProductRenderRequest(config, {
147
+ modelPath,
148
+ outputScenePath: "demo/one-click-cube.bip",
149
+ outputPath: "demo/one-click-cube.png",
150
+ renderMode: "single",
151
+ width: 640,
152
+ height: 480,
153
+ maxTimeSeconds: 8,
154
+ format: "png",
155
+ overwrite: true,
156
+ continueOnError: true,
157
+ });
158
+ const oneClickModel = await run("12/13 One-click model-to-product render", oneClickModelRequest);
159
+ if (!oneClickModel.data?.savedScene || oneClickModel.data?.renders?.length !== 1) {
160
+ throw new Error("One-click model workflow did not return a scene and one render.");
161
+ }
162
+
163
+ const oneClickSceneRequest = await prepareProductRenderRequest(config, {
164
+ scenePath: productCameraScene,
165
+ outputScenePath: "demo/one-click-existing-scene.bip",
166
+ outputDir: "demo/one-click-all-cameras",
167
+ renderMode: "allCameras",
168
+ width: 640,
169
+ height: 480,
170
+ maxTimeSeconds: 8,
171
+ format: "png",
172
+ overwrite: true,
173
+ continueOnError: true,
174
+ });
175
+ const oneClickScene = await run("13/13 One-click existing-scene all-camera render", oneClickSceneRequest);
176
+ if (!oneClickScene.data?.savedScene || oneClickScene.data?.renders?.length < 2) {
177
+ throw new Error("One-click scene workflow did not preserve the scene and render all cameras.");
178
+ }
144
179
 
145
180
  console.log(JSON.stringify({
146
181
  keyshotVersion: status.data?.version ?? null,
@@ -148,6 +183,7 @@ console.log(JSON.stringify({
148
183
  scenePath: productCameraScene,
149
184
  cameraCount: renderData.total,
150
185
  renderedImages: rendered.outputFiles,
186
+ oneClickOutputs: [...oneClickModel.outputFiles, ...oneClickScene.outputFiles],
151
187
  verifiedPresets: namedResults.map((entry) => entry.camera),
152
188
  verifiedControls: {
153
189
  importOptions: ["centerGeometry", "snapToGround", "adjustCameraLookAt", "adjustEnvironment"],
@@ -39,6 +39,8 @@ def main():
39
39
  data = batch_render(payload, output_files, warnings)
40
40
  elif operation == "render_all_cameras":
41
41
  data = render_all_cameras(payload, output_files, warnings)
42
+ elif operation == "product_render":
43
+ data = product_render(payload, output_files, warnings)
42
44
  elif operation == "import_model":
43
45
  data = import_model(payload, output_files, warnings)
44
46
  elif operation == "apply_material":
@@ -57,6 +59,8 @@ def main():
57
59
  operation_error = None
58
60
  if operation == "render_all_cameras" and data.get("failed", 0) > 0:
59
61
  operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
62
+ elif operation == "product_render":
63
+ operation_error = data.get("operationError")
60
64
  write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
61
65
  except Exception as exc:
62
66
  warnings.append(traceback.format_exc())
@@ -227,6 +231,13 @@ def batch_render(payload, output_files, warnings):
227
231
 
228
232
 
229
233
  def render_all_cameras(payload, output_files, warnings):
234
+ scene_path = payload.get("scenePath")
235
+ if not scene_path:
236
+ raise RuntimeError("scenePath is required")
237
+ return render_all_cameras_current(payload, output_files, warnings)
238
+
239
+
240
+ def render_all_cameras_current(payload, output_files, warnings):
230
241
  scene_path = payload.get("scenePath")
231
242
  output_dir = payload.get("outputDir")
232
243
  discovered_cameras = camera_names()
@@ -238,8 +249,6 @@ def render_all_cameras(payload, output_files, warnings):
238
249
  overwrite = bool(payload.get("overwrite", False))
239
250
  continue_on_error = payload.get("continueOnError", True) is not False
240
251
 
241
- if not scene_path:
242
- raise RuntimeError("scenePath is required")
243
252
  if not output_dir:
244
253
  raise RuntimeError("outputDir is required")
245
254
  if not cameras:
@@ -320,14 +329,168 @@ def unique_camera_output_path(output_dir, camera, image_format, used_stems):
320
329
  return os.path.join(output_dir, "%s.%s" % (stem, image_format))
321
330
 
322
331
 
332
+ def product_render(payload, output_files, warnings):
333
+ stages = []
334
+ source_type = "model" if payload.get("modelPath") else "scene"
335
+ source_path = payload.get("modelPath") or payload.get("scenePath")
336
+ result = {
337
+ "sourceType": source_type,
338
+ "sourcePath": source_path,
339
+ "savedScene": None,
340
+ "renderMode": payload.get("renderMode") or "single",
341
+ "renders": [],
342
+ "materialAssignments": [],
343
+ "camera": None,
344
+ "environment": None,
345
+ "stages": stages,
346
+ "operationError": None,
347
+ }
348
+
349
+ try:
350
+ if source_type == "model":
351
+ source_data = import_model_current(payload, warnings)
352
+ else:
353
+ if not source_path:
354
+ raise RuntimeError("scenePath is required")
355
+ source_data = {"openedScene": source_path}
356
+ stages.append(product_stage("source", True, source_data))
357
+ except Exception as exc:
358
+ return product_failure(result, stages, "source", exc, warnings)
359
+
360
+ assignments = payload.get("materialAssignments") or []
361
+ try:
362
+ applied_materials = [apply_material_current(assignment, warnings) for assignment in assignments]
363
+ result["materialAssignments"] = applied_materials
364
+ stages.append(product_stage("materials", True, applied_materials, len(assignments) == 0))
365
+ except Exception as exc:
366
+ return product_failure(result, stages, "materials", exc, warnings)
367
+
368
+ try:
369
+ camera_data = configure_product_camera(payload, output_files, warnings)
370
+ result["camera"] = camera_data
371
+ stages.append(product_stage("camera", True, camera_data, camera_data is None))
372
+ except Exception as exc:
373
+ return product_failure(result, stages, "camera", exc, warnings)
374
+
375
+ environment_requested = any(
376
+ payload.get(name) is not None
377
+ for name in ("environmentName", "environmentPath", "brightness", "rotation")
378
+ )
379
+ try:
380
+ environment_data = set_environment(payload, output_files, warnings, persist=False) if environment_requested else None
381
+ result["environment"] = environment_data
382
+ stages.append(product_stage("environment", True, environment_data, not environment_requested))
383
+ except Exception as exc:
384
+ return product_failure(result, stages, "environment", exc, warnings)
385
+
386
+ output_scene_path = payload.get("outputScenePath")
387
+ try:
388
+ if not output_scene_path:
389
+ raise RuntimeError("outputScenePath is required")
390
+ save_to(output_scene_path)
391
+ output_files.append(output_scene_path)
392
+ result["savedScene"] = output_scene_path
393
+ stages.append(product_stage("save", True, {"savedScene": output_scene_path}))
394
+ except Exception as exc:
395
+ return product_failure(result, stages, "save", exc, warnings)
396
+
397
+ try:
398
+ if result["renderMode"] == "allCameras":
399
+ render_payload = dict(payload)
400
+ render_payload["scenePath"] = output_scene_path
401
+ render_data = render_all_cameras_current(render_payload, output_files, warnings)
402
+ result["renders"] = render_data.get("results", [])
403
+ render_ok = render_data.get("failed", 0) == 0
404
+ stages.append(product_stage("render", render_ok, render_data))
405
+ if not render_ok:
406
+ result["operationError"] = "%s of %s camera render(s) failed." % (
407
+ render_data.get("failed", 0),
408
+ render_data.get("total", 0),
409
+ )
410
+ else:
411
+ output_path = payload.get("outputPath")
412
+ if not output_path:
413
+ raise RuntimeError("outputPath is required for single render mode")
414
+ if os.path.exists(output_path) and not payload.get("overwrite", False):
415
+ raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
416
+ render_payload = dict(payload)
417
+ if payload.get("cameraName"):
418
+ render_payload["camera"] = payload.get("cameraName")
419
+ render_data = render(render_payload, output_files, warnings)
420
+ result["renders"] = [{"camera": render_payload.get("camera"), "outputPath": render_data["rendered"], "ok": True}]
421
+ stages.append(product_stage("render", True, render_data))
422
+ except Exception as exc:
423
+ return product_failure(result, stages, "render", exc, warnings)
424
+
425
+ return result
426
+
427
+
428
+ def configure_product_camera(payload, output_files, warnings):
429
+ camera_name = payload.get("cameraName")
430
+ standard_view = payload.get("standardView")
431
+ transform_requested = payload.get("position") is not None
432
+ lens_requested = any(payload.get(name) is not None for name in ("distance", "fieldOfView", "focalLength"))
433
+
434
+ if standard_view:
435
+ standard_data = set_standard_camera(payload, output_files, warnings, persist=False)
436
+ if lens_requested:
437
+ lens_payload = {
438
+ "cameraName": camera_name,
439
+ "distance": payload.get("distance"),
440
+ "fieldOfView": payload.get("fieldOfView"),
441
+ "focalLength": payload.get("focalLength"),
442
+ }
443
+ lens_data = set_camera(lens_payload, output_files, warnings, persist=False)
444
+ standard_data.update({
445
+ "distance": lens_data.get("distance"),
446
+ "fieldOfView": lens_data.get("fieldOfView"),
447
+ "focalLength": lens_data.get("focalLength"),
448
+ })
449
+ standard_data["presetName"] = payload.get("cameraPresetName")
450
+ return standard_data
451
+
452
+ if transform_requested or lens_requested:
453
+ camera_data = set_camera(payload, output_files, warnings, persist=False)
454
+ camera_data["presetName"] = payload.get("cameraPresetName")
455
+ return camera_data
456
+
457
+ if camera_name:
458
+ if camera_name not in camera_names():
459
+ raise RuntimeError("Camera not found: %s" % camera_name)
460
+ call_non_false_variants("activate camera", lambda: lux.setCamera(camera_name))
461
+ return {"cameraName": camera_name, "selected": True}
462
+
463
+ return None
464
+
465
+
466
+ def product_stage(name, ok, data=None, skipped=False, error=None):
467
+ return {"name": name, "ok": ok, "skipped": skipped, "data": data, "error": error}
468
+
469
+
470
+ def product_failure(result, stages, stage_name, error, warnings):
471
+ message = "Product render stage '%s' failed: %s" % (stage_name, error)
472
+ stages.append(product_stage(stage_name, False, error=str(error)))
473
+ warnings.append(message)
474
+ result["operationError"] = message
475
+ return result
476
+
477
+
323
478
  def import_model(payload, output_files, warnings):
324
- model_path = payload.get("modelPath")
479
+ data = import_model_current(payload, warnings)
325
480
  output_scene_path = payload.get("outputScenePath")
481
+ if not output_scene_path:
482
+ raise RuntimeError("outputScenePath is required")
483
+ save_to(output_scene_path)
484
+ output_files.append(output_scene_path)
485
+ data["savedScene"] = output_scene_path
486
+ return data
487
+
488
+
489
+ def import_model_current(payload, warnings):
490
+ model_path = payload.get("modelPath")
326
491
  base_scene_path = payload.get("baseScenePath")
327
492
  if not model_path or not os.path.exists(model_path):
328
493
  raise RuntimeError("Model file not found: %s" % model_path)
329
- if not output_scene_path:
330
- raise RuntimeError("outputScenePath is required")
331
494
 
332
495
  if base_scene_path:
333
496
  if not os.path.exists(base_scene_path):
@@ -374,13 +537,10 @@ def import_model(payload, output_files, warnings):
374
537
  lambda: lux.importFile(model_path),
375
538
  lambda: lux.importFile(str(model_path)),
376
539
  )
377
- save_to(output_scene_path)
378
- output_files.append(output_scene_path)
379
540
  return {
380
541
  "importedModel": model_path,
381
542
  "baseScene": base_scene_path,
382
543
  "importOptions": requested_options,
383
- "savedScene": output_scene_path,
384
544
  }
385
545
 
386
546
 
@@ -407,7 +567,17 @@ def new_scene(warnings):
407
567
 
408
568
 
409
569
  def apply_material(payload, output_files, warnings):
570
+ data = apply_material_current(payload, warnings)
410
571
  output_scene_path = payload.get("outputScenePath")
572
+ if not output_scene_path:
573
+ raise RuntimeError("outputScenePath is required")
574
+ save_to(output_scene_path)
575
+ output_files.append(output_scene_path)
576
+ data["savedScene"] = output_scene_path
577
+ return data
578
+
579
+
580
+ def apply_material_current(payload, warnings):
411
581
  target = find_object(payload.get("objectName"), payload.get("objectPath"))
412
582
  if target is None:
413
583
  raise RuntimeError("Object not found. Provide a valid objectName or objectPath.")
@@ -420,16 +590,14 @@ def apply_material(payload, output_files, warnings):
420
590
  lambda: lux.setObjectMaterial(target, payload.get("materialPath")),
421
591
  )
422
592
 
423
- save_to(output_scene_path)
424
- output_files.append(output_scene_path)
425
593
  return {
426
594
  "object": describe_object(target),
427
595
  "material": serialize_value(material),
428
- "savedScene": output_scene_path,
596
+ "presetName": payload.get("presetName"),
429
597
  }
430
598
 
431
599
 
432
- def set_camera(payload, output_files, warnings):
600
+ def set_camera(payload, output_files, warnings, persist=True):
433
601
  output_scene_path = payload.get("outputScenePath")
434
602
  camera_name = payload.get("cameraName") or "MCP Camera"
435
603
  position = payload.get("position")
@@ -533,9 +701,7 @@ def set_camera(payload, output_files, warnings):
533
701
  except RuntimeError:
534
702
  warnings.append("The camera was updated, but this KeyShot version could not save the named view.")
535
703
 
536
- save_to(output_scene_path)
537
- output_files.append(output_scene_path)
538
- return {
704
+ data = {
539
705
  "cameraName": camera_name,
540
706
  "position": position,
541
707
  "lookAt": look_at,
@@ -543,11 +709,17 @@ def set_camera(payload, output_files, warnings):
543
709
  "distance": distance,
544
710
  "fieldOfView": field_of_view,
545
711
  "focalLength": focal_length,
546
- "savedScene": output_scene_path,
547
712
  }
713
+ if persist:
714
+ if not output_scene_path:
715
+ raise RuntimeError("outputScenePath is required")
716
+ save_to(output_scene_path)
717
+ output_files.append(output_scene_path)
718
+ data["savedScene"] = output_scene_path
719
+ return data
548
720
 
549
721
 
550
- def set_standard_camera(payload, output_files, warnings):
722
+ def set_standard_camera(payload, output_files, warnings, persist=True):
551
723
  output_scene_path = payload.get("outputScenePath")
552
724
  camera_name = payload.get("cameraName") or "MCP Camera"
553
725
  standard_view = str(payload.get("standardView") or "").lower()
@@ -586,16 +758,20 @@ def set_standard_camera(payload, output_files, warnings):
586
758
  lambda: lux.saveCamera(),
587
759
  lambda: lux.saveCamera(camera_name),
588
760
  )
589
- save_to(output_scene_path)
590
- output_files.append(output_scene_path)
591
- return {
761
+ data = {
592
762
  "cameraName": camera_name,
593
763
  "standardView": standard_view,
594
- "savedScene": output_scene_path,
595
764
  }
765
+ if persist:
766
+ if not output_scene_path:
767
+ raise RuntimeError("outputScenePath is required")
768
+ save_to(output_scene_path)
769
+ output_files.append(output_scene_path)
770
+ data["savedScene"] = output_scene_path
771
+ return data
596
772
 
597
773
 
598
- def set_environment(payload, output_files, warnings):
774
+ def set_environment(payload, output_files, warnings, persist=True):
599
775
  output_scene_path = payload.get("outputScenePath")
600
776
  environment_name = payload.get("environmentName")
601
777
  environment_path = payload.get("environmentPath")
@@ -616,6 +792,7 @@ def set_environment(payload, output_files, warnings):
616
792
  elif environment_name:
617
793
  call_variants(
618
794
  "set environment name",
795
+ lambda: lux.setActiveEnvironment(environment_name),
619
796
  lambda: lux.setEnvironment(environment_name),
620
797
  lambda: lux.setEnvironmentByName(environment_name),
621
798
  )
@@ -648,15 +825,19 @@ def set_environment(payload, output_files, warnings):
648
825
  if not changed:
649
826
  raise RuntimeError("No supported environment change was requested.")
650
827
 
651
- save_to(output_scene_path)
652
- output_files.append(output_scene_path)
653
- return {
828
+ data = {
654
829
  "environmentName": environment_name,
655
830
  "environmentPath": environment_path,
656
831
  "brightness": brightness,
657
832
  "rotation": rotation,
658
- "savedScene": output_scene_path,
659
833
  }
834
+ if persist:
835
+ if not output_scene_path:
836
+ raise RuntimeError("outputScenePath is required")
837
+ save_to(output_scene_path)
838
+ output_files.append(output_scene_path)
839
+ data["savedScene"] = output_scene_path
840
+ return data
660
841
 
661
842
 
662
843
  def active_environment():
package/server.json CHANGED
@@ -2,19 +2,19 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.truman-t3/keyshot-mcp",
4
4
  "title": "KeyShot MCP",
5
- "description": "Control local KeyShot product visualization, scene editing, cameras, environments, and rendering through headless scripting.",
5
+ "description": "Control local KeyShot scenes, cameras, environments, and renders through headless scripting.",
6
6
  "websiteUrl": "https://github.com/truman-t3/keyshot-mcp",
7
7
  "repository": {
8
8
  "id": "1290741614",
9
9
  "url": "https://github.com/truman-t3/keyshot-mcp",
10
10
  "source": "github"
11
11
  },
12
- "version": "0.7.0",
12
+ "version": "0.8.0",
13
13
  "packages": [
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "keyshot-mcp",
17
- "version": "0.7.0",
17
+ "version": "0.8.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
@@ -1,25 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="720" height="360" viewBox="0 0 720 360" role="img" aria-labelledby="title desc">
2
- <title id="title">Star History for truman-t3/keyshot-mcp</title>
3
- <desc id="desc">5 GitHub stars through 2026-07-20, plotted by the date each star was added.</desc>
4
- <style>
5
- .text { fill: #c9d1d9; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
6
- .title { fill: #f0f6fc; font: 700 26px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
7
- .muted { fill: #8b949e; font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
8
- </style>
9
- <rect fill="#0d1117" width="720" height="360" rx="16"/>
10
- <rect fill="#161b22" stroke="#30363d" x="32" y="32" width="656" height="296" rx="12"/>
11
- <text class="title" x="64" y="78">Star History</text>
12
- <text class="muted" x="64" y="104">truman-t3/keyshot-mcp</text>
13
- <rect fill="#21262d" stroke="#30363d" x="548" y="55" width="92" height="36" rx="18"/>
14
- <text class="text" x="571" y="79">&#9733; 5</text>
15
- <line stroke="#30363d" x1="88" y1="258" x2="632" y2="258"/>
16
- <line stroke="#30363d" x1="88" y1="128" x2="88" y2="258"/>
17
- <text class="muted" x="66" y="263">0</text>
18
- <text class="muted" x="66" y="136">5</text>
19
- <text class="muted" x="88" y="288">2026-07-06</text>
20
- <text class="muted" x="555" y="288">2026-07-20</text>
21
- <path fill="none" stroke="#58a6ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M 88 258 H 88.00 V 128.00 H 632"/>
22
- <circle fill="#58a6ff" stroke="#0d1117" stroke-width="3" cx="88.00" cy="128.00" r="6"/>
23
- <circle fill="#58a6ff" stroke="#0d1117" stroke-width="3" cx="632" cy="128.00" r="7"/>
24
- <text class="text" x="486" y="122">Current stars: 5</text>
25
- </svg>
@@ -1,25 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="720" height="360" viewBox="0 0 720 360" role="img" aria-labelledby="title desc">
2
- <title id="title">Star History for truman-t3/keyshot-mcp</title>
3
- <desc id="desc">5 GitHub stars through 2026-07-20, plotted by the date each star was added.</desc>
4
- <style>
5
- .text { fill: #24292f; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
6
- .title { fill: #1f2328; font: 700 26px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
7
- .muted { fill: #57606a; font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
8
- </style>
9
- <rect fill="#ffffff" width="720" height="360" rx="16"/>
10
- <rect fill="#f6f8fa" stroke="#d0d7de" x="32" y="32" width="656" height="296" rx="12"/>
11
- <text class="title" x="64" y="78">Star History</text>
12
- <text class="muted" x="64" y="104">truman-t3/keyshot-mcp</text>
13
- <rect fill="#ffffff" stroke="#d0d7de" x="548" y="55" width="92" height="36" rx="18"/>
14
- <text class="text" x="571" y="79">&#9733; 5</text>
15
- <line stroke="#d0d7de" x1="88" y1="258" x2="632" y2="258"/>
16
- <line stroke="#d0d7de" x1="88" y1="128" x2="88" y2="258"/>
17
- <text class="muted" x="66" y="263">0</text>
18
- <text class="muted" x="66" y="136">5</text>
19
- <text class="muted" x="88" y="288">2026-07-06</text>
20
- <text class="muted" x="555" y="288">2026-07-20</text>
21
- <path fill="none" stroke="#0969da" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M 88 258 H 88.00 V 128.00 H 632"/>
22
- <circle fill="#0969da" stroke="#ffffff" stroke-width="3" cx="88.00" cy="128.00" r="6"/>
23
- <circle fill="#0969da" stroke="#ffffff" stroke-width="3" cx="632" cy="128.00" r="7"/>
24
- <text class="text" x="486" y="122">Current stars: 5</text>
25
- </svg>