@craftstory/mcp 0.1.0 → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2 - 2026-09-12
4
+ - package metadata: public repository and issue tracker on GitHub (itseez3d/craftstory-mcp); CHANGELOG shipped
5
+ - wait_for_job: every request inside the wait is capped by the time left, so the call really returns within timeout_s (+ a few seconds for the result fetch)
6
+ - create_audio_clip rejects ambiguous input (both voice ids, or a file together with text)
7
+ - image_path / file_path accept ~/ and are documented as absolute paths
8
+ - CRAFTSTORY_API_BASE must be https unless CRAFTSTORY_ALLOW_HTTP=1
9
+ - Node >= 20 (matches the dependency tree); prepack builds; SECURITY.md; GitHub Actions CI
10
+
11
+ ## 0.1.1 - 2026-09-12
12
+ - wait_for_job: default 45 s, max 55 s, deadline-aware polling (stays under the 60 s tool-call limit of most MCP clients)
13
+ - every API request has a 25 s HTTP timeout; network errors report their cause
14
+ - removed an internal repository URL from package metadata (0.1.0 is deprecated for that reason)
15
+
16
+ ## 0.1.0 - 2026-09-12
17
+ - first release: 11 tools over the CraftStory public API (models, voices, avatars, audio clips, cost preview,
18
+ CraftStory 2.0 and MiniMax H3 generation, status, result, bounded wait, upscale) and one prompt
package/README.md CHANGED
@@ -5,19 +5,21 @@ Generate talking-avatar videos from Claude, Cursor, Claude Code or any other [MC
5
5
  - **CraftStory 2.0** - a talking video of any length from one photo plus an audio clip (script + voice, your own recording, or a custom avatar). 8-15 minutes per video.
6
6
  - **MiniMax H3** - a clip of up to 15 s from one photo: description-driven with generated sound, or audio-driven with lip-sync. 1-3 minutes.
7
7
 
8
- You need a CraftStory account on a plan with API access and an API key (app: **Account -> API Access**, keys look like `sk-cs-...`). Generations are billed in credits exactly like in the app; failed jobs are refunded.
8
+ You need Node.js 20 or newer, a CraftStory account on a plan with API access, and an API key (app: **Account -> API Access**, keys look like `sk-cs-...`). Generations are billed in credits exactly like in the app; failed jobs are refunded.
9
9
 
10
10
  ## Install
11
11
 
12
12
  ### Claude Code
13
13
 
14
14
  ```bash
15
- claude mcp add craftstory -e CRAFTSTORY_API_KEY=sk-cs-... -- npx -y @craftstory/mcp
15
+ claude mcp add --scope user craftstory -e CRAFTSTORY_API_KEY=sk-cs-... -- npx -y @craftstory/mcp
16
16
  ```
17
17
 
18
+ (`--scope user` makes it available in every project; drop it to install for the current project only.)
19
+
18
20
  ### Claude Desktop
19
21
 
20
- Add to `claude_desktop_config.json` (Settings -> Developer -> Edit Config):
22
+ Add to `claude_desktop_config.json` (Settings -> Developer -> Edit Config), then restart Claude Desktop; the server shows up under the tools icon:
21
23
 
22
24
  ```json
23
25
  {
@@ -33,9 +35,9 @@ Add to `claude_desktop_config.json` (Settings -> Developer -> Edit Config):
33
35
 
34
36
  ### Cursor / other clients
35
37
 
36
- Same shape in `.cursor/mcp.json` (or the client's MCP config): command `npx`, args `["-y", "@craftstory/mcp"]`, env `CRAFTSTORY_API_KEY`.
38
+ Same shape in `~/.cursor/mcp.json` (user-level, so the key never lands in a repository): command `npx`, args `["-y", "@craftstory/mcp"]`, env `CRAFTSTORY_API_KEY`. In a project-level `.cursor/mcp.json` prefer `"CRAFTSTORY_API_KEY": "${env:CRAFTSTORY_API_KEY}"` and keep the real key in your shell environment.
37
39
 
38
- Environment variables: `CRAFTSTORY_API_KEY` (required), `CRAFTSTORY_API_BASE` (optional, default `https://api.craftstory.com/api/v1`).
40
+ Environment variables: `CRAFTSTORY_API_KEY` (required), `CRAFTSTORY_API_BASE` (optional, default `https://api.craftstory.com/api/v1`; must be https unless `CRAFTSTORY_ALLOW_HTTP=1`).
39
41
 
40
42
  ## Tools
41
43
 
@@ -57,22 +59,23 @@ Plus the prompt `talking_video_from_photo` (script + photo) that walks the model
57
59
 
58
60
  ## Example
59
61
 
60
- > Make a 15-second portrait video of the person in `~/photos/anna.jpg` saying "Welcome to our spring collection", calm gestures.
62
+ > Make a portrait video of the person in `/Users/me/photos/portrait.jpg` saying "Welcome to our spring collection", calm gestures.
61
63
 
62
64
  The assistant will: `list_voices` -> `create_audio_clip` -> `wait_for_job(audio-clip)` -> `preview_cost` -> `create_craftstory2_video` (resolution `720_1280`, gestures `calm`) -> `wait_for_job(craftstory-2)` a few times -> `get_job_result` -> the video URL.
63
65
 
64
- Long jobs: `wait_for_job` never blocks longer than `timeout_s` (max 55 s, under the 60 s tool-call limit of most clients). A CraftStory 2.0 video needs several calls; that is by design so agent runtimes do not time out.
66
+ Long jobs: `wait_for_job` returns within `timeout_s` (default 45 s, max 55 s) plus a few seconds for the final result fetch; every request inside it is capped by the time left, so it stays under the 60 s tool-call limit of most clients. A CraftStory 2.0 video needs several calls; that is by design so agent runtimes do not time out. A CraftStory 2.0 video needs several calls; that is by design so agent runtimes do not time out.
65
67
 
66
68
  ## Local files vs URLs
67
69
 
68
- Photos accept `image_url` or `image_path`; recordings and extra references are local paths (uploaded as multipart). Photos up to 20 MB (JPG/PNG/HEIC), audio WAV/MP3/M4A.
70
+ Photos accept `image_url` (JPG/PNG) or `image_path` (absolute path or `~/...`; JPG/PNG/HEIC, up to 20 MB, uploaded as multipart). Recordings and extra references are local paths too. Anything you pass as a path is uploaded to the CraftStory API, so keep your client's tool-approval prompts on (see SECURITY.md).
69
71
 
70
72
  ## Development
71
73
 
72
74
  ```bash
73
75
  npm install
74
76
  npm run build
75
- CRAFTSTORY_API_KEY=sk-cs-... npm run smoke # live check over stdio (add -- --h3 to also render a 5 s H3 clip)
77
+ CRAFTSTORY_API_KEY=sk-cs-... npm run smoke # live check over stdio: lists tools, makes an audio clip, previews cost
78
+ # add "-- --h3" and SMOKE_IMAGE=/path/to/photo.jpg to also render a 5 s MiniMax H3 clip (costs 17 credits)
76
79
  npm test
77
80
  ```
78
81
 
package/dist/client.js CHANGED
@@ -3,9 +3,12 @@
3
3
  * Every method maps 1:1 onto a documented endpoint; no business logic lives here.
4
4
  */
5
5
  import { readFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
6
7
  import { basename } from "node:path";
7
8
  export const DEFAULT_BASE = "https://api.craftstory.com/api/v1";
8
- export const USER_AGENT = "craftstory-mcp/0.1.0";
9
+ export const USER_AGENT = "craftstory-mcp/0.1.2";
10
+ /** Per-request HTTP timeout; keeps every tool call well under MCP clients' ~60 s limit. */
11
+ export const REQUEST_TIMEOUT_MS = 25_000;
9
12
  export class ApiError extends Error {
10
13
  status;
11
14
  body;
@@ -22,8 +25,9 @@ export async function fileOrUrl(input, field) {
22
25
  if (input.url)
23
26
  return { url: input.url };
24
27
  if (input.path) {
25
- const bytes = await readFile(input.path);
26
- return { blob: new Blob([bytes]), name: basename(input.path) };
28
+ const path = input.path.startsWith("~/") ? homedir() + input.path.slice(1) : input.path;
29
+ const bytes = await readFile(path);
30
+ return { blob: new Blob([bytes]), name: basename(path) };
27
31
  }
28
32
  throw new Error(`${field}: a URL or a local file path is required`);
29
33
  }
@@ -34,9 +38,14 @@ export class CraftStoryClient {
34
38
  constructor(opts) {
35
39
  this.opts = opts;
36
40
  this.base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
41
+ // The bearer key and uploads travel to this host: plaintext HTTP only on explicit request.
42
+ if (!this.base.startsWith("https://") && process.env.CRAFTSTORY_ALLOW_HTTP !== "1") {
43
+ throw new Error(`CRAFTSTORY_API_BASE must use https:// (got ${this.base}); set CRAFTSTORY_ALLOW_HTTP=1 to override for local testing`);
44
+ }
37
45
  this.fetchImpl = opts.fetchImpl ?? fetch;
38
46
  }
39
- async request(method, path, body) {
47
+ /** timeoutMs caps this one request (default REQUEST_TIMEOUT_MS); wait_for_job passes its remaining budget. */
48
+ async request(method, path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
40
49
  const headers = { Authorization: `Bearer ${this.opts.apiKey}`, "User-Agent": USER_AGENT, Accept: "application/json" };
41
50
  let payload;
42
51
  if (body instanceof FormData)
@@ -47,10 +56,12 @@ export class CraftStoryClient {
47
56
  }
48
57
  let res;
49
58
  try {
50
- res = await this.fetchImpl(`${this.base}${path}`, { method, headers, body: payload });
59
+ res = await this.fetchImpl(`${this.base}${path}`, { method, headers, body: payload, signal: AbortSignal.timeout(Math.max(1000, timeoutMs)) });
51
60
  }
52
61
  catch (e) {
53
62
  // Node's fetch hides the reason behind "fetch failed"; surface the cause (DNS, TLS, reset...).
63
+ if (e.name === "TimeoutError")
64
+ throw new Error(`Timeout after ${Math.round(Math.max(1000, timeoutMs) / 1000)}s calling ${method} ${path}`);
54
65
  const cause = e.cause;
55
66
  throw new Error(`Network error calling ${method} ${path}: ${cause?.code ?? ""} ${cause?.message ?? e.message}`.trim());
56
67
  }
@@ -66,8 +77,8 @@ export class CraftStoryClient {
66
77
  throw new ApiError(res.status, data, describeError(res.status, data));
67
78
  return data;
68
79
  }
69
- get(path) {
70
- return this.request("GET", path);
80
+ get(path, timeoutMs) {
81
+ return this.request("GET", path, undefined, timeoutMs);
71
82
  }
72
83
  post(path, body) {
73
84
  return this.request("POST", path, body);
@@ -159,11 +170,11 @@ export class CraftStoryClient {
159
170
  jobPath(kind, id) {
160
171
  return kind === "audio-clip" ? `/audio/clips/${id}/` : `/${kind}/${id}/`;
161
172
  }
162
- getStatus(kind, id) {
163
- return this.get(`${this.jobPath(kind, id)}status/`);
173
+ getStatus(kind, id, timeoutMs) {
174
+ return this.get(`${this.jobPath(kind, id)}status/`, timeoutMs);
164
175
  }
165
- getResult(kind, id) {
166
- return this.get(this.jobPath(kind, id));
176
+ getResult(kind, id, timeoutMs) {
177
+ return this.get(this.jobPath(kind, id), timeoutMs);
167
178
  }
168
179
  }
169
180
  /** Terminal-state classification shared by wait_for_job and the tool descriptions. */
package/dist/server.js CHANGED
@@ -14,7 +14,7 @@ const JOB_KINDS = ["craftstory-2", "minimax-h3", "audio-clip"];
14
14
  const text = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
15
15
  const fail = (err) => ({ isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] });
16
16
  export function buildServer(client) {
17
- const server = new McpServer({ name: "craftstory", version: "0.1.0" });
17
+ const server = new McpServer({ name: "craftstory", version: "0.1.2" });
18
18
  server.registerTool("list_models", {
19
19
  title: "List CraftStory video models",
20
20
  description: "Catalogue of the video models behind this server with their status, modes, limits and credit prices. " +
@@ -72,10 +72,14 @@ export function buildServer(client) {
72
72
  },
73
73
  }, async ({ text: script, voice_id, voice_user_id, file_path }) => {
74
74
  try {
75
+ if (file_path && (script || voice_id || voice_user_id))
76
+ throw new Error("Pass either file_path or text + one voice, not both");
75
77
  if (file_path)
76
78
  return text(await client.createAudioClipFromFile(file_path));
77
79
  if (!script)
78
80
  throw new Error("Pass text (with a voice) or file_path");
81
+ if (voice_id && voice_user_id)
82
+ throw new Error("Pass exactly one of voice_id or voice_user_id");
79
83
  if (!voice_id && !voice_user_id)
80
84
  throw new Error("Pass voice_id (library voice) or voice_user_id (cloned voice) with text");
81
85
  return text(await client.createAudioClipFromText(script, voice_id ? { voice_id } : { voice_user_id }));
@@ -109,7 +113,7 @@ export function buildServer(client) {
109
113
  "so call wait_for_job(model='craftstory-2') repeatedly until it reports done, then get_job_result for the video URL.",
110
114
  inputSchema: {
111
115
  image_url: z.string().url().optional().describe("Public URL of the photo (JPG/PNG)"),
112
- image_path: z.string().optional().describe("Local path of the photo to upload (<= 20 MB)"),
116
+ image_path: z.string().optional().describe("Absolute local path of the photo to upload (JPG/PNG/HEIC, <= 20 MB); ~/ is expanded"),
113
117
  scene_id: z.string().uuid().optional().describe("Custom avatar scene id (from list_avatars) used instead of a photo"),
114
118
  avatar_id: z.string().uuid().optional().describe("Custom avatar id (from list_avatars); its trained model drives identity"),
115
119
  audio_clip_ids: z.array(z.string().uuid()).min(1).describe("Audio clip ids (from create_audio_clip), played in order"),
@@ -220,9 +224,15 @@ export function buildServer(client) {
220
224
  const deadline = Date.now() + (timeout_s ?? 45) * 1000;
221
225
  const token = extra._meta?.progressToken;
222
226
  let last;
227
+ const running = () => text({ state: "running", ...last, hint: "still running - call wait_for_job again" });
223
228
  try {
224
229
  while (true) {
225
- last = await client.getStatus(model, id);
230
+ // Every request is capped by the time left, so the call returns within timeout_s
231
+ // (plus at most a few seconds for the final result fetch).
232
+ const budget = deadline - Date.now();
233
+ if (budget <= 0 && last)
234
+ return running();
235
+ last = await client.getStatus(model, id, Math.min(25_000, Math.max(3_000, budget)));
226
236
  const state = classify(last.status);
227
237
  if (token !== undefined) {
228
238
  await extra.sendNotification({
@@ -231,12 +241,21 @@ export function buildServer(client) {
231
241
  });
232
242
  }
233
243
  if (state !== "running") {
234
- const result = state === "done" ? await client.getResult(model, id) : undefined;
244
+ let result;
245
+ if (state === "done") {
246
+ try {
247
+ result = await client.getResult(model, id, Math.min(10_000, Math.max(3_000, deadline - Date.now())));
248
+ }
249
+ catch {
250
+ return text({ state, ...last, hint: "finished - call get_job_result for the video URL" });
251
+ }
252
+ }
235
253
  return text({ state, ...last, ...(result ? { result } : {}) });
236
254
  }
237
- if (Date.now() >= deadline)
238
- return text({ state: "running", ...last, hint: "still running - call wait_for_job again" });
239
- await new Promise((r) => setTimeout(r, model === "audio-clip" ? 2000 : 5000));
255
+ const remaining = deadline - Date.now();
256
+ if (remaining <= 0)
257
+ return running();
258
+ await new Promise((r) => setTimeout(r, Math.min(model === "audio-clip" ? 2000 : 5000, remaining)));
240
259
  }
241
260
  }
242
261
  catch (e) {
package/package.json CHANGED
@@ -1,22 +1,40 @@
1
1
  {
2
2
  "name": "@craftstory/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for the CraftStory API: talking-avatar videos from a photo (CraftStory 2.0) and short clips with generated sound (MiniMax H3), from Claude, Cursor and other MCP clients.",
5
5
  "type": "module",
6
- "bin": { "craftstory-mcp": "dist/index.js" },
6
+ "bin": {
7
+ "craftstory-mcp": "dist/index.js"
8
+ },
7
9
  "main": "dist/index.js",
8
- "files": ["dist", "README.md", "LICENSE"],
9
- "engines": { "node": ">=18" },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
10
19
  "scripts": {
11
20
  "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
12
21
  "start": "node dist/index.js",
13
22
  "test": "node --import tsx --test test/*.test.ts",
14
- "smoke": "tsx test/smoke.ts"
23
+ "smoke": "tsx test/smoke.ts",
24
+ "prepack": "npm run build"
15
25
  },
16
- "keywords": ["mcp", "model-context-protocol", "craftstory", "ai-video", "talking-avatar", "lip-sync", "text-to-video", "minimax"],
26
+ "keywords": [
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "craftstory",
30
+ "ai-video",
31
+ "talking-avatar",
32
+ "lip-sync",
33
+ "text-to-video",
34
+ "minimax"
35
+ ],
17
36
  "license": "MIT",
18
- "repository": { "type": "git", "url": "https://gitlab.itseez3d.com/craftstory/craftstory-mcp" },
19
- "homepage": "https://craftstory.com",
37
+ "homepage": "https://github.com/itseez3d/craftstory-mcp#readme",
20
38
  "dependencies": {
21
39
  "@modelcontextprotocol/sdk": "^1.30.0",
22
40
  "zod": "^3.24.0"
@@ -25,5 +43,12 @@
25
43
  "@types/node": "^22.0.0",
26
44
  "tsx": "^4.19.0",
27
45
  "typescript": "^5.6.0"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/itseez3d/craftstory-mcp/issues"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/itseez3d/craftstory-mcp.git"
28
53
  }
29
54
  }