keyshot-mcp 0.2.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/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,56 +43,77 @@ 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,
43
50
  resultPath,
44
51
  ];
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"));
52
+ try {
53
+ const processResult = await spawnWithTimeout(config.keyshotHeadlessExe, args, config.keyshotTimeoutMs);
54
+ const stdoutTail = tail(processResult.stdout, 6000);
55
+ const stderrTail = tail(processResult.stderr, 6000);
56
+ let parsed = null;
57
+ if (await exists(resultPath)) {
58
+ try {
59
+ parsed = JSON.parse(await fs.readFile(resultPath, "utf8"));
60
+ }
61
+ catch (error) {
62
+ return localFailure(`Could not parse KeyShot result JSON: ${errorMessage(error)}`, {
63
+ keyshotStdoutTail: stdoutTail,
64
+ warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
65
+ });
66
+ }
52
67
  }
53
- catch (error) {
54
- return localFailure(`Could not parse KeyShot result JSON: ${errorMessage(error)}`, {
68
+ if (processResult.spawnError) {
69
+ return localFailure(`Could not start KeyShot headless: ${processResult.spawnError}`, {
55
70
  keyshotStdoutTail: stdoutTail,
56
71
  warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
57
72
  });
58
73
  }
74
+ if (processResult.timedOut) {
75
+ return localFailure(`KeyShot timed out after ${config.keyshotTimeoutMs}ms`, {
76
+ data: parsed?.data ?? null,
77
+ outputFiles: parsed?.outputFiles ?? [],
78
+ warnings: [...(parsed?.warnings ?? []), ...(stderrTail ? [`stderr: ${stderrTail}`] : [])],
79
+ keyshotStdoutTail: stdoutTail,
80
+ });
81
+ }
82
+ if (!parsed) {
83
+ return localFailure(`KeyShot did not produce a result file. Exit code: ${processResult.exitCode ?? "unknown"}`, {
84
+ keyshotStdoutTail: stdoutTail,
85
+ warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
86
+ });
87
+ }
88
+ parsed.keyshotStdoutTail = stdoutTail || parsed.keyshotStdoutTail || "";
89
+ if (stderrTail)
90
+ parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
91
+ if (processResult.exitCode !== 0 && parsed.ok) {
92
+ return {
93
+ ...parsed,
94
+ ok: false,
95
+ error: `KeyShot exited with code ${processResult.exitCode}`,
96
+ };
97
+ }
98
+ return parsed;
59
99
  }
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
- };
100
+ finally {
101
+ // Best-effort cleanup so work/tmp does not accumulate args/result files.
102
+ await cleanupTmp([argsPath, resultPath]).catch(() => undefined);
83
103
  }
84
- return parsed;
85
104
  }
86
- function spawnWithTimeout(command, args, timeoutMs) {
105
+ async function cleanupTmp(paths) {
106
+ await Promise.all(paths.map(async (filePath) => {
107
+ if (await exists(filePath)) {
108
+ await fs.rm(filePath, { force: true });
109
+ }
110
+ }));
111
+ }
112
+ export const MAX_CAPTURE_CHARS = 64 * 1024;
113
+ export function spawnWithTimeout(command, args, timeoutMs) {
87
114
  return new Promise((resolve) => {
88
115
  const child = spawn(command, args, {
116
+ detached: process.platform !== "win32",
89
117
  windowsHide: true,
90
118
  stdio: ["ignore", "pipe", "pipe"],
91
119
  });
@@ -93,30 +121,64 @@ function spawnWithTimeout(command, args, timeoutMs) {
93
121
  let stderr = "";
94
122
  let settled = false;
95
123
  let timedOut = false;
124
+ let spawnError = null;
96
125
  const timer = setTimeout(() => {
97
126
  timedOut = true;
98
- child.kill("SIGTERM");
127
+ terminateProcessTree(child.pid, "SIGTERM");
99
128
  setTimeout(() => {
100
- if (!settled)
101
- child.kill("SIGKILL");
129
+ if (!settled && process.platform !== "win32")
130
+ terminateProcessTree(child.pid, "SIGKILL");
102
131
  }, 2500).unref();
103
132
  }, timeoutMs);
104
133
  child.stdout?.on("data", (chunk) => {
105
- stdout += String(chunk);
134
+ stdout = appendBounded(stdout, String(chunk));
106
135
  });
107
136
  child.stderr?.on("data", (chunk) => {
108
- stderr += String(chunk);
137
+ stderr = appendBounded(stderr, String(chunk));
109
138
  });
110
139
  child.on("error", (error) => {
111
- stderr += errorMessage(error);
140
+ spawnError = errorMessage(error);
141
+ stderr = appendBounded(stderr, spawnError);
112
142
  });
113
143
  child.on("close", (exitCode) => {
114
144
  settled = true;
115
145
  clearTimeout(timer);
116
- resolve({ exitCode, stdout, stderr, timedOut });
146
+ resolve({ exitCode, stdout, stderr, timedOut, spawnError });
117
147
  });
118
148
  });
119
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
+ }
120
182
  async function exists(filePath) {
121
183
  try {
122
184
  await fs.access(filePath);
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." });
@@ -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.2.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",
@@ -19,7 +20,9 @@
19
20
  "prepare": "tsc -p tsconfig.json",
20
21
  "start": "node ./dist/index.js",
21
22
  "check": "tsc -p tsconfig.json --noEmit",
22
- "status": "node ./dist/cli/status.js"
23
+ "test": "vitest run",
24
+ "status": "node ./dist/cli/status.js",
25
+ "smoke:keyshot": "npm run build && node ./scripts/keyshot-smoke.mjs"
23
26
  },
24
27
  "keywords": [
25
28
  "mcp",
@@ -32,7 +35,11 @@
32
35
  "files": [
33
36
  "dist",
34
37
  "scripts/keyshot_bridge.py",
38
+ "scripts/keyshot-smoke.mjs",
35
39
  "examples",
40
+ "assets/demo",
41
+ "presets",
42
+ "server.json",
36
43
  "README.md",
37
44
  "CONTRIBUTING.md",
38
45
  "SECURITY.md",
@@ -42,12 +49,14 @@
42
49
  "engines": {
43
50
  "node": ">=20"
44
51
  },
52
+ "packageManager": "pnpm@10.17.1",
45
53
  "dependencies": {
46
54
  "@modelcontextprotocol/sdk": "^1.18.1",
47
55
  "zod": "^3.25.76"
48
56
  },
49
57
  "devDependencies": {
50
58
  "@types/node": "^22.17.2",
51
- "typescript": "^5.9.2"
59
+ "typescript": "^5.9.2",
60
+ "vitest": "^3.2.4"
52
61
  }
53
62
  }
@@ -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
+ }
@@ -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));