@craftstory/mcp 0.1.1 → 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 +18 -0
- package/README.md +12 -9
- package/dist/client.js +19 -12
- package/dist/server.js +23 -7
- package/package.json +11 -5
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
|
|
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
|
|
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` returns
|
|
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
|
|
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
|
|
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,10 @@
|
|
|
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.
|
|
9
|
+
export const USER_AGENT = "craftstory-mcp/0.1.2";
|
|
9
10
|
/** Per-request HTTP timeout; keeps every tool call well under MCP clients' ~60 s limit. */
|
|
10
11
|
export const REQUEST_TIMEOUT_MS = 25_000;
|
|
11
12
|
export class ApiError extends Error {
|
|
@@ -24,8 +25,9 @@ export async function fileOrUrl(input, field) {
|
|
|
24
25
|
if (input.url)
|
|
25
26
|
return { url: input.url };
|
|
26
27
|
if (input.path) {
|
|
27
|
-
const
|
|
28
|
-
|
|
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) };
|
|
29
31
|
}
|
|
30
32
|
throw new Error(`${field}: a URL or a local file path is required`);
|
|
31
33
|
}
|
|
@@ -36,9 +38,14 @@ export class CraftStoryClient {
|
|
|
36
38
|
constructor(opts) {
|
|
37
39
|
this.opts = opts;
|
|
38
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
|
+
}
|
|
39
45
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
40
46
|
}
|
|
41
|
-
|
|
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) {
|
|
42
49
|
const headers = { Authorization: `Bearer ${this.opts.apiKey}`, "User-Agent": USER_AGENT, Accept: "application/json" };
|
|
43
50
|
let payload;
|
|
44
51
|
if (body instanceof FormData)
|
|
@@ -49,12 +56,12 @@ export class CraftStoryClient {
|
|
|
49
56
|
}
|
|
50
57
|
let res;
|
|
51
58
|
try {
|
|
52
|
-
res = await this.fetchImpl(`${this.base}${path}`, { method, headers, body: payload, signal: AbortSignal.timeout(
|
|
59
|
+
res = await this.fetchImpl(`${this.base}${path}`, { method, headers, body: payload, signal: AbortSignal.timeout(Math.max(1000, timeoutMs)) });
|
|
53
60
|
}
|
|
54
61
|
catch (e) {
|
|
55
62
|
// Node's fetch hides the reason behind "fetch failed"; surface the cause (DNS, TLS, reset...).
|
|
56
63
|
if (e.name === "TimeoutError")
|
|
57
|
-
throw new Error(`Timeout after ${
|
|
64
|
+
throw new Error(`Timeout after ${Math.round(Math.max(1000, timeoutMs) / 1000)}s calling ${method} ${path}`);
|
|
58
65
|
const cause = e.cause;
|
|
59
66
|
throw new Error(`Network error calling ${method} ${path}: ${cause?.code ?? ""} ${cause?.message ?? e.message}`.trim());
|
|
60
67
|
}
|
|
@@ -70,8 +77,8 @@ export class CraftStoryClient {
|
|
|
70
77
|
throw new ApiError(res.status, data, describeError(res.status, data));
|
|
71
78
|
return data;
|
|
72
79
|
}
|
|
73
|
-
get(path) {
|
|
74
|
-
return this.request("GET", path);
|
|
80
|
+
get(path, timeoutMs) {
|
|
81
|
+
return this.request("GET", path, undefined, timeoutMs);
|
|
75
82
|
}
|
|
76
83
|
post(path, body) {
|
|
77
84
|
return this.request("POST", path, body);
|
|
@@ -163,11 +170,11 @@ export class CraftStoryClient {
|
|
|
163
170
|
jobPath(kind, id) {
|
|
164
171
|
return kind === "audio-clip" ? `/audio/clips/${id}/` : `/${kind}/${id}/`;
|
|
165
172
|
}
|
|
166
|
-
getStatus(kind, id) {
|
|
167
|
-
return this.get(`${this.jobPath(kind, id)}status
|
|
173
|
+
getStatus(kind, id, timeoutMs) {
|
|
174
|
+
return this.get(`${this.jobPath(kind, id)}status/`, timeoutMs);
|
|
168
175
|
}
|
|
169
|
-
getResult(kind, id) {
|
|
170
|
-
return this.get(this.jobPath(kind, id));
|
|
176
|
+
getResult(kind, id, timeoutMs) {
|
|
177
|
+
return this.get(this.jobPath(kind, id), timeoutMs);
|
|
171
178
|
}
|
|
172
179
|
}
|
|
173
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.
|
|
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("
|
|
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,11 +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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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)));
|
|
228
236
|
const state = classify(last.status);
|
|
229
237
|
if (token !== undefined) {
|
|
230
238
|
await extra.sendNotification({
|
|
@@ -233,12 +241,20 @@ export function buildServer(client) {
|
|
|
233
241
|
});
|
|
234
242
|
}
|
|
235
243
|
if (state !== "running") {
|
|
236
|
-
|
|
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
|
+
}
|
|
237
253
|
return text({ state, ...last, ...(result ? { result } : {}) });
|
|
238
254
|
}
|
|
239
255
|
const remaining = deadline - Date.now();
|
|
240
256
|
if (remaining <= 0)
|
|
241
|
-
return
|
|
257
|
+
return running();
|
|
242
258
|
await new Promise((r) => setTimeout(r, Math.min(model === "audio-clip" ? 2000 : 5000, remaining)));
|
|
243
259
|
}
|
|
244
260
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@craftstory/mcp",
|
|
3
|
-
"version": "0.1.
|
|
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
6
|
"bin": {
|
|
@@ -10,16 +10,18 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
12
|
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
17
|
+
"node": ">=20"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
20
|
"build": "tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
20
21
|
"start": "node dist/index.js",
|
|
21
22
|
"test": "node --import tsx --test test/*.test.ts",
|
|
22
|
-
"smoke": "tsx test/smoke.ts"
|
|
23
|
+
"smoke": "tsx test/smoke.ts",
|
|
24
|
+
"prepack": "npm run build"
|
|
23
25
|
},
|
|
24
26
|
"keywords": [
|
|
25
27
|
"mcp",
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
"minimax"
|
|
33
35
|
],
|
|
34
36
|
"license": "MIT",
|
|
35
|
-
"homepage": "https://
|
|
37
|
+
"homepage": "https://github.com/itseez3d/craftstory-mcp#readme",
|
|
36
38
|
"dependencies": {
|
|
37
39
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
40
|
"zod": "^3.24.0"
|
|
@@ -43,6 +45,10 @@
|
|
|
43
45
|
"typescript": "^5.6.0"
|
|
44
46
|
},
|
|
45
47
|
"bugs": {
|
|
46
|
-
"url": "https://
|
|
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"
|
|
47
53
|
}
|
|
48
54
|
}
|