@wavespeed/mcp 1.0.18 → 1.1.1
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 +10 -3
- package/dist/lib/media-models.js +102 -0
- package/dist/lib/media-plan.js +188 -0
- package/dist/lib/prompt-refs.js +58 -0
- package/dist/server.js +181 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,9 +22,9 @@ An MCP server for the [WaveSpeed](https://wavespeed.ai) platform: run any model
|
|
|
22
22
|
|
|
23
23
|
Built on the same design as the open-source [`@wavespeed/cli`](https://github.com/WaveSpeedAI/wavespeed-cli):
|
|
24
24
|
|
|
25
|
-
- **No hardcoded models
|
|
26
|
-
- **One
|
|
27
|
-
- **Inputs are never mutated.** The one explicit transform is the `@path` marker — a `"@./photo.jpg"` value inside `input` uploads the file and substitutes its hosted URL. Bare paths are passed through untouched.
|
|
25
|
+
- **No hardcoded models for `run_model`.** The catalog comes from the live API (1h cache); new platform models work the day they ship.
|
|
26
|
+
- **One general verb, five shortcuts.** `run_model` executes anything; `get_model_schema` tells the agent what inputs a model actually accepts, so it reads before it writes. `generate_image`, `edit_image`, `generate_video`, `edit_video` and `extend_video` skip that: they use the same models as the [WaveSpeed /chat](https://wavespeed.ai/chat) page, pick the endpoint for what you give them and fit the request to its live schema.
|
|
27
|
+
- **Inputs are never mutated.** The one explicit transform is the `@path` marker — a `"@./photo.jpg"` value inside `input` uploads the file and substitutes its hosted URL. Bare paths are passed through untouched. The shortcut tools (`generate_image` etc.) are the exception: they pick the endpoint and fit the request to the model, and report what they changed in `notes`.
|
|
28
28
|
- **Quote before you spend.** `get_price` takes the same input as `run_model` and returns an estimate; the amount actually charged for a run is authoritative.
|
|
29
29
|
|
|
30
30
|
## Tools
|
|
@@ -34,6 +34,11 @@ Built on the same design as the open-source [`@wavespeed/cli`](https://github.co
|
|
|
34
34
|
| `list_models` | Search the live catalog by text or modality type |
|
|
35
35
|
| `get_model_schema` | A model's real input schema (required, properties, defaults), with a link to the model's playground page |
|
|
36
36
|
| `run_model` | Submit + wait; rejects input keys the model doesn't accept; `@path` inputs upload automatically; returns output URLs |
|
|
37
|
+
| `generate_image` | Image from a prompt with the /chat image models (default Seedream 5 Pro); reference images use the model's edit endpoint |
|
|
38
|
+
| `edit_image` | Edit, restyle or combine existing images with the same models |
|
|
39
|
+
| `generate_video` | Video from a prompt, a first/last frame or reference images/videos/audio, with the /chat video models (default Seedance 2.5); the mode (text, image, reference) is picked from the inputs |
|
|
40
|
+
| `edit_video` | Edit an existing video, optionally with reference images/audio |
|
|
41
|
+
| `extend_video` | Continue an existing video with new footage |
|
|
37
42
|
| `get_price` | Cost estimate for the inputs you pass (no charge), with a link to the model's playground page |
|
|
38
43
|
| `get_balance` | Account credit balance, with the top-up link |
|
|
39
44
|
| `list_predictions` | Run history (default last 24h); filter by model, status, time range |
|
|
@@ -43,6 +48,8 @@ Built on the same design as the open-source [`@wavespeed/cli`](https://github.co
|
|
|
43
48
|
| `upload_file` | Local file → hosted URL (24h content-hash dedupe) |
|
|
44
49
|
| `get_prediction` | Status/outputs of any run by id; optional `wait_seconds` to wait for it |
|
|
45
50
|
|
|
51
|
+
The five shortcut tools take `wait_seconds` like `run_model`, and `estimate_only: true` returns the price and the exact request that would run without submitting or charging anything. In their prompts, media is named `@image 1`, `@video 1`, `@audio 1` (in the order given); it is renamed to what each model expects. Models and defaults follow /chat: an unknown `model` is rejected, and a named model is never switched.
|
|
52
|
+
|
|
46
53
|
Some generations take minutes to hours. `run_model` submits the run and returns the prediction id right away (`done: false`); the assistant then calls `get_prediction` with that id and `wait_seconds` (max 1800) until `done` is `true`. The task keeps running server-side, so a dropped connection loses nothing. Pass `wait_seconds` to `run_model` to wait for a quick model in the same call. While waiting, both tools send MCP progress notifications that carry the prediction id.
|
|
47
54
|
|
|
48
55
|
## Setup
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The image and video models the dedicated generate/edit tools offer: the
|
|
2
|
+
// same list, in the same order, as the WaveSpeed /chat page (api-dashboard
|
|
3
|
+
// lib/media-models.ts, checked 2026-10-26). The first of each is the default.
|
|
4
|
+
// This is a vendored copy: when /chat changes its models, change this file and
|
|
5
|
+
// its test (src/lib/media-models.test.ts) with it. Capabilities mirror the
|
|
6
|
+
// endpoints' input schemas; the tools still fit every request to the live
|
|
7
|
+
// schema, so a stale entry degrades to a trimmed request rather than a wrong one.
|
|
8
|
+
// The code is kept in the dashboard's style (no semicolons) so the two files diff cleanly.
|
|
9
|
+
const WIDE_RATIOS = ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '9:21', '21:9'];
|
|
10
|
+
const NANO_BANANA_RATIOS = ['1:1', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'];
|
|
11
|
+
const NANO_BANANA_2_RATIOS = [...NANO_BANANA_RATIOS, '1:4', '4:1', '1:8', '8:1'];
|
|
12
|
+
const image = (id, label, base, generate, resolutions, aspectRatios, maxImages) => ({
|
|
13
|
+
id, label, generate: `${base}/${generate}`.replace(/\/$/, ''), edit: `${base}/edit`, resolutions, aspectRatios, maxImages,
|
|
14
|
+
});
|
|
15
|
+
/** In menu order; the first is the default. */
|
|
16
|
+
export const IMAGE_MODELS = {
|
|
17
|
+
'seedream-5-pro': image('seedream-5-pro', 'Seedream 5 Pro', 'bytedance/seedream-v5.0-pro', '', ['1k', '1.5k', '2k'], WIDE_RATIOS, 10),
|
|
18
|
+
'nano-banana-2.1': image('nano-banana-2.1', 'Nano Banana 2.1', 'google/nano-banana-2.1', 'text-to-image', ['1k', '2k', '4k'], NANO_BANANA_2_RATIOS, 14),
|
|
19
|
+
'nano-banana-2': image('nano-banana-2', 'Nano Banana 2', 'google/nano-banana-2', 'text-to-image', ['0.5k', '1k', '2k', '4k'], NANO_BANANA_2_RATIOS, 14),
|
|
20
|
+
'nano-banana-pro': image('nano-banana-pro', 'Nano Banana Pro', 'google/nano-banana-pro', 'text-to-image', ['1k', '2k', '4k'], NANO_BANANA_RATIOS, 14),
|
|
21
|
+
'gpt-image-2.5': image('gpt-image-2.5', 'GPT Image 2.5', 'openai/gpt-image-2.5-sunburst', 'text-to-image', ['1k', '2k', '4k'], WIDE_RATIOS, 16),
|
|
22
|
+
'gpt-image-2': image('gpt-image-2', 'GPT Image 2', 'openai/gpt-image-2', 'text-to-image', ['1k', '2k', '4k'], WIDE_RATIOS, 16),
|
|
23
|
+
};
|
|
24
|
+
export const IMAGE_MODEL_KEYS = Object.keys(IMAGE_MODELS);
|
|
25
|
+
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_KEYS[0];
|
|
26
|
+
export function findImageModel(id) {
|
|
27
|
+
return IMAGE_MODELS[id] ?? IMAGE_MODELS[DEFAULT_IMAGE_MODEL];
|
|
28
|
+
}
|
|
29
|
+
const SEEDANCE_ASPECTS = ['16:9', '9:16', '4:3', '3:4', '1:1', '21:9'];
|
|
30
|
+
const ALL_RES = ['480p', '720p', '1080p', '4k'];
|
|
31
|
+
const TURBO_RES = ['720p', '1080p'];
|
|
32
|
+
const seedance = (id, label, base, turbo, v25) => ({
|
|
33
|
+
id, label, family: v25 ? 'seedance-2.5' : 'seedance-2.0', base, turbo,
|
|
34
|
+
// Turbo endpoints have no extend.
|
|
35
|
+
modes: turbo ? ['generate', 'edit'] : ['generate', 'edit', 'extend'],
|
|
36
|
+
resolutions: turbo ? TURBO_RES : ALL_RES, defaultResolution: '720p',
|
|
37
|
+
durations: v25 ? [4, 30] : [4, 15], aspectRatios: SEEDANCE_ASPECTS, audio: true,
|
|
38
|
+
maxRefs: v25 ? { images: 30, videos: 10, audios: 10 } : { images: 9, videos: 3, audios: 3 },
|
|
39
|
+
});
|
|
40
|
+
const wan = (id, label, base) => ({
|
|
41
|
+
id, label, family: 'wan-3.0', base, turbo: false, modes: ['generate', 'edit', 'extend'],
|
|
42
|
+
resolutions: ['480p', '720p', '1080p'], defaultResolution: '720p', durations: [2, 30],
|
|
43
|
+
aspectRatios: ['16:9', '9:16', '4:3', '3:4', '1:1'], audio: true, maxRefs: { images: 10, videos: 5, audios: 5 },
|
|
44
|
+
});
|
|
45
|
+
/** In menu order; the first is the default. */
|
|
46
|
+
export const VIDEO_MODELS = {
|
|
47
|
+
'seedance-2.5': seedance('seedance-2.5', 'Seedance 2.5', 'bytedance/seedance-2.5', false, true),
|
|
48
|
+
'seedance-2.5-turbo': seedance('seedance-2.5-turbo', 'Seedance 2.5 Turbo', 'bytedance/seedance-2.5', true, true),
|
|
49
|
+
'seedance-2.0': seedance('seedance-2.0', 'Seedance 2.0', 'bytedance/seedance-2.0', false, false),
|
|
50
|
+
'seedance-2.0-turbo': seedance('seedance-2.0-turbo', 'Seedance 2.0 Turbo', 'bytedance/seedance-2.0', true, false),
|
|
51
|
+
'seedance-2.0-fast': seedance('seedance-2.0-fast', 'Seedance 2.0 Fast', 'bytedance/seedance-2.0-fast', false, false),
|
|
52
|
+
'seedance-2.0-fast-turbo': seedance('seedance-2.0-fast-turbo', 'Seedance 2.0 Fast Turbo', 'bytedance/seedance-2.0-fast', true, false),
|
|
53
|
+
'seedance-2.0-mini': seedance('seedance-2.0-mini', 'Seedance 2.0 Mini', 'bytedance/seedance-2.0-mini', false, false),
|
|
54
|
+
'wan-3.0': wan('wan-3.0', 'Wan 3.0', 'alibaba/wan-3.0'),
|
|
55
|
+
'wan-3.0-prime': wan('wan-3.0-prime', 'Wan 3.0 Prime', 'alibaba/wan-3.0-prime'),
|
|
56
|
+
'kling-4.0': {
|
|
57
|
+
id: 'kling-4.0', label: 'Kling 4.0', family: 'kling-4.0', base: 'kwaivgi/kling-v4.0', turbo: false, modes: ['generate', 'edit', 'extend'],
|
|
58
|
+
resolutions: ['720p', '1080p'], defaultResolution: '720p', durations: [3, 30],
|
|
59
|
+
// Sound is always generated with the picture; there is no switch.
|
|
60
|
+
aspectRatios: ['16:9', '9:16', '1:1', '21:9'], audio: false, maxRefs: { images: 10, videos: 5, audios: 3 },
|
|
61
|
+
},
|
|
62
|
+
'minimax-h3': {
|
|
63
|
+
id: 'minimax-h3', label: 'MiniMax H3', family: 'minimax-h3', base: 'wavespeed-ai/minimax-h3', turbo: false, modes: ['generate', 'edit', 'extend'],
|
|
64
|
+
resolutions: ['480p', '540p', '768p', '1080p'], defaultResolution: '768p', durations: [3, 15],
|
|
65
|
+
aspectRatios: ['16:9', '9:16', '4:3', '3:4', '1:1', '21:9', '9:21'], audio: false, maxRefs: { images: 9, videos: 3, audios: 3 },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
export const VIDEO_MODEL_KEYS = Object.keys(VIDEO_MODELS);
|
|
69
|
+
export const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_KEYS[0];
|
|
70
|
+
export function findVideoModel(id) {
|
|
71
|
+
return VIDEO_MODELS[id] ?? VIDEO_MODELS[DEFAULT_VIDEO_MODEL];
|
|
72
|
+
}
|
|
73
|
+
/** The model a full endpoint id belongs to, e.g. bytedance/seedance-2.0-fast/image-to-video-turbo. */
|
|
74
|
+
export function videoModelForEndpoint(modelId) {
|
|
75
|
+
const turbo = modelId.endsWith('-turbo');
|
|
76
|
+
return VIDEO_MODEL_KEYS.map((k) => VIDEO_MODELS[k]).find((m) => m.turbo === turbo && modelId.startsWith(`${m.base}/`));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A model's endpoints. Wan, Kling 4.0 and H3 take references on a dedicated
|
|
80
|
+
* reference-to-video endpoint; Seedance takes them on text-to-video. Turbo
|
|
81
|
+
* endpoints have no extend.
|
|
82
|
+
*/
|
|
83
|
+
export function videoEndpoints(m) {
|
|
84
|
+
const suffix = m.turbo ? '-turbo' : '';
|
|
85
|
+
return {
|
|
86
|
+
text: `${m.base}/text-to-video${suffix}`,
|
|
87
|
+
image: `${m.base}/image-to-video${suffix}`,
|
|
88
|
+
...(m.family === 'wan-3.0' || m.family === 'kling-4.0' || m.family === 'minimax-h3' ? { reference: `${m.base}/reference-to-video` } : {}),
|
|
89
|
+
edit: `${m.base}/video-edit${suffix}`,
|
|
90
|
+
...(m.modes.includes('extend') ? { extend: `${m.base}/video-extend` } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** The input names a model takes its reference media under (Kling 4.0 drops the reference_ prefix). */
|
|
94
|
+
export function referenceFields(m) {
|
|
95
|
+
return m.family === 'kling-4.0'
|
|
96
|
+
? { images: 'images', videos: 'videos', audios: 'audios' }
|
|
97
|
+
: { images: 'reference_images', videos: 'reference_videos', audios: 'reference_audios' };
|
|
98
|
+
}
|
|
99
|
+
/** Image-to-video follows the start image's frame on these families; the others take aspect_ratio. */
|
|
100
|
+
export function imageToVideoTakesAspectRatio(m) {
|
|
101
|
+
return m.family === 'seedance-2.0' || m.family === 'wan-3.0';
|
|
102
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Turn a generate/edit request into the endpoint and input to submit: the
|
|
2
|
+
// model choice, the endpoint for the inputs given, and the input fitted to
|
|
3
|
+
// that endpoint's live schema. A port of /chat's runImage and proposeVideo
|
|
4
|
+
// (api-dashboard lib/chat/server.ts) without the chat session around them, so
|
|
5
|
+
// the same words give the same model, endpoint and input on both.
|
|
6
|
+
import { DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, IMAGE_MODELS, IMAGE_MODEL_KEYS, VIDEO_MODELS, VIDEO_MODEL_KEYS, referenceFields, videoEndpoints, } from './media-models.js';
|
|
7
|
+
import { renameRefs } from './prompt-refs.js';
|
|
8
|
+
/** Keep only fields the model accepts, snapping values onto its enums and ranges. */
|
|
9
|
+
export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
|
|
10
|
+
const properties = await propsOf(modelId);
|
|
11
|
+
if (!properties)
|
|
12
|
+
throw new Error(`No schema for ${modelId} in the model catalog.`);
|
|
13
|
+
const input = {};
|
|
14
|
+
// Spell out the defaults that set the price, so the estimate shows them.
|
|
15
|
+
for (const key of showDefaults) {
|
|
16
|
+
if (properties[key]?.default !== undefined && (wanted[key] === undefined || wanted[key] === null || wanted[key] === '')) {
|
|
17
|
+
wanted = { ...wanted, [key]: properties[key].default };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
for (const [key, value] of Object.entries(wanted)) {
|
|
21
|
+
const prop = properties[key];
|
|
22
|
+
if (!prop || value === undefined || value === null || value === '')
|
|
23
|
+
continue;
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
if (!value.length)
|
|
26
|
+
continue;
|
|
27
|
+
input[key] = prop.maxItems ? value.slice(0, prop.maxItems) : value;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (prop.enum?.length) {
|
|
31
|
+
if (prop.enum.includes(value))
|
|
32
|
+
input[key] = value;
|
|
33
|
+
else if (typeof value === 'number' && prop.enum.every((v) => typeof v === 'number')) {
|
|
34
|
+
input[key] = prop.enum.reduce((a, b) => (Math.abs(b - value) < Math.abs(a - value) ? b : a));
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number' && (prop.type === 'integer' || prop.type === 'number')) {
|
|
39
|
+
let v = prop.type === 'integer' ? Math.round(value) : value;
|
|
40
|
+
if (typeof prop.minimum === 'number')
|
|
41
|
+
v = Math.max(prop.minimum, v);
|
|
42
|
+
if (typeof prop.maximum === 'number')
|
|
43
|
+
v = Math.min(prop.maximum, v);
|
|
44
|
+
input[key] = v;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
input[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return input;
|
|
50
|
+
}
|
|
51
|
+
function imageModelKey(named) {
|
|
52
|
+
if (named === undefined || named === '')
|
|
53
|
+
return DEFAULT_IMAGE_MODEL;
|
|
54
|
+
if (IMAGE_MODEL_KEYS.includes(named))
|
|
55
|
+
return named;
|
|
56
|
+
throw new Error(`Unknown image model "${named}". Choose one of: ${IMAGE_MODEL_KEYS.join(', ')}.`);
|
|
57
|
+
}
|
|
58
|
+
/** Images go to the model's edit endpoint whenever any image is given, and to text-to-image otherwise. */
|
|
59
|
+
export async function planImage(args, propsOf) {
|
|
60
|
+
const model = IMAGE_MODELS[imageModelKey(args.model)];
|
|
61
|
+
const images = [...(args.images ?? []), ...(args.reference_images ?? [])];
|
|
62
|
+
const edit = images.length > 0;
|
|
63
|
+
const modelId = edit ? model.edit : model.generate;
|
|
64
|
+
const wanted = { prompt: args.prompt, aspect_ratio: args.aspect_ratio, resolution: args.resolution };
|
|
65
|
+
if (edit)
|
|
66
|
+
wanted.images = images;
|
|
67
|
+
const input = await fitInput(modelId, wanted, propsOf);
|
|
68
|
+
if (typeof input.prompt === 'string')
|
|
69
|
+
input.prompt = renameRefs(input.prompt, input, modelId);
|
|
70
|
+
const notes = [];
|
|
71
|
+
if (edit && Array.isArray(input.images) && input.images.length < images.length) {
|
|
72
|
+
notes.push(`${model.label} takes at most ${input.images.length} images, so only the first ${input.images.length} were used.`);
|
|
73
|
+
}
|
|
74
|
+
return { modelId, label: model.label, mode: edit ? 'edit' : 'generate', input, notes };
|
|
75
|
+
}
|
|
76
|
+
const MODE_LABEL = { text: 'text to video', image: 'image to video', reference: 'reference to video', edit: 'video edit', extend: 'video extend' };
|
|
77
|
+
// Reference media goes under reference_* on most models and under images, videos, audios on Kling 4.0 (referenceFields).
|
|
78
|
+
const OPTIONAL_INPUTS = ['reference_images', 'reference_videos', 'reference_audios', 'images', 'videos', 'audios', 'last_image'];
|
|
79
|
+
function videoModelKey(named) {
|
|
80
|
+
if (named === undefined || named === '')
|
|
81
|
+
return DEFAULT_VIDEO_MODEL;
|
|
82
|
+
if (VIDEO_MODEL_KEYS.includes(named))
|
|
83
|
+
return named;
|
|
84
|
+
throw new Error(`Unknown video model "${named}". Choose one of: ${VIDEO_MODEL_KEYS.join(', ')}.`);
|
|
85
|
+
}
|
|
86
|
+
/** Which allowed video model + mode accepts every one of these inputs, read from their schemas. */
|
|
87
|
+
async function whoTakes(fields, propsOf) {
|
|
88
|
+
const options = [];
|
|
89
|
+
for (const key of VIDEO_MODEL_KEYS) {
|
|
90
|
+
const e = videoEndpoints(VIDEO_MODELS[key]);
|
|
91
|
+
for (const [mode, id] of [['text to video', e.text], ['image to video', e.image], ['reference to video', e.reference]]) {
|
|
92
|
+
if (!id)
|
|
93
|
+
continue;
|
|
94
|
+
const props = await propsOf(id).catch(() => null);
|
|
95
|
+
if (props && fields.every((f) => props[f]))
|
|
96
|
+
options.push(`${key} (${mode})`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return options.length ? `Models that take them: ${options.join('; ')}.` : 'No available video model takes them.';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Pick the model + mode for a video. Candidates are tried in order and the
|
|
103
|
+
* first whose schema takes every input given wins: an exact first frame
|
|
104
|
+
* (image to video), then a dedicated reference-to-video endpoint, then text to
|
|
105
|
+
* video with the references. Other models are tried only when the caller did
|
|
106
|
+
* not name one.
|
|
107
|
+
*/
|
|
108
|
+
export async function planVideo(args, action, propsOf) {
|
|
109
|
+
const firstFrame = action === 'generate' ? args.first_frame : undefined;
|
|
110
|
+
const lastFrame = action !== 'edit' ? args.last_frame : undefined;
|
|
111
|
+
const video = action !== 'generate' ? args.video : undefined;
|
|
112
|
+
const refImages = args.reference_images?.length ? args.reference_images : undefined;
|
|
113
|
+
const refVideos = action === 'generate' && args.reference_videos?.length ? args.reference_videos : undefined;
|
|
114
|
+
const refAudios = args.reference_audios?.length ? args.reference_audios : undefined;
|
|
115
|
+
const pinned = args.model !== undefined && args.model !== '';
|
|
116
|
+
const first = videoModelKey(args.model);
|
|
117
|
+
const keys = pinned ? [first] : [first, ...VIDEO_MODEL_KEYS.filter((k) => k !== first)];
|
|
118
|
+
const candidates = [];
|
|
119
|
+
for (const key of keys) {
|
|
120
|
+
// H3's native 768p tier is the counterpart of other models' 720p default.
|
|
121
|
+
// Keep explicit supported choices, including cheaper drafts, intact.
|
|
122
|
+
const resolution = key === 'minimax-h3' && (args.resolution == null || args.resolution === '' || args.resolution === '720p') ? '768p' : args.resolution;
|
|
123
|
+
const base = { prompt: args.prompt, resolution };
|
|
124
|
+
const names = referenceFields(VIDEO_MODELS[key]);
|
|
125
|
+
const refs = { [names.images]: refImages, [names.videos]: refVideos, [names.audios]: refAudios };
|
|
126
|
+
if (action === 'edit') {
|
|
127
|
+
candidates.push({ key, mode: 'edit', wanted: { ...base, video, [names.images]: refImages, [names.audios]: refAudios }, lost: [] });
|
|
128
|
+
}
|
|
129
|
+
else if (action === 'extend') {
|
|
130
|
+
candidates.push({ key, mode: 'extend', wanted: { ...base, video, duration: args.duration, last_image: lastFrame }, lost: [] });
|
|
131
|
+
}
|
|
132
|
+
else if (firstFrame) {
|
|
133
|
+
candidates.push({ key, mode: 'image', wanted: { ...base, duration: args.duration, image: firstFrame, last_image: lastFrame, ...refs }, lost: [] });
|
|
134
|
+
// No exact first frame: the start image becomes a reference instead.
|
|
135
|
+
const asRef = { ...base, duration: args.duration, aspect_ratio: args.aspect_ratio, ...refs, [names.images]: [firstFrame, ...(refImages ?? [])] };
|
|
136
|
+
const lost = lastFrame ? ['exact first/last frame'] : ['exact first frame'];
|
|
137
|
+
candidates.push({ key, mode: 'reference', wanted: asRef, lost });
|
|
138
|
+
candidates.push({ key, mode: 'text', wanted: asRef, lost });
|
|
139
|
+
}
|
|
140
|
+
else if (refImages || refVideos || refAudios) {
|
|
141
|
+
const withRefs = { ...base, duration: args.duration, aspect_ratio: args.aspect_ratio, ...refs };
|
|
142
|
+
candidates.push({ key, mode: 'reference', wanted: withRefs, lost: [] });
|
|
143
|
+
candidates.push({ key, mode: 'text', wanted: withRefs, lost: [] });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
candidates.push({ key, mode: 'text', wanted: { ...base, duration: args.duration, aspect_ratio: args.aspect_ratio }, lost: [] });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const endpoints = (key) => videoEndpoints(VIDEO_MODELS[key]);
|
|
150
|
+
const usable = candidates.filter((c) => endpoints(c.key)[c.mode]);
|
|
151
|
+
if (!usable.length) {
|
|
152
|
+
// Turbo tiers have no extend endpoint.
|
|
153
|
+
throw new Error(`${VIDEO_MODELS[first].label} cannot ${action} videos. Try another video model, such as ${DEFAULT_VIDEO_MODEL}.`);
|
|
154
|
+
}
|
|
155
|
+
const needed = (c) => OPTIONAL_INPUTS.filter((k) => c.wanted[k] !== undefined);
|
|
156
|
+
const takesAll = async (c) => {
|
|
157
|
+
const props = await propsOf(endpoints(c.key)[c.mode]).catch(() => null);
|
|
158
|
+
return !!props && needed(c).every((k) => props[k]);
|
|
159
|
+
};
|
|
160
|
+
let chosen = usable[0];
|
|
161
|
+
for (const c of usable) {
|
|
162
|
+
if (await takesAll(c)) {
|
|
163
|
+
chosen = c;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const model = VIDEO_MODELS[chosen.key];
|
|
168
|
+
const modelId = endpoints(chosen.key)[chosen.mode];
|
|
169
|
+
// Image-to-video follows the first frame's shape: no aspect_ratio, not even the schema default.
|
|
170
|
+
const input = await fitInput(modelId, chosen.wanted, propsOf, chosen.mode === 'image' ? ['duration', 'resolution'] : ['duration', 'resolution', 'aspect_ratio']);
|
|
171
|
+
if (chosen.mode === 'image')
|
|
172
|
+
delete input.aspect_ratio;
|
|
173
|
+
if (typeof input.prompt === 'string')
|
|
174
|
+
input.prompt = renameRefs(input.prompt, input, modelId);
|
|
175
|
+
const dropped = needed(chosen).filter((k) => input[k] === undefined);
|
|
176
|
+
const notes = [];
|
|
177
|
+
if (chosen.key !== usable[0].key || chosen.lost.length) {
|
|
178
|
+
notes.push(`Used ${model.label} ${MODE_LABEL[chosen.mode]} so that ${needed(chosen).join(', ') || 'the inputs'} could be used` +
|
|
179
|
+
(chosen.lost.length ? `; this means no ${chosen.lost.join(', ')}: the start image is used as a visual reference` : '') +
|
|
180
|
+
'.');
|
|
181
|
+
}
|
|
182
|
+
if (dropped.length) {
|
|
183
|
+
notes.push(pinned
|
|
184
|
+
? `${model.label} does not take ${dropped.join(', ')} in any mode, so those were left out; the model you named was not switched. ${await whoTakes(dropped, propsOf)}`
|
|
185
|
+
: `No available video model takes ${dropped.join(', ')} for this request, so those were left out.`);
|
|
186
|
+
}
|
|
187
|
+
return { modelId, label: model.label, mode: MODE_LABEL[chosen.mode], input, notes };
|
|
188
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// How a prompt names its input media. Callers write "@image 1", "@video 1",
|
|
2
|
+
// "@audio 1" (the older "@image1" also works); each model documents its own
|
|
3
|
+
// way to refer to them, so before submitting we swap the caller's names for
|
|
4
|
+
// the model's. A subset of api-dashboard lib/prompt-refs.ts: the syntaxes of
|
|
5
|
+
// the models the generate/edit tools offer. Models that document none (the
|
|
6
|
+
// image models) get plain words ("Image 1").
|
|
7
|
+
const word = (kind) => kind[0].toUpperCase() + kind.slice(1);
|
|
8
|
+
// Vendor docs, as researched for /chat on 2026-09-28.
|
|
9
|
+
const SYNTAXES = [
|
|
10
|
+
[/^wavespeed-ai\/minimax-h3(-[a-z0-9]+)?\//, { tag: (k, n) => `<${k === 'image' ? 'Picture' : word(k)} ${n}>`, inputVideo: null }],
|
|
11
|
+
[/^kwaivgi\/kling-v4\.0\/(reference-to-video|video-edit)/, { tag: (k, n) => `@${word(k)}${n}` }],
|
|
12
|
+
[/^alibaba\/wan-3\.0/, { tag: (k, n) => `${word(k)} ${n}` }],
|
|
13
|
+
[/^bytedance\/seedance-2\.[05]/, { tag: (k, n) => `@${word(k)} ${n}` }],
|
|
14
|
+
];
|
|
15
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
16
|
+
/** The media of a request in the order models number them: the edited video, the image and reference lists, then the frames. */
|
|
17
|
+
export function promptRefs(input, modelId) {
|
|
18
|
+
const syntax = SYNTAXES.find(([pattern]) => pattern.test(modelId))?.[1];
|
|
19
|
+
const refs = [];
|
|
20
|
+
const labels = { image: 0, video: 0, audio: 0 };
|
|
21
|
+
const counts = { image: 0, video: 0, audio: 0 };
|
|
22
|
+
const plainCounts = { image: 0, video: 0, audio: 0 };
|
|
23
|
+
const add = (url, kind, role) => {
|
|
24
|
+
if (typeof url !== 'string' || !url || refs.some((r) => r.url === url))
|
|
25
|
+
return;
|
|
26
|
+
labels[kind] += 1;
|
|
27
|
+
let name;
|
|
28
|
+
if (role && role.name !== undefined)
|
|
29
|
+
name = role.name;
|
|
30
|
+
else if (!syntax)
|
|
31
|
+
name = null;
|
|
32
|
+
else {
|
|
33
|
+
counts[kind] += 1;
|
|
34
|
+
name = syntax.tag(kind, counts[kind]);
|
|
35
|
+
}
|
|
36
|
+
const plain = role?.plain ?? `${word(kind)} ${++plainCounts[kind]}`;
|
|
37
|
+
refs.push({ label: `${kind}${labels[kind]}`, kind, url, name, plain });
|
|
38
|
+
};
|
|
39
|
+
add(input.video, 'video', syntax && syntax.inputVideo !== undefined ? { name: syntax.inputVideo, plain: 'the input video' } : undefined);
|
|
40
|
+
list(input.images).forEach((u) => add(u, 'image'));
|
|
41
|
+
list(input.reference_images).forEach((u) => add(u, 'image'));
|
|
42
|
+
list(input.reference_videos).concat(list(input.videos)).forEach((u) => add(u, 'video'));
|
|
43
|
+
list(input.reference_audios).forEach((u) => add(u, 'audio'));
|
|
44
|
+
list(input.audios).forEach((u) => add(u, 'audio'));
|
|
45
|
+
add(input.image, 'image', { name: null, plain: 'the first frame' });
|
|
46
|
+
add(input.last_image, 'image', { name: null, plain: 'the last frame' });
|
|
47
|
+
return refs;
|
|
48
|
+
}
|
|
49
|
+
// "@image 1" or "@image1", in any case; "image1" without the @ is not a ref here.
|
|
50
|
+
const PROMPT_REF_PATTERN = /@(image|video|audio) ?(\d+)\b/gi;
|
|
51
|
+
/** Swap the caller's @image 1-style names for the model's documented terms (or plain words); refs the request does not carry stay as typed. */
|
|
52
|
+
export function renameRefs(prompt, input, modelId) {
|
|
53
|
+
const refs = new Map(promptRefs(input, modelId).map((r) => [r.label, r]));
|
|
54
|
+
return prompt.replace(PROMPT_REF_PATTERN, (whole, kind, n) => {
|
|
55
|
+
const ref = refs.get(`${kind.toLowerCase()}${n}`);
|
|
56
|
+
return ref ? (ref.name ?? ref.plain) : whole;
|
|
57
|
+
});
|
|
58
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// WaveSpeed MCP server — tools mirror the wavespeed CLI's verbs.
|
|
2
2
|
//
|
|
3
3
|
// Design (shared with wavespeed-cli):
|
|
4
|
-
// * The live /api/v3/models catalog is the only model source
|
|
5
|
-
//
|
|
6
|
-
// work the day they ship.
|
|
4
|
+
// * The live /api/v3/models catalog is the only model source for `run_model`:
|
|
5
|
+
// no hardcoded per-model tool, so new platform models work the day they ship.
|
|
7
6
|
// * One generation verb (`run_model`), driven by per-model schemas exposed
|
|
8
7
|
// through `get_model_schema`.
|
|
8
|
+
// * Five shortcut verbs (generate_image, edit_image, generate_video,
|
|
9
|
+
// edit_video, extend_video) pick the endpoint and fit the input for the
|
|
10
|
+
// models the WaveSpeed /chat page offers (lib/media-models.ts). They submit
|
|
11
|
+
// through the same path as `run_model`.
|
|
9
12
|
// * Inputs are never mutated. The one explicit transform is the `@path`
|
|
10
13
|
// marker, which uploads the referenced local file and substitutes its
|
|
11
14
|
// hosted URL. Bare paths pass through untouched.
|
|
@@ -23,6 +26,8 @@ const TOP_UP_URL = 'https://wavespeed.ai/top-up';
|
|
|
23
26
|
import { resolveLocalFiles } from './lib/local-files.js';
|
|
24
27
|
import { uploadWithCache } from './lib/upload-cache.js';
|
|
25
28
|
import { findUnknownInputs } from './lib/validate-inputs.js';
|
|
29
|
+
import { IMAGE_MODEL_KEYS, VIDEO_MODEL_KEYS } from './lib/media-models.js';
|
|
30
|
+
import { planImage, planVideo } from './lib/media-plan.js';
|
|
26
31
|
const require = createRequire(import.meta.url);
|
|
27
32
|
const { version: PKG_VERSION } = require('../package.json');
|
|
28
33
|
function toIso(value, name) {
|
|
@@ -108,6 +113,42 @@ async function assertKnownInputs(model, input) {
|
|
|
108
113
|
`inputs and still bills the run, so nothing was submitted. Accepted inputs: ` +
|
|
109
114
|
`${report.known.join(', ')}. See get_model_schema.`);
|
|
110
115
|
}
|
|
116
|
+
// Input properties of an endpoint from the live catalog, for fitting a
|
|
117
|
+
// request to what the model accepts. Null when the catalog has no schema.
|
|
118
|
+
async function catalogProps(modelId) {
|
|
119
|
+
const model = (await fetchModels()).find((m) => m.model_id === modelId);
|
|
120
|
+
const props = model && requestSchema(model)?.properties;
|
|
121
|
+
return props ? props : null;
|
|
122
|
+
}
|
|
123
|
+
// Wait for a submitted prediction up to wait_seconds and report it. Shared by
|
|
124
|
+
// run_model and the shortcut verbs so a run reads the same whichever started it.
|
|
125
|
+
async function reportPrediction(submitted, model, waitSeconds, extra, started,
|
|
126
|
+
// always: in every reply; waited: only when the call waited for the result.
|
|
127
|
+
more = {}) {
|
|
128
|
+
if (waitSeconds === 0) {
|
|
129
|
+
return ok({ id: submitted.id, status: submitted.status, model, done: false, ...more.always, next: STILL_RUNNING_HINT });
|
|
130
|
+
}
|
|
131
|
+
const { item, done } = await waitForPrediction(submitted.id, {
|
|
132
|
+
intervalMs: POLL_INTERVAL_MS,
|
|
133
|
+
timeoutMs: waitSeconds * 1000,
|
|
134
|
+
onTick: progressTicker(extra, submitted.id, started),
|
|
135
|
+
signal: extra.signal,
|
|
136
|
+
});
|
|
137
|
+
if (TERMINAL_FAILURES.includes(item.status)) {
|
|
138
|
+
throw new Error(`Prediction ${item.status}${item.error ? `: ${item.error}` : ''} (task_id: ${submitted.id})`);
|
|
139
|
+
}
|
|
140
|
+
return ok({
|
|
141
|
+
id: submitted.id,
|
|
142
|
+
model,
|
|
143
|
+
status: item.status,
|
|
144
|
+
done,
|
|
145
|
+
outputs: item.outputs ?? [],
|
|
146
|
+
elapsed_ms: Date.now() - started,
|
|
147
|
+
...more.waited,
|
|
148
|
+
...more.always,
|
|
149
|
+
...(done ? {} : { next: STILL_RUNNING_HINT }),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
111
152
|
function ok(payload) {
|
|
112
153
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
113
154
|
}
|
|
@@ -119,6 +160,10 @@ export function createServer() {
|
|
|
119
160
|
'inputs, run_model to execute. Reference local files as "@./path"',
|
|
120
161
|
'values inside input — they upload automatically; bare paths are',
|
|
121
162
|
'passed through untouched and will fail model validation.',
|
|
163
|
+
'For plain image or video generation, generate_image, edit_image,',
|
|
164
|
+
'generate_video, edit_video and extend_video pick the model and inputs',
|
|
165
|
+
'for you (the models of the WaveSpeed chat page); pass estimate_only to',
|
|
166
|
+
'get the price without running.',
|
|
122
167
|
'Use get_price before expensive runs. Never invent model IDs or input keys.',
|
|
123
168
|
'Long generations (video, some audio/3D) can run for hours: when',
|
|
124
169
|
'run_model returns done=false, keep the id and poll get_prediction.',
|
|
@@ -185,28 +230,142 @@ export function createServer() {
|
|
|
185
230
|
await assertKnownInputs(model, resolved.input);
|
|
186
231
|
const started = Date.now();
|
|
187
232
|
const submitted = await submitPrediction(model, resolved.input);
|
|
188
|
-
|
|
189
|
-
|
|
233
|
+
return reportPrediction(submitted, model, wait_seconds, extra, started, { waited: { uploaded_files: resolved.uploaded } });
|
|
234
|
+
});
|
|
235
|
+
// ---- Shortcut verbs: the models the /chat page offers, endpoint and input
|
|
236
|
+
// chosen and fitted for the caller (see lib/media-plan.ts). They submit and
|
|
237
|
+
// wait exactly like run_model, and estimate_only quotes without running.
|
|
238
|
+
const waitSchema = z
|
|
239
|
+
.number()
|
|
240
|
+
.int()
|
|
241
|
+
.min(0)
|
|
242
|
+
.max(1800)
|
|
243
|
+
.default(DEFAULT_WAIT_SECONDS)
|
|
244
|
+
.describe(`Max seconds to wait for the result in this call (default ${DEFAULT_WAIT_SECONDS}: return the prediction id right away)`);
|
|
245
|
+
const estimateSchema = z
|
|
246
|
+
.boolean()
|
|
247
|
+
.default(false)
|
|
248
|
+
.describe('true: only return the price estimate and the request that would run. Nothing is submitted or charged.');
|
|
249
|
+
const mediaList = (what) => z.array(z.string()).describe(`${what}: public URLs, or "@./path" for local files (uploaded automatically).`);
|
|
250
|
+
const REFS = 'In the prompt, name input media as @image 1, @image 2, @video 1, @audio 1 (counted in the order the lists are given); they are renamed to what the model expects.';
|
|
251
|
+
const shortcutAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
252
|
+
async function runPlan(plan, opts, extra, uploaded) {
|
|
253
|
+
let estimate = null;
|
|
254
|
+
let estimateError;
|
|
255
|
+
try {
|
|
256
|
+
estimate = await fetchPricing(plan.modelId, plan.input);
|
|
190
257
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
timeoutMs: wait_seconds * 1000,
|
|
194
|
-
onTick: progressTicker(extra, submitted.id, started),
|
|
195
|
-
signal: extra.signal,
|
|
196
|
-
});
|
|
197
|
-
if (TERMINAL_FAILURES.includes(item.status)) {
|
|
198
|
-
throw new Error(`Prediction ${item.status}${item.error ? `: ${item.error}` : ''} (task_id: ${submitted.id})`);
|
|
258
|
+
catch (err) {
|
|
259
|
+
estimateError = err?.message ?? String(err);
|
|
199
260
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
261
|
+
const summary = {
|
|
262
|
+
model_label: plan.label,
|
|
263
|
+
mode: plan.mode,
|
|
264
|
+
estimate,
|
|
265
|
+
...(estimateError ? { estimate_error: estimateError } : {}),
|
|
266
|
+
...(plan.notes.length ? { notes: plan.notes } : {}),
|
|
267
|
+
};
|
|
268
|
+
if (opts.estimate_only) {
|
|
269
|
+
return ok({
|
|
270
|
+
estimate_only: true,
|
|
271
|
+
model: plan.modelId,
|
|
272
|
+
input: plan.input,
|
|
273
|
+
...summary,
|
|
274
|
+
disclaimer: PRICE_DISCLAIMER,
|
|
275
|
+
playground_url: modelPlaygroundUrl(plan.modelId),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const started = Date.now();
|
|
279
|
+
const submitted = await submitPrediction(plan.modelId, plan.input);
|
|
280
|
+
return reportPrediction(submitted, plan.modelId, opts.wait_seconds, extra, started, {
|
|
281
|
+
always: summary,
|
|
282
|
+
waited: { uploaded_files: uploaded },
|
|
209
283
|
});
|
|
284
|
+
}
|
|
285
|
+
// Replace @path values in the media arguments with hosted URLs. The prompt is
|
|
286
|
+
// not scanned: a prompt that starts with "@image 1" is not a file.
|
|
287
|
+
async function withUploads(args, keys) {
|
|
288
|
+
const media = {};
|
|
289
|
+
for (const k of keys)
|
|
290
|
+
if (args[k] !== undefined)
|
|
291
|
+
media[k] = args[k];
|
|
292
|
+
const resolved = await resolveLocalFiles(media, { upload: async (p) => (await uploadFile(p)).url });
|
|
293
|
+
return { args: { ...args, ...resolved.input }, uploaded: resolved.uploaded };
|
|
294
|
+
}
|
|
295
|
+
const imageShape = {
|
|
296
|
+
prompt: z.string().min(1).describe('Complete visual prompt: subject, composition, style, lighting, mood.'),
|
|
297
|
+
model: z.enum(IMAGE_MODEL_KEYS).optional().describe('Only when the user names a model; default is the first (Seedream 5 Pro).'),
|
|
298
|
+
aspect_ratio: z.string().optional().describe('e.g. 1:1, 16:9, 9:16, 4:3, 3:4; snapped to what the model supports. If omitted: text-to-image is 1:1; with input images it is determined automatically from the input image.'),
|
|
299
|
+
resolution: z.enum(['1k', '2k', '4k']).optional().describe('Only when the user asks for a size.'),
|
|
300
|
+
wait_seconds: waitSchema,
|
|
301
|
+
estimate_only: estimateSchema,
|
|
302
|
+
};
|
|
303
|
+
const IMAGE_KEYS = ['images', 'reference_images'];
|
|
304
|
+
server.tool('generate_image', `Create an image from a text description with the same models as the WaveSpeed /chat page (${IMAGE_MODEL_KEYS.join(', ')}; default ${IMAGE_MODEL_KEYS[0]}). Give reference_images to use existing images as subject, product, face or style (the model's edit endpoint is used). To change existing images use edit_image. ${REFS}`, { ...imageShape, reference_images: mediaList('Optional images whose subject, product, face or style the new image should use').optional() }, shortcutAnnotations, async (raw, extra) => {
|
|
305
|
+
const { args, uploaded } = await withUploads(raw, IMAGE_KEYS);
|
|
306
|
+
const plan = await planImage(args, catalogProps);
|
|
307
|
+
return runPlan(plan, { estimate_only: raw.estimate_only, wait_seconds: raw.wait_seconds }, extra, uploaded);
|
|
308
|
+
});
|
|
309
|
+
server.tool('edit_image', `Change existing images with the same models as the WaveSpeed /chat page: edits, restyles, backgrounds, adding or removing things, combining several images. ${REFS}`, { ...imageShape, images: mediaList('The images to edit, in order').min(1) }, shortcutAnnotations, async (raw, extra) => {
|
|
310
|
+
const { args, uploaded } = await withUploads(raw, IMAGE_KEYS);
|
|
311
|
+
const plan = await planImage(args, catalogProps);
|
|
312
|
+
return runPlan(plan, { estimate_only: raw.estimate_only, wait_seconds: raw.wait_seconds }, extra, uploaded);
|
|
313
|
+
});
|
|
314
|
+
const videoModelSchema = z
|
|
315
|
+
.enum(VIDEO_MODEL_KEYS)
|
|
316
|
+
.optional()
|
|
317
|
+
.describe(`Only when the user names a model (${VIDEO_MODEL_KEYS.join(', ')}). Without it the default (${VIDEO_MODEL_KEYS[0]}) is used, and another model only when it cannot take the inputs.`);
|
|
318
|
+
const resolutionSchema = z
|
|
319
|
+
.enum(['480p', '540p', '720p', '768p', '1080p'])
|
|
320
|
+
.optional()
|
|
321
|
+
.describe('Only when the user asks for a resolution, or 480p for a cheaper draft; otherwise omit. MiniMax H3 defaults to 768p (its equivalent of 720p).');
|
|
322
|
+
const durationSchema = z.number().int().optional().describe('Seconds, only when the user asks for a length; omit to use the model default.');
|
|
323
|
+
const VIDEO_KEYS = ['first_frame', 'last_frame', 'reference_images', 'reference_videos', 'reference_audios', 'video'];
|
|
324
|
+
server.tool('generate_video', `Create a video with the same models as the WaveSpeed /chat page (default ${VIDEO_MODEL_KEYS[0]}). Give first_frame to animate an image; give reference_images / reference_videos / reference_audios to keep characters, products, motion, voice or music. The right mode (text, image or reference to video) is picked from what you give. Videos can cost dollars: use estimate_only first when unsure. ${REFS}`, {
|
|
325
|
+
prompt: z.string().min(1).describe('Shot description: subject, action, camera movement, style, sound.'),
|
|
326
|
+
model: videoModelSchema,
|
|
327
|
+
first_frame: z.string().optional().describe('Image to start the video from (URL or "@./path").'),
|
|
328
|
+
last_frame: z.string().optional().describe('Optional image to end on (needs first_frame).'),
|
|
329
|
+
reference_images: mediaList('Optional images for characters, products or style to keep (not a first frame)').optional(),
|
|
330
|
+
reference_videos: mediaList('Optional videos for motion, camera or a character to follow').optional(),
|
|
331
|
+
reference_audios: mediaList('Optional audio for voice, music or sound to use').optional(),
|
|
332
|
+
duration: durationSchema,
|
|
333
|
+
resolution: resolutionSchema,
|
|
334
|
+
aspect_ratio: z.enum(['16:9', '9:16', '1:1', '4:3', '3:4', '21:9']).optional().describe("If omitted, the model's default ratio is used; with first_frame the video follows that image's shape and this is ignored."),
|
|
335
|
+
wait_seconds: waitSchema,
|
|
336
|
+
estimate_only: estimateSchema,
|
|
337
|
+
}, shortcutAnnotations, async (raw, extra) => {
|
|
338
|
+
const { args, uploaded } = await withUploads(raw, VIDEO_KEYS);
|
|
339
|
+
const plan = await planVideo(args, 'generate', catalogProps);
|
|
340
|
+
return runPlan(plan, { estimate_only: raw.estimate_only, wait_seconds: raw.wait_seconds }, extra, uploaded);
|
|
341
|
+
});
|
|
342
|
+
server.tool('edit_video', `Edit an existing video with the same models as the WaveSpeed /chat page. Name the video and any references in the prompt (e.g. "replace the cat in @video 1 with the lion from @image 1"). ${REFS}`, {
|
|
343
|
+
prompt: z.string().min(1).describe('What to change in the video and what must stay.'),
|
|
344
|
+
video: z.string().describe('The video to edit (URL or "@./path").'),
|
|
345
|
+
reference_images: mediaList('Optional images to use as references').optional(),
|
|
346
|
+
reference_audios: mediaList('Optional audio for voice, music or sound').optional(),
|
|
347
|
+
model: videoModelSchema,
|
|
348
|
+
resolution: resolutionSchema,
|
|
349
|
+
wait_seconds: waitSchema,
|
|
350
|
+
estimate_only: estimateSchema,
|
|
351
|
+
}, shortcutAnnotations, async (raw, extra) => {
|
|
352
|
+
const { args, uploaded } = await withUploads(raw, VIDEO_KEYS);
|
|
353
|
+
const plan = await planVideo(args, 'edit', catalogProps);
|
|
354
|
+
return runPlan(plan, { estimate_only: raw.estimate_only, wait_seconds: raw.wait_seconds }, extra, uploaded);
|
|
355
|
+
});
|
|
356
|
+
server.tool('extend_video', `Continue an existing video with new footage after its end, with the same models as the WaveSpeed /chat page. Turbo models cannot extend. ${REFS}`, {
|
|
357
|
+
prompt: z.string().min(1).describe('What happens next.'),
|
|
358
|
+
video: z.string().describe('The video to continue (URL or "@./path").'),
|
|
359
|
+
last_frame: z.string().optional().describe('Optional image the continuation should end on.'),
|
|
360
|
+
duration: durationSchema.describe('Seconds of new footage; omit to use the model default.'),
|
|
361
|
+
model: videoModelSchema,
|
|
362
|
+
resolution: resolutionSchema,
|
|
363
|
+
wait_seconds: waitSchema,
|
|
364
|
+
estimate_only: estimateSchema,
|
|
365
|
+
}, shortcutAnnotations, async (raw, extra) => {
|
|
366
|
+
const { args, uploaded } = await withUploads(raw, VIDEO_KEYS);
|
|
367
|
+
const plan = await planVideo(args, 'extend', catalogProps);
|
|
368
|
+
return runPlan(plan, { estimate_only: raw.estimate_only, wait_seconds: raw.wait_seconds }, extra, uploaded);
|
|
210
369
|
});
|
|
211
370
|
server.tool('get_price', 'Estimate the cost of a run before executing it (no charge). Provide the same input you would pass to run_model — pricing often depends on inputs like duration or resolution, and a quote made without them can be lower than the real charge.', {
|
|
212
371
|
model: z.string().describe('Model ID from list_models'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavespeed/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "WaveSpeed MCP server — run any model on the live WaveSpeed catalog (image, video, audio, 3D) with schema introspection, local-file upload, and price quotes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|