keyshot-mcp 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,7 +67,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
67
67
  npm install -g keyshot-mcp
68
68
  ```
69
69
 
70
- The current release line is `0.4.1`. The published package is the easiest option
70
+ The current release line is `0.4.2`. The published package is the easiest option
71
71
  when you only want to use the MCP server. You still need KeyShot Studio and a
72
72
  local KeyShot license.
73
73
 
@@ -78,21 +78,41 @@ npm install
78
78
  npm run build
79
79
  ```
80
80
 
81
+ ### Three MCP configuration options
82
+
83
+ Use exactly one of these approaches:
84
+
85
+ **Global npm installation** (`npm install -g keyshot-mcp`):
86
+
87
+ ```json
88
+ {"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
89
+ ```
90
+
91
+ **No installation, run with npx**:
92
+
93
+ ```json
94
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
95
+ ```
96
+
97
+ **Run from a cloned source directory** (after `npm install && npm run build`):
98
+
99
+ ```json
100
+ {"mcpServers":{"keyshot":{"command":"node","args":["/absolute/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
101
+ ```
102
+
81
103
  ## Copy-Paste Setup Prompt for an Agent
82
104
 
83
105
  If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
84
106
 
85
107
  ```text
86
- Install the KeyShot MCP server from https://github.com/truman-t3/keyshot-mcp.
108
+ Install KeyShot MCP 0.4.2 from npm and configure it in my MCP client.
87
109
 
88
110
  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.
111
+ 1. Find my KeyShot headless executable path.
112
+ 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.4.2.
113
+ 3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
114
+ 4. Test the setup by running the keyshot_status tool.
115
+ 5. Tell me the exact config that was added and whether the status check passed.
96
116
 
97
117
  Do not store license keys or account passwords. Use my existing local KeyShot license configuration.
98
118
  ```
@@ -188,6 +208,11 @@ save the scene to a new file, and render one preview from that camera.
188
208
  - `KEYSHOT_OUTPUT_DIR`: default output folder for renders.
189
209
  - `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
190
210
  - `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
211
+ - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`: allow output paths outside `KEYSHOT_OUTPUT_DIR`. Default: `false`.
212
+
213
+ By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
214
+ are placed there automatically. Set the compatibility switch to `true` only when
215
+ an external output location is intentional; input scenes and models are unrestricted.
191
216
 
192
217
  `samples` and `maxTimeSeconds` select different KeyShot render modes. Provide one
193
218
  or the other, not both.
@@ -248,11 +273,22 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
248
273
  - [x] Material preset library
249
274
  - [ ] Camera preset templates
250
275
  - [x] Sequential render queue
251
- - [ ] Safer output directory restrictions
276
+ - [x] Safer output directory restrictions
252
277
  - [ ] More tested KeyShot versions
253
278
  - [ ] macOS compatibility verification
254
279
  - [x] Claude Desktop / Cursor / Codex config examples
255
280
 
281
+ ## Real KeyShot demo
282
+
283
+ ![KeyShot MCP generated cube demo](assets/demo/keyshot-mcp-demo.png)
284
+
285
+ The demo uses only the generated geometry in `examples/demo`. To reproduce the
286
+ status, import, inspect, camera, save, and PNG-render workflow locally:
287
+
288
+ ```bash
289
+ npm run smoke:keyshot
290
+ ```
291
+
256
292
  ## License
257
293
 
258
294
  MIT
@@ -324,7 +360,7 @@ MIT
324
360
  npm install -g keyshot-mcp
325
361
  ```
326
362
 
327
- 当前发布版本为 `0.4.1`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
363
+ 当前发布版本为 `0.4.2`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
328
364
  电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
329
365
 
330
366
  ### 从源码安装
@@ -334,21 +370,41 @@ npm install
334
370
  npm run build
