@craftstory/mcp 0.1.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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/client.js +199 -0
- package/dist/index.js +17 -0
- package/dist/server.js +289 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 itSeez3D / CraftStory
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# CraftStory MCP server
|
|
2
|
+
|
|
3
|
+
Generate talking-avatar videos from Claude, Cursor, Claude Code or any other [MCP](https://modelcontextprotocol.io) client, using the [CraftStory](https://craftstory.com) API:
|
|
4
|
+
|
|
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
|
+
- **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
|
+
|
|
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.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
### Claude Code
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
claude mcp add craftstory -e CRAFTSTORY_API_KEY=sk-cs-... -- npx -y @craftstory/mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Claude Desktop
|
|
19
|
+
|
|
20
|
+
Add to `claude_desktop_config.json` (Settings -> Developer -> Edit Config):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"craftstory": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "@craftstory/mcp"],
|
|
28
|
+
"env": { "CRAFTSTORY_API_KEY": "sk-cs-..." }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Cursor / other clients
|
|
35
|
+
|
|
36
|
+
Same shape in `.cursor/mcp.json` (or the client's MCP config): command `npx`, args `["-y", "@craftstory/mcp"]`, env `CRAFTSTORY_API_KEY`.
|
|
37
|
+
|
|
38
|
+
Environment variables: `CRAFTSTORY_API_KEY` (required), `CRAFTSTORY_API_BASE` (optional, default `https://api.craftstory.com/api/v1`).
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | What it does |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `list_models` | Models, status (a paused model answers 503), limits and prices |
|
|
45
|
+
| `list_voices` | Library voices; `include_cloned` adds your cloned voices |
|
|
46
|
+
| `list_avatars` | Your custom avatars, or the scenes of one avatar |
|
|
47
|
+
| `create_audio_clip` | Speech from text + voice, or upload a local recording |
|
|
48
|
+
| `preview_cost` | Credit estimate for a CraftStory 2.0 video |
|
|
49
|
+
| `create_craftstory2_video` | Start a CraftStory 2.0 job (photo or avatar scene + audio clips) |
|
|
50
|
+
| `create_minimax_h3_video` | Start a MiniMax H3 job (basic or reference mode) |
|
|
51
|
+
| `get_job_status` | Status, percentage, failure reason, refund flag |
|
|
52
|
+
| `get_job_result` | Full record with the signed video URL (valid 7 days) |
|
|
53
|
+
| `wait_for_job` | Bounded polling (default 45 s, max 55 s); call again while `state` is `running` |
|
|
54
|
+
| `upscale_video` | New job with the upscaled result (CraftStory 2.0 720p -> 1080p, H3 2x) |
|
|
55
|
+
|
|
56
|
+
Plus the prompt `talking_video_from_photo` (script + photo) that walks the model through the whole flow.
|
|
57
|
+
|
|
58
|
+
## Example
|
|
59
|
+
|
|
60
|
+
> Make a 15-second portrait video of the person in `~/photos/anna.jpg` saying "Welcome to our spring collection", calm gestures.
|
|
61
|
+
|
|
62
|
+
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
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
## Local files vs URLs
|
|
67
|
+
|
|
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.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm install
|
|
74
|
+
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)
|
|
76
|
+
npm test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Full API reference: https://api.craftstory.com/api/v1/docs/public/ and the curl walkthrough at https://api.craftstory.com/api/v1/docs/samples/curl/.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client for the CraftStory public API (https://api.craftstory.com/api/v1/docs/public/).
|
|
3
|
+
* Every method maps 1:1 onto a documented endpoint; no business logic lives here.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
export const DEFAULT_BASE = "https://api.craftstory.com/api/v1";
|
|
8
|
+
export const USER_AGENT = "craftstory-mcp/0.1.0";
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
body;
|
|
12
|
+
constructor(status, body, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.body = body;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** A file-or-URL input as the API accepts it: a URL string, or a local path that is uploaded. */
|
|
19
|
+
export async function fileOrUrl(input, field) {
|
|
20
|
+
if (input.url && input.path)
|
|
21
|
+
throw new Error(`${field}: pass either a URL or a local path, not both`);
|
|
22
|
+
if (input.url)
|
|
23
|
+
return { url: input.url };
|
|
24
|
+
if (input.path) {
|
|
25
|
+
const bytes = await readFile(input.path);
|
|
26
|
+
return { blob: new Blob([bytes]), name: basename(input.path) };
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`${field}: a URL or a local file path is required`);
|
|
29
|
+
}
|
|
30
|
+
export class CraftStoryClient {
|
|
31
|
+
opts;
|
|
32
|
+
base;
|
|
33
|
+
fetchImpl;
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
this.opts = opts;
|
|
36
|
+
this.base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
|
|
37
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
38
|
+
}
|
|
39
|
+
async request(method, path, body) {
|
|
40
|
+
const headers = { Authorization: `Bearer ${this.opts.apiKey}`, "User-Agent": USER_AGENT, Accept: "application/json" };
|
|
41
|
+
let payload;
|
|
42
|
+
if (body instanceof FormData)
|
|
43
|
+
payload = body;
|
|
44
|
+
else if (body !== undefined) {
|
|
45
|
+
headers["Content-Type"] = "application/json";
|
|
46
|
+
payload = JSON.stringify(body);
|
|
47
|
+
}
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await this.fetchImpl(`${this.base}${path}`, { method, headers, body: payload });
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
// Node's fetch hides the reason behind "fetch failed"; surface the cause (DNS, TLS, reset...).
|
|
54
|
+
const cause = e.cause;
|
|
55
|
+
throw new Error(`Network error calling ${method} ${path}: ${cause?.code ?? ""} ${cause?.message ?? e.message}`.trim());
|
|
56
|
+
}
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
let data = text;
|
|
59
|
+
try {
|
|
60
|
+
data = text ? JSON.parse(text) : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* non-JSON body (e.g. 502 page) stays as text */
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok)
|
|
66
|
+
throw new ApiError(res.status, data, describeError(res.status, data));
|
|
67
|
+
return data;
|
|
68
|
+
}
|
|
69
|
+
get(path) {
|
|
70
|
+
return this.request("GET", path);
|
|
71
|
+
}
|
|
72
|
+
post(path, body) {
|
|
73
|
+
return this.request("POST", path, body);
|
|
74
|
+
}
|
|
75
|
+
// ---- catalogue -------------------------------------------------------
|
|
76
|
+
listModels() {
|
|
77
|
+
return this.get("/models/");
|
|
78
|
+
}
|
|
79
|
+
listVoices() {
|
|
80
|
+
return this.get("/craftstory-2/voices/");
|
|
81
|
+
}
|
|
82
|
+
listUserVoices() {
|
|
83
|
+
return this.get("/craftstory-2/user-voices/");
|
|
84
|
+
}
|
|
85
|
+
listAvatars() {
|
|
86
|
+
return this.get("/craftstory-2/avatars/");
|
|
87
|
+
}
|
|
88
|
+
listAvatarScenes(avatarId) {
|
|
89
|
+
return this.get(`/craftstory-2/avatars/${avatarId}/scenes/`);
|
|
90
|
+
}
|
|
91
|
+
// ---- audio clips -----------------------------------------------------
|
|
92
|
+
createAudioClipFromText(text, voice) {
|
|
93
|
+
return this.post("/audio/clips/", { text, ...voice });
|
|
94
|
+
}
|
|
95
|
+
async createAudioClipFromFile(path) {
|
|
96
|
+
const fd = new FormData();
|
|
97
|
+
const { blob, name } = await fileOrUrl({ path }, "file");
|
|
98
|
+
fd.append("file", blob, name);
|
|
99
|
+
return this.post("/audio/clips/", fd);
|
|
100
|
+
}
|
|
101
|
+
// ---- CraftStory 2.0 --------------------------------------------------
|
|
102
|
+
previewCost(body) {
|
|
103
|
+
return this.post("/craftstory-2/preview-cost/", body);
|
|
104
|
+
}
|
|
105
|
+
async createCraftStory2(args) {
|
|
106
|
+
const fd = new FormData();
|
|
107
|
+
if (args.image?.url || args.image?.path) {
|
|
108
|
+
const img = await fileOrUrl(args.image, "image");
|
|
109
|
+
if (img.url)
|
|
110
|
+
fd.append("image", img.url);
|
|
111
|
+
else
|
|
112
|
+
fd.append("image", img.blob, img.name);
|
|
113
|
+
}
|
|
114
|
+
for (const a of args.audios)
|
|
115
|
+
fd.append("audios", a);
|
|
116
|
+
fd.append("resolution", args.resolution);
|
|
117
|
+
for (const k of ["scene_id", "avatar_id", "gestures", "lipsync_mode", "user_prompt", "name"]) {
|
|
118
|
+
const v = args[k];
|
|
119
|
+
if (v !== undefined && v !== "")
|
|
120
|
+
fd.append(k, String(v));
|
|
121
|
+
}
|
|
122
|
+
if (args.faceswap !== undefined)
|
|
123
|
+
fd.append("faceswap", args.faceswap ? "true" : "false");
|
|
124
|
+
return this.post("/craftstory-2/", fd);
|
|
125
|
+
}
|
|
126
|
+
upscaleCraftStory2(id, resolution) {
|
|
127
|
+
return this.post(`/craftstory-2/${id}/upscale/`, { resolution });
|
|
128
|
+
}
|
|
129
|
+
// ---- MiniMax H3 ------------------------------------------------------
|
|
130
|
+
async createMiniMaxH3(args) {
|
|
131
|
+
const fd = new FormData();
|
|
132
|
+
const img = await fileOrUrl(args.image, "image");
|
|
133
|
+
if (img.url)
|
|
134
|
+
fd.append("image", img.url);
|
|
135
|
+
else
|
|
136
|
+
fd.append("image", img.blob, img.name);
|
|
137
|
+
if (args.user_prompt)
|
|
138
|
+
fd.append("user_prompt", args.user_prompt);
|
|
139
|
+
if (args.name)
|
|
140
|
+
fd.append("name", args.name);
|
|
141
|
+
if (args.mode === "basic") {
|
|
142
|
+
fd.append("requested_duration_s", String(args.requested_duration_s ?? 8));
|
|
143
|
+
return this.post("/minimax-h3/", fd);
|
|
144
|
+
}
|
|
145
|
+
for (const a of args.audios ?? [])
|
|
146
|
+
fd.append("audios", a);
|
|
147
|
+
const captions = args.reference_captions ?? [];
|
|
148
|
+
for (const [i, p] of (args.reference_files ?? []).entries()) {
|
|
149
|
+
const f = await fileOrUrl({ path: p }, "reference_files");
|
|
150
|
+
fd.append("reference_files", f.blob, f.name);
|
|
151
|
+
fd.append("reference_captions", captions[i] ?? "");
|
|
152
|
+
}
|
|
153
|
+
return this.post("/minimax-h3/reference/", fd);
|
|
154
|
+
}
|
|
155
|
+
upscaleMiniMaxH3(id) {
|
|
156
|
+
return this.post(`/minimax-h3/${id}/upscale/`);
|
|
157
|
+
}
|
|
158
|
+
// ---- generic job access ---------------------------------------------
|
|
159
|
+
jobPath(kind, id) {
|
|
160
|
+
return kind === "audio-clip" ? `/audio/clips/${id}/` : `/${kind}/${id}/`;
|
|
161
|
+
}
|
|
162
|
+
getStatus(kind, id) {
|
|
163
|
+
return this.get(`${this.jobPath(kind, id)}status/`);
|
|
164
|
+
}
|
|
165
|
+
getResult(kind, id) {
|
|
166
|
+
return this.get(this.jobPath(kind, id));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Terminal-state classification shared by wait_for_job and the tool descriptions. */
|
|
170
|
+
export function classify(status) {
|
|
171
|
+
if (status === "done")
|
|
172
|
+
return "done";
|
|
173
|
+
if (status.startsWith("failed") || status.startsWith("rejected_") || status === "not_pass_moderation")
|
|
174
|
+
return "failed";
|
|
175
|
+
return "running";
|
|
176
|
+
}
|
|
177
|
+
function describeError(status, body) {
|
|
178
|
+
if (status === 401)
|
|
179
|
+
return `401 Unauthorized: ${flatten(body)} (check CRAFTSTORY_API_KEY and that the plan includes API access)`;
|
|
180
|
+
if (status === 402 || (status === 400 && JSON.stringify(body).includes("Low credits")))
|
|
181
|
+
return `Low credits: ${flatten(body)}`;
|
|
182
|
+
if (status === 503)
|
|
183
|
+
return `503: the model is paused right now (see list_models); retry later`;
|
|
184
|
+
return `HTTP ${status}: ${flatten(body)}`;
|
|
185
|
+
}
|
|
186
|
+
function flatten(body) {
|
|
187
|
+
if (body == null)
|
|
188
|
+
return "";
|
|
189
|
+
if (typeof body === "string")
|
|
190
|
+
return body.slice(0, 300);
|
|
191
|
+
if (Array.isArray(body))
|
|
192
|
+
return body.map(flatten).join("; ");
|
|
193
|
+
if (typeof body === "object") {
|
|
194
|
+
return Object.entries(body)
|
|
195
|
+
.map(([k, v]) => `${k}: ${flatten(v)}`)
|
|
196
|
+
.join("; ");
|
|
197
|
+
}
|
|
198
|
+
return String(body);
|
|
199
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** CraftStory MCP server over stdio. Configuration comes from the environment:
|
|
3
|
+
* CRAFTSTORY_API_KEY - required, an sk-cs-... key from Account -> API Access
|
|
4
|
+
* CRAFTSTORY_API_BASE - optional, defaults to https://api.craftstory.com/api/v1
|
|
5
|
+
*/
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { CraftStoryClient, DEFAULT_BASE } from "./client.js";
|
|
8
|
+
import { buildServer } from "./server.js";
|
|
9
|
+
const apiKey = process.env.CRAFTSTORY_API_KEY;
|
|
10
|
+
if (!apiKey) {
|
|
11
|
+
console.error("craftstory-mcp: set CRAFTSTORY_API_KEY (create a key in the CraftStory app: Account -> API Access)");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const client = new CraftStoryClient({ apiKey, baseUrl: process.env.CRAFTSTORY_API_BASE ?? DEFAULT_BASE });
|
|
15
|
+
const server = buildServer(client);
|
|
16
|
+
const transport = new StdioServerTransport();
|
|
17
|
+
await server.connect(transport);
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server definition: every tool is a thin, documented wrapper over one
|
|
3
|
+
* CraftStory public API call. Long generations (CraftStory 2.0: 8-15 min,
|
|
4
|
+
* MiniMax H3: 1-3 min) return a job id immediately; `wait_for_job` polls in
|
|
5
|
+
* bounded slices so agent runtimes never hang on a single call.
|
|
6
|
+
*/
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { classify } from "./client.js";
|
|
10
|
+
const RESOLUTIONS = ["480_832", "832_480", "720_1280", "1280_720"];
|
|
11
|
+
const GESTURES = ["normal", "calm", "expressive"];
|
|
12
|
+
const LIPSYNC = ["craftstory", "sync_so", "empty"];
|
|
13
|
+
const JOB_KINDS = ["craftstory-2", "minimax-h3", "audio-clip"];
|
|
14
|
+
const text = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
|
|
15
|
+
const fail = (err) => ({ isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] });
|
|
16
|
+
export function buildServer(client) {
|
|
17
|
+
const server = new McpServer({ name: "craftstory", version: "0.1.0" });
|
|
18
|
+
server.registerTool("list_models", {
|
|
19
|
+
title: "List CraftStory video models",
|
|
20
|
+
description: "Catalogue of the video models behind this server with their status, modes, limits and credit prices. " +
|
|
21
|
+
"Two models today: craftstory-2 (a talking video of any length from one photo plus an audio clip; 8-15 min) " +
|
|
22
|
+
"and minimax-h3 (a clip of up to 15 s from one photo, either description-driven with generated sound or audio-driven with lip-sync; 1-3 min). " +
|
|
23
|
+
"Call this first when unsure which model fits, or to check that a model is not paused.",
|
|
24
|
+
inputSchema: {},
|
|
25
|
+
}, async () => {
|
|
26
|
+
try {
|
|
27
|
+
return text(await client.listModels());
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
return fail(e);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
server.registerTool("list_voices", {
|
|
34
|
+
title: "List voices for text-to-speech",
|
|
35
|
+
description: "Library voices (id, name, language, gender) usable as voice_id in create_audio_clip. " +
|
|
36
|
+
"With include_cloned=true also returns the account's own cloned voices, usable as voice_user_id. Voices are cloned in the CraftStory app, not via the API.",
|
|
37
|
+
inputSchema: { include_cloned: z.boolean().optional().describe("Also return the account's cloned voices (default false)") },
|
|
38
|
+
}, async ({ include_cloned }) => {
|
|
39
|
+
try {
|
|
40
|
+
const library = await client.listVoices();
|
|
41
|
+
const cloned = include_cloned ? await client.listUserVoices() : undefined;
|
|
42
|
+
return text(cloned ? { library, cloned } : { library });
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return fail(e);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
server.registerTool("list_avatars", {
|
|
49
|
+
title: "List custom avatars (and their scenes)",
|
|
50
|
+
description: "Custom avatars trained in the CraftStory app that craftstory-2 can generate with (pass an id as avatar_id). " +
|
|
51
|
+
"Each avatar may carry a default voice {id, voice_kind}: voice_kind 'user' means send it as voice_user_id, 'library' as voice_id in create_audio_clip. " +
|
|
52
|
+
"Pass avatar_id to list that avatar's scenes; a scene id can replace the photo (scene_id) in create_craftstory2_video.",
|
|
53
|
+
inputSchema: { avatar_id: z.string().uuid().optional().describe("Return the scenes of this avatar instead of the avatar list") },
|
|
54
|
+
}, async ({ avatar_id }) => {
|
|
55
|
+
try {
|
|
56
|
+
return text(avatar_id ? await client.listAvatarScenes(avatar_id) : await client.listAvatars());
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
return fail(e);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
server.registerTool("create_audio_clip", {
|
|
63
|
+
title: "Create an audio clip (speech from text, or upload a recording)",
|
|
64
|
+
description: "The soundtrack every video model takes as input. Either text (up to 2000 characters) plus exactly one voice (voice_id from list_voices, or voice_user_id for a cloned voice), " +
|
|
65
|
+
"or file_path to upload a local WAV/MP3/M4A recording. Returns the clip id; it is ready when wait_for_job(model='audio-clip') reports done (usually seconds). " +
|
|
66
|
+
"Longer scripts: create several clips and pass all ids to create_craftstory2_video in order.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
text: z.string().max(2000).optional().describe("Script to synthesize (<= 2000 chars)"),
|
|
69
|
+
voice_id: z.string().uuid().optional().describe("Library voice id (from list_voices)"),
|
|
70
|
+
voice_user_id: z.string().uuid().optional().describe("Cloned voice id (from list_voices with include_cloned)"),
|
|
71
|
+
file_path: z.string().optional().describe("Local path of a recording to upload instead of text"),
|
|
72
|
+
},
|
|
73
|
+
}, async ({ text: script, voice_id, voice_user_id, file_path }) => {
|
|
74
|
+
try {
|
|
75
|
+
if (file_path)
|
|
76
|
+
return text(await client.createAudioClipFromFile(file_path));
|
|
77
|
+
if (!script)
|
|
78
|
+
throw new Error("Pass text (with a voice) or file_path");
|
|
79
|
+
if (!voice_id && !voice_user_id)
|
|
80
|
+
throw new Error("Pass voice_id (library voice) or voice_user_id (cloned voice) with text");
|
|
81
|
+
return text(await client.createAudioClipFromText(script, voice_id ? { voice_id } : { voice_user_id }));
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return fail(e);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
server.registerTool("preview_cost", {
|
|
88
|
+
title: "Estimate the credit cost of a CraftStory 2.0 video",
|
|
89
|
+
description: "Credits a craftstory-2 job would cost for the given audio clips and settings, without creating anything. " +
|
|
90
|
+
"Rate per second of audio: 480p 2.2 (2 with lipsync_mode=empty), 720p 3.3 (3 with empty); rounded up per job. MiniMax H3 is a flat 3.3 credits per billed second.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
audio_clip_ids: z.array(z.string().uuid()).min(1),
|
|
93
|
+
resolution: z.enum(RESOLUTIONS),
|
|
94
|
+
lipsync_mode: z.enum(LIPSYNC).optional(),
|
|
95
|
+
},
|
|
96
|
+
}, async ({ audio_clip_ids, resolution, lipsync_mode }) => {
|
|
97
|
+
try {
|
|
98
|
+
return text(await client.previewCost({ audios: audio_clip_ids, resolution, lipsync_mode }));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return fail(e);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
server.registerTool("create_craftstory2_video", {
|
|
105
|
+
title: "Create a CraftStory 2.0 talking video (photo + audio)",
|
|
106
|
+
description: "Start a craftstory-2 generation: a photo of a person (image_url or image_path, or a custom avatar scene via scene_id) speaks the given audio clips with lip-sync, gestures and natural motion; any length. " +
|
|
107
|
+
"resolution is WIDTH_HEIGHT (480_832 / 720_1280 portrait, 832_480 / 1280_720 landscape); 1080p is available afterwards via upscale_video. " +
|
|
108
|
+
"Credits are charged on create (see preview_cost) and refunded if the job fails. Returns the job id and initial status; generation takes 8-15 minutes, " +
|
|
109
|
+
"so call wait_for_job(model='craftstory-2') repeatedly until it reports done, then get_job_result for the video URL.",
|
|
110
|
+
inputSchema: {
|
|
111
|
+
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)"),
|
|
113
|
+
scene_id: z.string().uuid().optional().describe("Custom avatar scene id (from list_avatars) used instead of a photo"),
|
|
114
|
+
avatar_id: z.string().uuid().optional().describe("Custom avatar id (from list_avatars); its trained model drives identity"),
|
|
115
|
+
audio_clip_ids: z.array(z.string().uuid()).min(1).describe("Audio clip ids (from create_audio_clip), played in order"),
|
|
116
|
+
resolution: z.enum(RESOLUTIONS),
|
|
117
|
+
gestures: z.enum(GESTURES).optional().describe("How much the avatar moves (default normal)"),
|
|
118
|
+
lipsync_mode: z.enum(LIPSYNC).optional().describe("craftstory (default) / sync_so (alternative engine) / empty (no lip-sync)"),
|
|
119
|
+
user_prompt: z.string().max(1000).optional().describe("Optional motion / scene hint"),
|
|
120
|
+
faceswap: z.boolean().optional().describe("Identity pass on the result (default true; off for custom avatars)"),
|
|
121
|
+
name: z.string().optional().describe("Label, used as the download file name"),
|
|
122
|
+
},
|
|
123
|
+
}, async (a) => {
|
|
124
|
+
try {
|
|
125
|
+
if (!a.image_url && !a.image_path && !a.scene_id)
|
|
126
|
+
throw new Error("Pass image_url, image_path or scene_id");
|
|
127
|
+
const r = await client.createCraftStory2({
|
|
128
|
+
image: { url: a.image_url, path: a.image_path },
|
|
129
|
+
scene_id: a.scene_id,
|
|
130
|
+
avatar_id: a.avatar_id,
|
|
131
|
+
audios: a.audio_clip_ids,
|
|
132
|
+
resolution: a.resolution,
|
|
133
|
+
gestures: a.gestures,
|
|
134
|
+
lipsync_mode: a.lipsync_mode,
|
|
135
|
+
user_prompt: a.user_prompt,
|
|
136
|
+
faceswap: a.faceswap,
|
|
137
|
+
name: a.name,
|
|
138
|
+
});
|
|
139
|
+
return text({ id: r.id, status: r.status, credits: r.credits, next: "wait_for_job(model='craftstory-2', id=...) until done (8-15 min), then get_job_result" });
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
return fail(e);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
server.registerTool("create_minimax_h3_video", {
|
|
146
|
+
title: "Create a MiniMax H3 clip (up to 15 s)",
|
|
147
|
+
description: "Start a minimax-h3 generation from one photo. mode='basic': user_prompt (scene description) + requested_duration_s (5-15); the model animates the photo and generates the soundtrack itself. " +
|
|
148
|
+
"mode='reference': one audio_clip_id drives the clip with lip-sync (first 15 s billed); user_prompt is optional; up to 8 extra image / 3 video / 2 audio reference_files with reference_captions keep a product or background consistent. " +
|
|
149
|
+
"Output is 768 px on the short side, orientation follows the photo. Cost 3.3 credits per billed second, charged on create. Returns the job id; call wait_for_job(model='minimax-h3') until done (1-3 min).",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
mode: z.enum(["basic", "reference"]),
|
|
152
|
+
image_url: z.string().url().optional(),
|
|
153
|
+
image_path: z.string().optional(),
|
|
154
|
+
user_prompt: z.string().optional().describe("Scene / motion description (required in basic mode)"),
|
|
155
|
+
requested_duration_s: z.number().int().min(5).max(15).optional().describe("Clip length in basic mode (default 8)"),
|
|
156
|
+
audio_clip_id: z.string().uuid().optional().describe("Reference mode: the clip that drives the video"),
|
|
157
|
+
reference_files: z.array(z.string()).optional().describe("Reference mode: local paths of extra reference images/videos/audio"),
|
|
158
|
+
reference_captions: z.array(z.string()).optional().describe("One caption per reference file, same order"),
|
|
159
|
+
name: z.string().optional(),
|
|
160
|
+
},
|
|
161
|
+
}, async (a) => {
|
|
162
|
+
try {
|
|
163
|
+
if (!a.image_url && !a.image_path)
|
|
164
|
+
throw new Error("Pass image_url or image_path");
|
|
165
|
+
if (a.mode === "basic" && !a.user_prompt)
|
|
166
|
+
throw new Error("basic mode needs user_prompt");
|
|
167
|
+
if (a.mode === "reference" && !a.audio_clip_id)
|
|
168
|
+
throw new Error("reference mode needs audio_clip_id");
|
|
169
|
+
const r = await client.createMiniMaxH3({
|
|
170
|
+
mode: a.mode,
|
|
171
|
+
image: { url: a.image_url, path: a.image_path },
|
|
172
|
+
user_prompt: a.user_prompt,
|
|
173
|
+
requested_duration_s: a.requested_duration_s,
|
|
174
|
+
audios: a.audio_clip_id ? [a.audio_clip_id] : undefined,
|
|
175
|
+
reference_files: a.reference_files,
|
|
176
|
+
reference_captions: a.reference_captions,
|
|
177
|
+
name: a.name,
|
|
178
|
+
});
|
|
179
|
+
return text({ id: r.id, status: r.status, credits: r.credits, next: "wait_for_job(model='minimax-h3', id=...) until done (1-3 min), then get_job_result" });
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
return fail(e);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
server.registerTool("get_job_status", {
|
|
186
|
+
title: "Get a job's status",
|
|
187
|
+
description: "Status of a video job or audio clip: status, status_percentage, status_failed, credits_refunded. Terminal states: done; failed*, rejected_* and not_pass_moderation (audio) are failures. " +
|
|
188
|
+
"Prefer wait_for_job, which polls for you.",
|
|
189
|
+
inputSchema: { model: z.enum(JOB_KINDS), id: z.string().uuid() },
|
|
190
|
+
}, async ({ model, id }) => {
|
|
191
|
+
try {
|
|
192
|
+
return text(await client.getStatus(model, id));
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return fail(e);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
server.registerTool("get_job_result", {
|
|
199
|
+
title: "Get a finished job (video URL and details)",
|
|
200
|
+
description: "Full record of a job. For craftstory-2 the video is in `video`, for minimax-h3 in `video_url`, for audio clips in `file`; all are signed URLs valid for 7 days (call again for a fresh link). Also returns the parameters used and the credits charged.",
|
|
201
|
+
inputSchema: { model: z.enum(JOB_KINDS), id: z.string().uuid() },
|
|
202
|
+
}, async ({ model, id }) => {
|
|
203
|
+
try {
|
|
204
|
+
return text(await client.getResult(model, id));
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
return fail(e);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
server.registerTool("wait_for_job", {
|
|
211
|
+
title: "Wait for a job (bounded polling)",
|
|
212
|
+
description: "Polls a job's status every few seconds for up to timeout_s (default 45, max 55 - most MCP clients cut a tool call at 60 s) and returns as soon as it is terminal. " +
|
|
213
|
+
"If it returns state='running', call it again - craftstory-2 jobs take 8-15 minutes, minimax-h3 1-3 minutes, audio clips seconds. Reports progress notifications when the client supports them.",
|
|
214
|
+
inputSchema: {
|
|
215
|
+
model: z.enum(JOB_KINDS),
|
|
216
|
+
id: z.string().uuid(),
|
|
217
|
+
timeout_s: z.number().int().min(5).max(55).optional().describe("How long this call may wait (default 45, max 55)"),
|
|
218
|
+
},
|
|
219
|
+
}, async ({ model, id, timeout_s }, extra) => {
|
|
220
|
+
const deadline = Date.now() + (timeout_s ?? 45) * 1000;
|
|
221
|
+
const token = extra._meta?.progressToken;
|
|
222
|
+
let last;
|
|
223
|
+
try {
|
|
224
|
+
while (true) {
|
|
225
|
+
last = await client.getStatus(model, id);
|
|
226
|
+
const state = classify(last.status);
|
|
227
|
+
if (token !== undefined) {
|
|
228
|
+
await extra.sendNotification({
|
|
229
|
+
method: "notifications/progress",
|
|
230
|
+
params: { progressToken: token, progress: last.status_percentage ?? 0, total: 100, message: last.status },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (state !== "running") {
|
|
234
|
+
const result = state === "done" ? await client.getResult(model, id) : undefined;
|
|
235
|
+
return text({ state, ...last, ...(result ? { result } : {}) });
|
|
236
|
+
}
|
|
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));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
return fail(e);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
server.registerTool("upscale_video", {
|
|
247
|
+
title: "Upscale a finished video (new job)",
|
|
248
|
+
description: "Creates a NEW job with the upscaled result; the original stays. craftstory-2: only 720p sources, resolution 1080_1920 (from 720_1280) or 1920_1080 (from 1280_720). minimax-h3: always 2x, no resolution needed. " +
|
|
249
|
+
"Costs 0.2 credits per second. Poll the returned id with wait_for_job.",
|
|
250
|
+
inputSchema: {
|
|
251
|
+
model: z.enum(["craftstory-2", "minimax-h3"]),
|
|
252
|
+
id: z.string().uuid(),
|
|
253
|
+
resolution: z.enum(["1080_1920", "1920_1080"]).optional().describe("craftstory-2 only"),
|
|
254
|
+
},
|
|
255
|
+
}, async ({ model, id, resolution }) => {
|
|
256
|
+
try {
|
|
257
|
+
if (model === "craftstory-2") {
|
|
258
|
+
if (!resolution)
|
|
259
|
+
throw new Error("craftstory-2 upscale needs resolution 1080_1920 or 1920_1080");
|
|
260
|
+
const r = await client.upscaleCraftStory2(id, resolution);
|
|
261
|
+
return text({ id: r.id, status: r.status, credits: r.credits });
|
|
262
|
+
}
|
|
263
|
+
const r = await client.upscaleMiniMaxH3(id);
|
|
264
|
+
return text({ id: r.id, status: r.status, credits: r.credits });
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
return fail(e);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
server.registerPrompt("talking_video_from_photo", {
|
|
271
|
+
title: "Talking video from a photo",
|
|
272
|
+
description: "Step-by-step recipe: script -> voice -> audio clip -> CraftStory 2.0 video -> download.",
|
|
273
|
+
argsSchema: { script: z.string().describe("What the person should say"), photo: z.string().describe("Photo URL or local path") },
|
|
274
|
+
}, ({ script, photo }) => ({
|
|
275
|
+
messages: [
|
|
276
|
+
{
|
|
277
|
+
role: "user",
|
|
278
|
+
content: {
|
|
279
|
+
type: "text",
|
|
280
|
+
text: `Make a talking video of the person in ${photo} saying: "${script}".\n` +
|
|
281
|
+
"1) list_voices and pick a fitting voice. 2) create_audio_clip with the script and that voice; wait_for_job(model='audio-clip') until done. " +
|
|
282
|
+
"3) preview_cost, then create_craftstory2_video with the clip id, the photo and resolution 720_1280 (portrait) or 1280_720 (landscape). " +
|
|
283
|
+
"4) wait_for_job(model='craftstory-2') repeatedly until done (8-15 minutes). 5) get_job_result and give me the video URL.",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
}));
|
|
288
|
+
return server;
|
|
289
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@craftstory/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": { "craftstory-mcp": "dist/index.js" },
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
14
|
+
"smoke": "tsx test/smoke.ts"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["mcp", "model-context-protocol", "craftstory", "ai-video", "talking-avatar", "lip-sync", "text-to-video", "minimax"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": { "type": "git", "url": "https://gitlab.itseez3d.com/craftstory/craftstory-mcp" },
|
|
19
|
+
"homepage": "https://craftstory.com",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
22
|
+
"zod": "^3.24.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"tsx": "^4.19.0",
|
|
27
|
+
"typescript": "^5.6.0"
|
|
28
|
+
}
|
|
29
|
+
}
|