335
371
  ```
336
372
 
373
+ ### 三种独立配置方式
374
+
375
+ 下面三种方式选择一种即可:
376
+
377
+ **全局 npm 安装**(先运行 `npm install -g keyshot-mcp`):
378
+
379
+ ```json
380
+ {"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
381
+ ```
382
+
383
+ **免安装,直接使用 npx**:
384
+
385
+ ```json
386
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
387
+ ```
388
+
389
+ **使用本地源码**(先运行 `npm install && npm run build`):
390
+
391
+ ```json
392
+ {"mcpServers":{"keyshot":{"command":"node","args":["C:/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
393
+ ```
394
+
337
395
  ## 复制给 Agent 的安装提示词
338
396
 
339
397
  如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
340
398
 
341
399
  ```text
342
- 请帮我安装 KeyShot MCP Server:https://github.com/truman-t3/keyshot-mcp。
400
+ 请帮我安装 KeyShot MCP 0.4.2,并添加到我的 MCP 客户端。
343
401
 
344
402
  请你:
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. 最后告诉我实际添加的配置,以及状态检查是否通过。
403
+ 1. 查找我电脑上的 KeyShot headless 可执行文件路径。
404
+ 2. 使用 npx -y keyshot-mcp@0.4.2 添加 keyshot MCP server
405
+ 3. KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
406
+ 4. keyshot_status 工具测试是否配置成功。
407
+ 5. 最后告诉我实际添加的配置,以及状态检查是否通过。
352
408
 
353
409
  不要保存许可证密钥、账号密码或授权信息。直接使用我电脑上已有的 KeyShot 本地授权配置。
354
410
  ```
@@ -445,6 +501,10 @@ examples/codex.example.json
445
501
  - `KEYSHOT_LICENSE_ARGS`:可选的 KeyShot 无界面许可证参数,默认留空。
446
502
  - `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
447
503
  - `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
504
+ - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
505
+
506
+ 默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
507
+ 只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
448
508
 
449
509
  `samples` 和 `maxTimeSeconds` 是两种不同的 KeyShot 渲染模式,请二选一,不要同时传入。
450
510
 
@@ -503,11 +563,22 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
503
563
  - [x] 材质预设库
504
564
  - [ ] 相机预设模板
505
565
  - [x] 顺序渲染队列
506
- - [ ] 更安全的输出目录限制
566
+ - [x] 更安全的输出目录限制
507
567
  - [ ] 测试更多 KeyShot 版本
508
568
  - [ ] 验证 macOS 兼容性
509
569
  - [x] 补充 Claude Desktop / Cursor / Codex 配置示例
510
570
 
571
+ ## 真实 KeyShot Demo
572
+
573
+ ![KeyShot MCP 自动生成的立方体 Demo](assets/demo/keyshot-mcp-demo.png)
574
+
575
+ Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
576
+ 设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建相机、保存和真实 PNG 渲染:
577
+
578
+ ```bash
579
+ npm run smoke:keyshot
580
+ ```
581
+
511
582
  ## 测试
512
583
 
513
584
  项目带了两套测试,不依赖真实的 KeyShot 也能跑:
Binary file
package/dist/config.js CHANGED
@@ -9,6 +9,7 @@ export function getConfig() {
9
9
  projectRoot,
10
10
  keyshotHeadlessExe: process.env.KEYSHOT_HEADLESS_EXE ?? DEFAULT_KEYSHOT_EXE,
11
11
  keyshotOutputDir,
12
+ keyshotAllowExternalOutputs: parseBoolean(process.env.KEYSHOT_ALLOW_EXTERNAL_OUTPUTS),
12
13
  keyshotLicenseArgs: splitWindowsArgs(process.env.KEYSHOT_LICENSE_ARGS ?? ""),
13
14
  keyshotTimeoutMs: parsePositiveInt(process.env.KEYSHOT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
14
15
  tmpDir: path.join(projectRoot, "work", "tmp"),
@@ -16,6 +17,9 @@ export function getConfig() {
16
17
  materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
17
18
  };
18
19
  }
20
+ function parseBoolean(value) {
21
+ return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
22
+ }
19
23
  function parsePositiveInt(value, fallback) {
20
24
  if (!value)
21
25
  return fallback;
package/dist/index.js CHANGED
@@ -6,11 +6,12 @@ import { toolResponse, localFailure } from "./result.js";
6
6
  import { runKeyShotSerialized } from "./runner.js";
7
7
  import { runRenderQueue } from "./queue.js";
8
8
  import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
9
+ import { VERSION } from "./version.js";
9
10
  import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
10
11
  const config = getConfig();
11
12
  const server = new McpServer({
12
13
  name: "keyshot-mcp",
13
- version: "0.4.1",
14
+ version: VERSION,
14
15
  });
15
16
  function errorMessage(error) {
16
17
  return error instanceof Error ? error.message : String(error);
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const OUTPUT_FIELDS = ["outputPath", "outputDir", "outputScenePath"];
4
+ export async function normalizeOutputPaths(config, request) {
5
+ await fs.mkdir(config.keyshotOutputDir, { recursive: true });
6
+ const normalized = { ...request };
7
+ for (const field of OUTPUT_FIELDS) {
8
+ const value = request[field];
9
+ if (typeof value !== "string" || value.length === 0)
10
+ continue;
11
+ normalized[field] = await normalizeOutputPath(config, value);
12
+ }
13
+ return normalized;
14
+ }
15
+ export async function normalizeOutputPath(config, value) {
16
+ const candidate = path.resolve(config.keyshotOutputDir, value);
17
+ if (config.keyshotAllowExternalOutputs)
18
+ return candidate;
19
+ const outputRoot = await realpathWithMissingTail(config.keyshotOutputDir);
20
+ const resolvedCandidate = await realpathWithMissingTail(candidate);
21
+ if (!isWithin(outputRoot, resolvedCandidate)) {
22
+ throw new Error(`Output path must stay inside KEYSHOT_OUTPUT_DIR (${config.keyshotOutputDir}): ${value}. ` +
23
+ "Set KEYSHOT_ALLOW_EXTERNAL_OUTPUTS=true only if external writes are intentional.");
24
+ }
25
+ return candidate;
26
+ }
27
+ async function realpathWithMissingTail(inputPath) {
28
+ let current = path.resolve(inputPath);
29
+ const missing = [];
30
+ while (!(await exists(current))) {
31
+ const parent = path.dirname(current);
32
+ if (parent === current)
33
+ break;
34
+ missing.unshift(path.basename(current));
35
+ current = parent;
36
+ }
37
+ const realBase = await fs.realpath(current);
38
+ return path.resolve(realBase, ...missing);
39
+ }
40
+ function isWithin(root, candidate) {
41
+ const relative = path.relative(normalizeCase(root), normalizeCase(candidate));
42
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
43
+ }
44
+ function normalizeCase(value) {
45
+ return process.platform === "win32" ? value.toLowerCase() : value;
46
+ }
47
+ async function exists(value) {
48
+ try {
49
+ await fs.lstat(value);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
package/dist/runner.js CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { localFailure } from "./result.js";
6
+ import { normalizeOutputPaths } from "./output-paths.js";
6
7
  let queue = Promise.resolve();
7
8
  export function runKeyShotSerialized(config, request) {
8
9
  const run = queue.then(() => runKeyShot(config, request));
@@ -10,8 +11,7 @@ export function runKeyShotSerialized(config, request) {
10
11
  return run;
11
12
  }
12
13
  async function runKeyShot(config, request) {
13
- 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);
@@ -0,0 +1 @@
1
+ export const VERSION = "0.4.2";
@@ -0,0 +1,9 @@
1
+ # KeyShot smoke-test demo
2
+
3
+ `keyshot-mcp-cube.obj` is original, generated geometry included under this
4
+ repository's MIT license. It contains no customer model or third-party asset.
5
+
6
+ Run `npm run smoke:keyshot` after setting `KEYSHOT_HEADLESS_EXE`. The command
7
+ checks KeyShot, imports the cube, saves and inspects the scene, creates an MCP
8
+ camera, and renders `outputs/demo/keyshot-mcp-demo.png`. Generated `.bip` files
9
+ remain under `outputs/` and are ignored by Git.
@@ -0,0 +1,23 @@
1
+ # KeyShot MCP generated demo cube
2
+ # Original geometry released under the repository MIT license.
3
+ o KeyShot_MCP_Cube
4
+ v -1.0 -1.0 -1.0
5
+ v 1.0 -1.0 -1.0
6
+ v 1.0 1.0 -1.0
7
+ v -1.0 1.0 -1.0
8
+ v -1.0 -1.0 1.0
9
+ v 1.0 -1.0 1.0
10
+ v 1.0 1.0 1.0
11
+ v -1.0 1.0 1.0
12
+ vn 0.0 0.0 -1.0
13
+ vn 0.0 0.0 1.0
14
+ vn 0.0 -1.0 0.0
15
+ vn 0.0 1.0 0.0
16
+ vn -1.0 0.0 0.0
17
+ vn 1.0 0.0 0.0
18
+ f 1//1 4//1 3//1 2//1
19
+ f 5//2 6//2 7//2 8//2
20
+ f 1//3 2//3 6//3 5//3
21
+ f 4//4 8//4 7//4 3//4
22
+ f 1//5 5//5 8//5 4//5
23
+ f 2//6 3//6 7//6 6//6
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "keyshot-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
+ "mcpName": "io.github.truman-t3/keyshot-mcp",
4
5
  "description": "Local and portable MCP server for KeyShot headless scripting.",
5
6
  "type": "module",
6
7
  "homepage": "https://github.com/truman-t3/keyshot-mcp#readme",
@@ -20,7 +21,8 @@
20
21
  "start": "node ./dist/index.js",
21
22
  "check": "tsc -p tsconfig.json --noEmit",
22
23
  "test": "vitest run",
23
- "status": "node ./dist/cli/status.js"
24
+ "status": "node ./dist/cli/status.js",
25
+ "smoke:keyshot": "npm run build && node ./scripts/keyshot-smoke.mjs"
24
26
  },
25
27
  "keywords": [
26
28
  "mcp",
@@ -33,8 +35,11 @@
33
35
  "files": [
34
36
  "dist",
35
37
  "scripts/keyshot_bridge.py",
38
+ "scripts/keyshot-smoke.mjs",
36
39
  "examples",
40
+ "assets/demo",
37
41
  "presets",
42
+ "server.json",
38
43
  "README.md",
39
44
  "CONTRIBUTING.md",
40
45
  "SECURITY.md",
@@ -44,6 +49,7 @@
44
49
  "engines": {
45
50
  "node": ">=20"
46
51
  },
52
+ "packageManager": "pnpm@10.17.1",
47
53
  "dependencies": {
48
54
  "@modelcontextprotocol/sdk": "^1.18.1",
49
55
  "zod": "^3.25.76"
@@ -0,0 +1,67 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { getConfig } from "../dist/config.js";
4
+ import { runKeyShotSerialized } from "../dist/runner.js";
5
+
6
+ const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
7
+ const config = getConfig();
8
+ const modelPath = path.join(repoRoot, "examples", "demo", "keyshot-mcp-cube.obj");
9
+
10
+ async function run(label, request) {
11
+ process.stdout.write(`${label}... `);
12
+ const result = await runKeyShotSerialized(config, request);
13
+ if (!result.ok) {
14
+ process.stdout.write("failed\n");
15
+ throw new Error(`${label}: ${result.error}\n${result.warnings.join("\n")}`);
16
+ }
17
+ process.stdout.write("ok\n");
18
+ return result;
19
+ }
20
+
21
+ const status = await run("1/5 KeyShot status", { operation: "status" });
22
+ const imported = await run("2/5 Import and save generated OBJ", {
23
+ operation: "import_model",
24
+ modelPath,
25
+ outputScenePath: "demo/keyshot-mcp-demo-import.bip",
26
+ });
27
+ const importedScene = imported.outputFiles[0];
28
+ if (!importedScene) throw new Error("Import did not return a saved scene path.");
29
+
30
+ const inspected = await run("3/5 Inspect imported scene", {
31
+ operation: "inspect_scene",
32
+ scenePath: importedScene,
33
+ });
34
+ const objects = inspected.data?.objects;
35
+ if (!Array.isArray(objects) || objects.length === 0) {
36
+ throw new Error("Imported scene did not contain any inspectable objects.");
37
+ }
38
+
39
+ const cameraSceneResult = await run("4/5 Create camera and save scene", {
40
+ operation: "set_camera",
41
+ scenePath: importedScene,
42
+ cameraName: "MCP Demo",
43
+ position: [4.5, 3.5, 4.5],
44
+ lookAt: [0, 0, 0],
45
+ up: [0, 1, 0],
46
+ outputScenePath: "demo/keyshot-mcp-demo-camera.bip",
47
+ });
48
+ const cameraScene = cameraSceneResult.outputFiles[0];
49
+ if (!cameraScene) throw new Error("Camera operation did not return a saved scene path.");
50
+
51
+ const rendered = await run("5/5 Render real PNG", {
52
+ operation: "render",
53
+ scenePath: cameraScene,
54
+ camera: "MCP Demo",
55
+ outputPath: "demo/keyshot-mcp-demo.png",
56
+ width: 640,
57
+ height: 480,
58
+ maxTimeSeconds: 8,
59
+ format: "png",
60
+ });
61
+
62
+ console.log(JSON.stringify({
63
+ keyshotVersion: status.data?.version ?? null,
64
+ objectCount: objects.length,
65
+ scenePath: cameraScene,
66
+ imagePath: rendered.outputFiles[0] ?? null,
67
+ }, null, 2));
@@ -307,12 +307,17 @@ def set_camera(payload, output_files, warnings):
307
307
 
308
308
  if camera is None and hasattr(lux, "saveCamera"):
309
309
  try:
310
- camera = first_camera_object(
310
+ # Current KeyShot versions expose cameras as names rather than camera
311
+ # objects. saveCamera creates the named camera and setCamera activates it.
312
+ call_variants(
313
+ "create named camera",
311
314
  lambda: lux.saveCamera(camera_name),
312
315
  lambda: lux.saveCamera(),
313
316
  )
317
+ if hasattr(lux, "setCamera"):
318
+ call_variants("activate camera", lambda: lux.setCamera(camera_name))
314
319
  except RuntimeError:
315
- camera = None
320
+ pass
316
321
 
317
322
  if camera is not None:
318
323
  # Object-level API is available: drive the camera object directly.
@@ -325,18 +330,21 @@ def set_camera(payload, output_files, warnings):
325
330
  # camera object, so it cannot crash when camera creation is unsupported.
326
331
  call_variants(
327
332
  "set camera position",
328
- lambda: lux.setCameraPosition(camera_name, tuple(position)),
333
+ lambda: lux.setCameraPosition(pos=tuple(position)),
329
334
  lambda: lux.setCameraPosition(tuple(position)),
335
+ lambda: lux.setCameraPosition(camera_name, tuple(position)),
330
336
  )
331
337
  call_variants(
332
338
  "set camera look-at",
339
+ lambda: lux.setCameraLookAt(pt=tuple(look_at)),
340
+ lambda: lux.setCameraLookAt(0, tuple(look_at)),
333
341
  lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
334
- lambda: lux.setCameraLookAt(tuple(look_at)),
335
342
  )
336
343
  call_variants(
337
344
  "set camera up",
338
- lambda: lux.setCameraUp(camera_name, tuple(up)),
345
+ lambda: lux.setCameraUp(up=tuple(up)),
339
346
  lambda: lux.setCameraUp(tuple(up)),
347
+ lambda: lux.setCameraUp(camera_name, tuple(up)),
340
348
  )
341
349
 
342
350
  save_to(output_scene_path)
package/server.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.truman-t3/keyshot-mcp",
4
+ "title": "KeyShot MCP",
5
+ "description": "Control KeyShot Studio through local headless scripting with MCP.",
6
+ "websiteUrl": "https://github.com/truman-t3/keyshot-mcp",
7
+ "repository": {
8
+ "id": "1290741614",
9
+ "url": "https://github.com/truman-t3/keyshot-mcp",
10
+ "source": "github"
11
+ },
12
+ "version": "0.4.2",
13
+ "packages": [
14
+ {
15
+ "registryType": "npm",
16
+ "identifier": "keyshot-mcp",
17
+ "version": "0.4.2",
18
+ "transport": {
19
+ "type": "stdio"
20
+ },
21
+ "environmentVariables": [
22
+ {
23
+ "name": "KEYSHOT_HEADLESS_EXE",
24
+ "description": "Absolute path to KeyShot headless, or a command available on PATH.",
25
+ "isRequired": true,
26
+ "isSecret": false
27
+ }
28
+ ]
29
+ }
30
+ ]
31
+ }