@wavespeed/mcp 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WaveSpeedAI
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,71 @@
1
+ # WaveSpeed MCP Server
2
+
3
+ [中文](README.zh.md)
4
+
5
+ <!-- mcp-name: ai.wavespeed/mcp -->
6
+
7
+ An MCP server for the [WaveSpeed](https://wavespeed.ai) platform: run any model on the **live catalog** — image, video, audio, 3D — from Claude Code, Claude Desktop, Cursor, Cline, or any MCP client.
8
+
9
+ Built on the same design as the open-source [`@wavespeed/cli`](https://github.com/WaveSpeedAI/wavespeed-cli):
10
+
11
+ - **No hardcoded models.** The catalog comes from the live API (1h cache); new platform models work the day they ship.
12
+ - **One generation verb.** `run_model` executes anything; `get_model_schema` tells the agent what inputs a model actually accepts, so it reads before it writes.
13
+ - **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.
14
+ - **Honest pricing.** `get_price` quotes before you spend and names the inputs the quote was blind to (`unpriced_inputs`) instead of presenting a formula's floor as "the" price.
15
+
16
+ ## Tools
17
+
18
+ | Tool | What it does |
19
+ |---|---|
20
+ | `list_models` | Search the live catalog by text or modality type |
21
+ | `get_model_schema` | A model's real input schema (required, properties, defaults) |
22
+ | `run_model` | Submit + wait; `@path` inputs upload automatically; returns output URLs |
23
+ | `get_price` | Cost estimate with `unpriced_inputs` / `at_base_price` disclosure |
24
+ | `get_balance` | Account credit balance |
25
+ | `upload_file` | Local file → hosted URL (24h content-hash dedupe) |
26
+ | `get_prediction` | Recover status/outputs of any run by id |
27
+
28
+ If `run_model` hits its wait limit the task keeps running server-side — the error names the prediction id, and `get_prediction` picks it up.
29
+
30
+ ## Setup
31
+
32
+ Auth resolves from `WAVESPEED_API_KEY`, or from the CLI's stored login (`wavespeed login`) — one login covers both tools. Keys: [wavespeed.ai/accesskey](https://wavespeed.ai/accesskey).
33
+
34
+ **Claude Code**
35
+
36
+ ```bash
37
+ claude mcp add wavespeed -- npx -y @wavespeed/mcp
38
+ ```
39
+
40
+ **Claude Desktop / other clients** (`mcpServers` config):
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "wavespeed": {
46
+ "command": "npx",
47
+ "args": ["-y", "@wavespeed/mcp"],
48
+ "env": { "WAVESPEED_API_KEY": "wsk_..." }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## Example prompts
55
+
56
+ - *"Generate a 16:9 hero image of a cyberpunk skyline at golden hour."*
57
+ - *"Take ./photo.jpg and replace the background with a sunlit kitchen."* (the agent passes `"@./photo.jpg"`)
58
+ - *"Animate ./hero.png with subtle parallax — check the price first."*
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ npm install
64
+ npm run dev # run from source (stdio)
65
+ npm run lint # typecheck
66
+ npm run build # tsc → dist/
67
+ ```
68
+
69
+ ## License
70
+
71
+ [MIT](LICENSE)
package/README.zh.md ADDED
@@ -0,0 +1,69 @@
1
+ # WaveSpeed MCP Server
2
+
3
+ [English](README.md)
4
+
5
+ [WaveSpeed](https://wavespeed.ai) 平台的 MCP server:在 Claude Code、Claude Desktop、Cursor、Cline 或任何 MCP 客户端里运行**实时目录**上的任意模型——图像、视频、音频、3D。
6
+
7
+ 与开源的 [`@wavespeed/cli`](https://github.com/WaveSpeedAI/wavespeed-cli) 共享同一套设计:
8
+
9
+ - **不硬编码模型。** 目录来自实时 API(1 小时缓存);平台新上的模型当天即可用。
10
+ - **一个生成动词。** `run_model` 执行一切;`get_model_schema` 告诉 agent 模型真正接受哪些输入——先读后写,不靠猜参数名。
11
+ - **绝不改写输入。** 唯一的显式转换是 `@path` 标记——`input` 里的 `"@./photo.jpg"` 会上传文件并替换成托管 URL。裸路径原样透传。
12
+ - **诚实报价。** `get_price` 在花钱前报价,并列出报价没有看到的输入(`unpriced_inputs`),而不是把公式的下限当成"价格"。
13
+
14
+ ## 工具
15
+
16
+ | 工具 | 作用 |
17
+ |---|---|
18
+ | `list_models` | 按文本或模态类型搜索实时目录 |
19
+ | `get_model_schema` | 模型的真实输入 schema(必填项、属性、默认值) |
20
+ | `run_model` | 提交并等待;`@path` 输入自动上传;返回输出 URL |
21
+ | `get_price` | 成本预估,附 `unpriced_inputs` / `at_base_price` 披露 |
22
+ | `get_balance` | 账户余额 |
23
+ | `upload_file` | 本地文件 → 托管 URL(24 小时内容哈希去重) |
24
+ | `get_prediction` | 按 id 找回任意一次运行的状态与输出 |
25
+
26
+ `run_model` 达到等待上限时任务在服务端继续跑——报错会给出 prediction id,用 `get_prediction` 接着取即可。
27
+
28
+ ## 配置
29
+
30
+ 认证按 `WAVESPEED_API_KEY` 环境变量,或复用 CLI 的登录状态(`wavespeed login`)——登录一次两个工具都能用。密钥:[wavespeed.ai/accesskey](https://wavespeed.ai/accesskey)。
31
+
32
+ **Claude Code**
33
+
34
+ ```bash
35
+ claude mcp add wavespeed -- npx -y @wavespeed/mcp
36
+ ```
37
+
38
+ **Claude Desktop / 其他客户端**(`mcpServers` 配置):
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "wavespeed": {
44
+ "command": "npx",
45
+ "args": ["-y", "@wavespeed/mcp"],
46
+ "env": { "WAVESPEED_API_KEY": "wsk_..." }
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## 示例提示词
53
+
54
+ - *「生成一张 16:9 的赛博朋克天际线主图,黄金时刻光线。」*
55
+ - *「把 ./photo.jpg 的背景换成阳光厨房。」*(agent 会传 `"@./photo.jpg"`)
56
+ - *「让 ./hero.png 动起来,轻微视差——先查一下价格。」*
57
+
58
+ ## 开发
59
+
60
+ ```bash
61
+ npm install
62
+ npm run dev # 源码直跑(stdio)
63
+ npm run lint # 类型检查
64
+ npm run build # tsc → dist/
65
+ ```
66
+
67
+ ## 许可
68
+
69
+ [MIT](LICENSE)
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // stdio entry point: `wavespeed-mcp` (or `npx -y @wavespeed/mcp`).
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { createServer } from './server.js';
5
+ async function main() {
6
+ const server = createServer();
7
+ const transport = new StdioServerTransport();
8
+ await server.connect(transport);
9
+ // stdout belongs to the MCP protocol; anything human goes to stderr.
10
+ console.error('wavespeed-mcp: connected (stdio)');
11
+ }
12
+ main().catch((err) => {
13
+ console.error(`wavespeed-mcp: fatal: ${err?.message ?? err}`);
14
+ process.exit(1);
15
+ });
@@ -0,0 +1,130 @@
1
+ // WaveSpeed REST helpers, ported from wavespeed-cli src/lib/api.ts so the two
2
+ // tools share one design: live /api/v3/models as the only catalog source
3
+ // (1h on-disk cache), explicit submit/poll split, and the platform's error
4
+ // envelope surfaced instead of bare status lines. Every endpoint here is v3
5
+ // and documented in the public api-doc — no internal or legacy contracts.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { getApiKey, getBaseUrl } from './config.js';
10
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
11
+ function cachePath() {
12
+ return path.join(os.homedir(), '.cache', 'wavespeed', 'models.json');
13
+ }
14
+ function readCache(baseUrl) {
15
+ try {
16
+ const parsed = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
17
+ if (parsed.base_url !== baseUrl)
18
+ return null;
19
+ if (Date.now() - parsed.fetched_at > CACHE_TTL_MS)
20
+ return null;
21
+ return parsed.models;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function writeCache(baseUrl, models) {
28
+ try {
29
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
30
+ const body = { fetched_at: Date.now(), base_url: baseUrl, models };
31
+ fs.writeFileSync(cachePath(), JSON.stringify(body));
32
+ }
33
+ catch {
34
+ /* best-effort cache; ignore failures */
35
+ }
36
+ }
37
+ // Non-2xx responses still carry the platform's error envelope, and for
38
+ // permission failures that body is the whole point: it names the role that
39
+ // created the key and a role whose key would work.
40
+ async function httpError(res, method, apiPath) {
41
+ let detail = '';
42
+ try {
43
+ const body = (await res.json());
44
+ if (body?.message) {
45
+ detail = body.error_code ? `${body.message} [${body.error_code}]` : body.message;
46
+ }
47
+ }
48
+ catch {
49
+ /* non-JSON body — fall back below */
50
+ }
51
+ if (detail)
52
+ return new Error(detail);
53
+ return new Error(`${method} ${apiPath} failed: ${res.status} ${res.statusText}`);
54
+ }
55
+ function authHeaders() {
56
+ const apiKey = getApiKey();
57
+ if (!apiKey) {
58
+ throw new Error('No WaveSpeed API key configured. Set WAVESPEED_API_KEY, or run `wavespeed login`.');
59
+ }
60
+ return { Authorization: `Bearer ${apiKey}` };
61
+ }
62
+ async function apiGet(apiPath) {
63
+ const res = await fetch(`${getBaseUrl()}${apiPath}`, { headers: authHeaders() });
64
+ if (!res.ok)
65
+ throw await httpError(res, 'GET', apiPath);
66
+ const json = (await res.json());
67
+ if (json.code !== 200)
68
+ throw new Error(json.message || `API returned code ${json.code}`);
69
+ return json.data;
70
+ }
71
+ async function apiPost(apiPath, body) {
72
+ const res = await fetch(`${getBaseUrl()}${apiPath}`, {
73
+ method: 'POST',
74
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
75
+ body: JSON.stringify(body),
76
+ });
77
+ if (!res.ok)
78
+ throw await httpError(res, 'POST', apiPath);
79
+ const json = (await res.json());
80
+ if (json.code !== 200)
81
+ throw new Error(json.message || `API returned code ${json.code}`);
82
+ return json.data;
83
+ }
84
+ export async function fetchModels(opts = {}) {
85
+ const baseUrl = getBaseUrl();
86
+ if (!opts.refresh) {
87
+ const cached = readCache(baseUrl);
88
+ if (cached)
89
+ return cached;
90
+ }
91
+ const models = await apiGet('/api/v3/models');
92
+ writeCache(baseUrl, models);
93
+ return models;
94
+ }
95
+ export async function fetchBalance() {
96
+ return apiGet('/api/v3/balance');
97
+ }
98
+ export async function fetchPricing(modelId, inputs) {
99
+ // /model/price is the documented pricing endpoint.
100
+ return apiPost('/api/v3/model/price', { model_id: modelId, inputs });
101
+ }
102
+ /**
103
+ * Submit a prediction WITHOUT waiting for it. Split from polling so the
104
+ * caller has the prediction ID the moment it exists — a dropped connection
105
+ * mid-generation must never orphan a paid task.
106
+ */
107
+ export async function submitPrediction(model, input) {
108
+ return apiPost(`/api/v3/${model}`, { ...input });
109
+ }
110
+ export async function fetchPrediction(id) {
111
+ return apiGet(`/api/v3/predictions/${id}/result`);
112
+ }
113
+ /** Poll a prediction until it reaches a terminal status. */
114
+ export async function waitForPrediction(id, opts = {}) {
115
+ const interval = opts.intervalMs ?? 1000;
116
+ const deadline = opts.timeoutMs ? Date.now() + opts.timeoutMs : undefined;
117
+ for (;;) {
118
+ const item = await fetchPrediction(id);
119
+ if (item.status === 'completed')
120
+ return item;
121
+ if (item.status === 'failed' || item.status === 'cancelled' || item.status === 'timeout') {
122
+ throw new Error(`Prediction ${item.status}${item.error ? `: ${item.error}` : ''} (task_id: ${id})`);
123
+ }
124
+ if (deadline && Date.now() > deadline) {
125
+ throw new Error(`Still ${item.status} after the wait limit (task_id: ${id}). ` +
126
+ `The task keeps running server-side — check it later with get_prediction.`);
127
+ }
128
+ await new Promise((r) => setTimeout(r, interval));
129
+ }
130
+ }
@@ -0,0 +1,39 @@
1
+ // API key + base URL resolution.
2
+ //
3
+ // Precedence: WAVESPEED_API_KEY / WAVESPEED_BASE_URL env vars, then the
4
+ // wavespeed CLI's stored config (~/.config/wavespeed-nodejs/config.json,
5
+ // written by `wavespeed login`). Reading the CLI's store means one login
6
+ // covers both tools — the MCP server never implements its own auth flow.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ export const DEFAULT_BASE_URL = 'https://api.wavespeed.ai';
11
+ function cliConfigPath() {
12
+ // conf@N with projectName "wavespeed" resolves to <config>/wavespeed-nodejs/config.json
13
+ const base = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME !== ''
14
+ ? process.env.XDG_CONFIG_HOME
15
+ : path.join(os.homedir(), '.config');
16
+ return path.join(base, 'wavespeed-nodejs', 'config.json');
17
+ }
18
+ function readCliConfig() {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(cliConfigPath(), 'utf8'));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ export function getApiKey() {
27
+ return process.env.WAVESPEED_API_KEY || readCliConfig().apiKey || undefined;
28
+ }
29
+ export function getBaseUrl() {
30
+ return process.env.WAVESPEED_BASE_URL || readCliConfig().baseUrl || DEFAULT_BASE_URL;
31
+ }
32
+ export function requireApiKey() {
33
+ const key = getApiKey();
34
+ if (!key) {
35
+ throw new Error('No WaveSpeed API key configured. Set WAVESPEED_API_KEY, or install the CLI ' +
36
+ '(npm i -g @wavespeed/cli) and run `wavespeed login`. Keys: https://wavespeed.ai/accesskey');
37
+ }
38
+ return key;
39
+ }
@@ -0,0 +1,78 @@
1
+ // Turn `@`-prefixed local file paths in a model's input into hosted URLs.
2
+ //
3
+ // The syntax is explicit on purpose — `-i image=@./cat.png`, borrowed from
4
+ // curl. An earlier draft also auto-detected bare paths (media extension +
5
+ // exists on disk), but that meant the CLI could upload a file the user never
6
+ // asked to share based on a heuristic; a value that merely looks like a
7
+ // filename must never leave the machine. No `@`, no upload: bare paths pass
8
+ // through to the API untouched and fail model validation there if they were
9
+ // meant to be files.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ function isRegularFile(p) {
13
+ try {
14
+ return fs.statSync(p).isFile();
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * Walk the input and list every `@path` value. Pure over `exists`, so the
22
+ * rules can be tested without touching the disk.
23
+ */
24
+ export function collectLocalFiles(input, _opts = {}) {
25
+ const found = [];
26
+ const walk = (node, at) => {
27
+ if (Array.isArray(node)) {
28
+ node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i)));
29
+ return;
30
+ }
31
+ if (node && typeof node === 'object') {
32
+ for (const [k, v] of Object.entries(node)) {
33
+ walk(v, at ? `${at}.${k}` : k);
34
+ }
35
+ return;
36
+ }
37
+ if (typeof node !== 'string' || !node.startsWith('@'))
38
+ return;
39
+ found.push({ path: node.slice(1), at });
40
+ };
41
+ walk(input, '');
42
+ return found;
43
+ }
44
+ function setAt(root, dotted, value) {
45
+ const parts = dotted.split('.');
46
+ let cur = root;
47
+ for (let i = 0; i < parts.length - 1; i++)
48
+ cur = cur[parts[i]];
49
+ cur[parts[parts.length - 1]] = value;
50
+ }
51
+ /**
52
+ * Replace `@path` values in `input` with uploaded URLs. Returns a new object;
53
+ * the caller's input is not mutated. A missing file is an error — the user
54
+ * explicitly asked for an upload, so a silent pass-through would submit the
55
+ * literal `@path` string to the model. Distinct paths upload once even when
56
+ * referenced several times.
57
+ */
58
+ export async function resolveLocalFiles(input, opts) {
59
+ const refs = collectLocalFiles(input, opts);
60
+ if (refs.length === 0)
61
+ return { input, uploaded: 0 };
62
+ const exists = opts.exists ?? isRegularFile;
63
+ const missing = refs.filter((r) => !exists(r.path));
64
+ if (missing.length > 0) {
65
+ throw new Error(`File not found: ${missing.map((m) => m.path).join(', ')}`);
66
+ }
67
+ const distinct = [...new Set(refs.map((r) => path.resolve(r.path)))];
68
+ const urls = new Map();
69
+ for (const [i, abs] of distinct.entries()) {
70
+ opts.onUpload?.(abs, i, distinct.length);
71
+ urls.set(abs, await opts.upload(abs));
72
+ }
73
+ const out = JSON.parse(JSON.stringify(input));
74
+ for (const ref of refs) {
75
+ setAt(out, ref.at, urls.get(path.resolve(ref.path)));
76
+ }
77
+ return { input: out, uploaded: distinct.length };
78
+ }
@@ -0,0 +1,75 @@
1
+ // Pricing formulas are JSONata expressions returned by /api/v3/models, e.g.
2
+ // {"total_price": $max([5, $min([$ceil(get_duration_v3(audio)), 600])]) / 5 * base_price}
3
+ // {"total_price": base_price * duration / 5}
4
+ //
5
+ // The pricing endpoint evaluates them server-side and happily accepts a partial
6
+ // (or empty) `inputs` object — a formula whose variables are all missing
7
+ // collapses to `base_price`, which is the floor of the model's price range, not
8
+ // a typical charge. `wavespeed price wavespeed-ai/infinitetalk` with no inputs
9
+ // quotes $0.15 while a 60s audio actually costs $1.80. So we parse the formula
10
+ // to find which inputs move the price, and say which ones the quote is blind to.
11
+ const RESERVED = new Set(['base_price', 'total_price', 'true', 'false', 'null']);
12
+ /**
13
+ * Variable names a pricing formula reads from the model's inputs, in first-seen
14
+ * order. Excludes JSONata builtins (`$max`), helper calls (`get_duration_v3`),
15
+ * string literals, and `base_price`/`total_price`.
16
+ *
17
+ * Returns [] for a flat-priced model (`base_price * 1`) or an unparseable
18
+ * formula — callers should treat [] as "nothing input-dependent to warn about".
19
+ */
20
+ export function extractPriceVars(formula) {
21
+ if (!formula)
22
+ return [];
23
+ // Drop string literals first so "720p" / "total_price" keys never register.
24
+ const stripped = formula.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)*'/g, "''");
25
+ const vars = [];
26
+ const seen = new Set();
27
+ const re = /(\$?)([A-Za-z_][A-Za-z0-9_]*)\s*(\()?/g;
28
+ let m;
29
+ while ((m = re.exec(stripped)) !== null) {
30
+ const [, dollar, name, call] = m;
31
+ if (dollar)
32
+ continue; // $max, $ceil, $length — JSONata builtin
33
+ if (call)
34
+ continue; // get_duration_v3(...) — server-side helper
35
+ if (RESERVED.has(name))
36
+ continue;
37
+ if (seen.has(name))
38
+ continue;
39
+ seen.add(name);
40
+ vars.push(name);
41
+ }
42
+ return vars;
43
+ }
44
+ /**
45
+ * Price-driving variables the quote is genuinely blind to.
46
+ *
47
+ * A driver the caller omitted but that carries a schema default is NOT blind —
48
+ * the server priced it at that default, which is exactly what a run would use.
49
+ * Only drivers with no default (infinitetalk's `audio`, seedance's
50
+ * `reference_videos`) leave the quote uninformed, and those are the ones that
51
+ * silently collapse the formula toward base_price.
52
+ */
53
+ export function missingPriceVars(formula, inputs, schemaProps) {
54
+ return extractPriceVars(formula).filter((v) => {
55
+ const val = inputs[v];
56
+ if (val !== undefined && val !== null && val !== '')
57
+ return false;
58
+ const prop = schemaProps?.[v];
59
+ return !(prop && 'default' in prop);
60
+ });
61
+ }
62
+ /**
63
+ * True when every price driver is unsupplied — the case where the formula
64
+ * degenerates to `base_price` and the quote is the floor of the model's range
65
+ * rather than a representative charge.
66
+ */
67
+ export function isFloorQuote(formula, inputs) {
68
+ const vars = extractPriceVars(formula);
69
+ if (vars.length === 0)
70
+ return false;
71
+ return vars.every((v) => {
72
+ const val = inputs[v];
73
+ return val === undefined || val === null || val === '';
74
+ });
75
+ }
@@ -0,0 +1,58 @@
1
+ // Content-addressed cache of uploaded files: sha256(file bytes) → hosted URL.
2
+ //
3
+ // Uploads are quota-limited per organization and slow for large videos, while
4
+ // the same file routinely goes up more than once — `price --upload` followed
5
+ // by `run`, or the same reference image across a batch of runs. Hashing the
6
+ // bytes (not the path) means a renamed or copied file still hits.
7
+ //
8
+ // TTL is 24h: hosted media does not expire that fast, but a bounded window
9
+ // keeps the cache from ever serving a URL the platform has since removed.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import crypto from 'node:crypto';
14
+ const TTL_MS = 24 * 60 * 60 * 1000;
15
+ function cacheFile() {
16
+ return path.join(os.homedir(), '.cache', 'wavespeed', 'uploads.json');
17
+ }
18
+ function readCache() {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function writeCache(entries) {
27
+ try {
28
+ fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
29
+ fs.writeFileSync(cacheFile(), JSON.stringify(entries));
30
+ }
31
+ catch {
32
+ /* best-effort; a failed cache write must never fail the upload */
33
+ }
34
+ }
35
+ export function hashFile(filePath) {
36
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
37
+ }
38
+ /**
39
+ * Upload through `doUpload` unless the identical bytes already went up within
40
+ * the TTL. Returns the hosted URL either way.
41
+ */
42
+ export async function uploadWithCache(filePath, doUpload) {
43
+ const key = hashFile(filePath);
44
+ const entries = readCache();
45
+ const hit = entries[key];
46
+ if (hit && Date.now() - hit.uploaded_at < TTL_MS) {
47
+ return { url: hit.url, cached: true };
48
+ }
49
+ const url = await doUpload(filePath);
50
+ entries[key] = { url, uploaded_at: Date.now() };
51
+ // Drop expired entries while we are here so the file stays small.
52
+ for (const [k, v] of Object.entries(entries)) {
53
+ if (Date.now() - v.uploaded_at >= TTL_MS)
54
+ delete entries[k];
55
+ }
56
+ writeCache(entries);
57
+ return { url, cached: false };
58
+ }
package/dist/server.js ADDED
@@ -0,0 +1,176 @@
1
+ // WaveSpeed MCP server — tools mirror the wavespeed CLI's verbs.
2
+ //
3
+ // Design (shared with wavespeed-cli):
4
+ // * The live /api/v3/models catalog is the only model source. There is no
5
+ // bundled model list and no hardcoded per-model tool — new platform models
6
+ // work the day they ship.
7
+ // * One generation verb (`run_model`), driven by per-model schemas exposed
8
+ // through `get_model_schema`.
9
+ // * Inputs are never mutated. The one explicit transform is the `@path`
10
+ // marker, which uploads the referenced local file and substitutes its
11
+ // hosted URL. Bare paths pass through untouched.
12
+ // * Price quotes name the inputs they were blind to instead of presenting
13
+ // the formula's floor as "the" price.
14
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
+ import { z } from 'zod';
16
+ import { Client } from 'wavespeed';
17
+ import { fetchModels, fetchBalance, fetchPricing, fetchPrediction, submitPrediction, waitForPrediction, } from './lib/api.js';
18
+ import { getApiKey, getBaseUrl } from './lib/config.js';
19
+ import { resolveLocalFiles } from './lib/local-files.js';
20
+ import { uploadWithCache } from './lib/upload-cache.js';
21
+ import { missingPriceVars, isFloorQuote } from './lib/pricing-vars.js';
22
+ const PRICE_DISCLAIMER = 'Estimate only, for reference — the amount actually charged for a run is authoritative.';
23
+ function requestSchema(m) {
24
+ return m.api_schema?.api_schemas?.[0]?.request_schema;
25
+ }
26
+ function compactModel(m) {
27
+ return {
28
+ model_id: m.model_id,
29
+ name: m.name,
30
+ type: m.type,
31
+ base_price: m.base_price,
32
+ description: m.description?.slice(0, 160),
33
+ };
34
+ }
35
+ async function uploadFile(filePath) {
36
+ const apiKey = getApiKey();
37
+ if (!apiKey)
38
+ throw new Error('No API key configured (WAVESPEED_API_KEY or `wavespeed login`).');
39
+ const client = new Client(apiKey, { baseUrl: getBaseUrl() });
40
+ return uploadWithCache(filePath, (p) => client.upload(p));
41
+ }
42
+ function ok(payload) {
43
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
44
+ }
45
+ export function createServer() {
46
+ const server = new McpServer({ name: 'wavespeed', version: '1.0.0' }, {
47
+ instructions: [
48
+ 'WaveSpeed AI media generation (image, video, audio, 3D).',
49
+ 'Pattern: list_models to find a model, get_model_schema to see its',
50
+ 'inputs, run_model to execute. Reference local files as "@./path"',
51
+ 'values inside input — they upload automatically; bare paths are',
52
+ 'passed through untouched and will fail model validation.',
53
+ 'Use get_price before expensive runs. Never invent model IDs.',
54
+ ].join(' '),
55
+ });
56
+ server.tool('list_models', 'Search the live WaveSpeed model catalog (image, video, audio, 3D). Returns model IDs usable with run_model. Do not invent model IDs — always pick one returned by this tool.', {
57
+ query: z.string().optional().describe('Free-text filter on id/name/description'),
58
+ type: z
59
+ .string()
60
+ .optional()
61
+ .describe('Filter by modality type, e.g. text-to-image, image-to-video'),
62
+ limit: z.number().int().min(1).max(200).default(30).describe('Max results'),
63
+ refresh: z.boolean().default(false).describe('Bypass the 1h catalog cache'),
64
+ }, async ({ query, type, limit, refresh }) => {
65
+ const models = await fetchModels({ refresh });
66
+ const q = query?.toLowerCase();
67
+ const filtered = models.filter((m) => {
68
+ if (type && (m.type ?? '') !== type)
69
+ return false;
70
+ if (!q)
71
+ return true;
72
+ return (m.model_id.toLowerCase().includes(q) ||
73
+ m.name.toLowerCase().includes(q) ||
74
+ (m.description ?? '').toLowerCase().includes(q));
75
+ });
76
+ return ok({
77
+ total_matches: filtered.length,
78
+ models: filtered.slice(0, limit).map(compactModel),
79
+ });
80
+ });
81
+ server.tool('get_model_schema', "Get a model's real input schema (required fields, properties, defaults). Call this before run_model so inputs match what the model actually accepts.", { model: z.string().describe('Model ID from list_models, e.g. google/nano-banana-2/text-to-image') }, async ({ model }) => {
82
+ const models = await fetchModels();
83
+ const meta = models.find((m) => m.model_id === model);
84
+ if (!meta) {
85
+ throw new Error(`Unknown model: ${model}. Use list_models to find valid IDs.`);
86
+ }
87
+ const schema = requestSchema(meta);
88
+ return ok({
89
+ model_id: meta.model_id,
90
+ type: meta.type,
91
+ base_price: meta.base_price,
92
+ required: schema?.required ?? [],
93
+ properties: schema?.properties ?? {},
94
+ property_order: schema?.['x-order-properties'] ?? [],
95
+ });
96
+ });
97
+ server.tool('run_model', 'Run any WaveSpeed model. input keys come from get_model_schema. Local files: pass "@./path" string values — they are uploaded and replaced with hosted URLs (bare paths are NOT uploaded). Returns output URLs. If the wait limit is hit, the task keeps running; recover it with get_prediction.', {
98
+ model: z.string().describe('Model ID from list_models'),
99
+ input: z
100
+ .record(z.unknown())
101
+ .describe('Model inputs per its schema (e.g. {"prompt": "...", "aspect_ratio": "16:9"})'),
102
+ wait_seconds: z
103
+ .number()
104
+ .int()
105
+ .min(0)
106
+ .max(1800)
107
+ .default(600)
108
+ .describe('Max seconds to wait; 0 = submit only and return the prediction id'),
109
+ }, async ({ model, input, wait_seconds }) => {
110
+ const resolved = await resolveLocalFiles(input, {
111
+ upload: async (p) => (await uploadFile(p)).url,
112
+ });
113
+ const started = Date.now();
114
+ const submitted = await submitPrediction(model, resolved.input);
115
+ if (wait_seconds === 0) {
116
+ return ok({ id: submitted.id, status: submitted.status, model });
117
+ }
118
+ const done = await waitForPrediction(submitted.id, {
119
+ intervalMs: 2000,
120
+ timeoutMs: wait_seconds * 1000,
121
+ });
122
+ return ok({
123
+ id: submitted.id,
124
+ model,
125
+ status: done.status,
126
+ outputs: done.outputs ?? [],
127
+ elapsed_ms: Date.now() - started,
128
+ uploaded_files: resolved.uploaded,
129
+ });
130
+ });
131
+ 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.', {
132
+ model: z.string().describe('Model ID from list_models'),
133
+ input: z.record(z.unknown()).default({}).describe('Inputs the quote should account for'),
134
+ }, async ({ model, input }) => {
135
+ const data = await fetchPricing(model, input);
136
+ // The pricing endpoint accepts partial inputs without complaint — a
137
+ // formula whose variables are all missing collapses to base_price, the
138
+ // floor of the model's range. Name what the quote could not see.
139
+ let unpriced = [];
140
+ let atFloor = false;
141
+ try {
142
+ const models = await fetchModels();
143
+ const meta = models.find((m) => m.model_id === model);
144
+ unpriced = missingPriceVars(meta?.formula, input, requestSchema(meta ?? {})?.properties);
145
+ atFloor = isFloorQuote(meta?.formula, input);
146
+ }
147
+ catch {
148
+ /* no catalog — the generic disclaimer still applies */
149
+ }
150
+ return ok({
151
+ ...data,
152
+ estimate: true,
153
+ unpriced_inputs: unpriced,
154
+ at_base_price: atFloor,
155
+ disclaimer: PRICE_DISCLAIMER,
156
+ });
157
+ });
158
+ server.tool('get_balance', 'Show the WaveSpeed account credit balance.', {}, async () => {
159
+ return ok(await fetchBalance());
160
+ });
161
+ server.tool('upload_file', 'Upload a local file to WaveSpeed and get its hosted URL (identical bytes reuse the same upload for 24h). Usually unnecessary — run_model handles "@./path" inputs itself.', { path: z.string().describe('Local file path') }, async ({ path: filePath }) => {
162
+ return ok(await uploadFile(filePath));
163
+ });
164
+ server.tool('get_prediction', 'Fetch the status and outputs of a past or in-flight prediction by id — use to recover a run that hit the wait limit.', { id: z.string().describe('Prediction id returned by run_model') }, async ({ id }) => {
165
+ const item = await fetchPrediction(id);
166
+ return ok({
167
+ id: item.id,
168
+ model: item.model,
169
+ status: item.status,
170
+ outputs: item.outputs ?? [],
171
+ error: item.error,
172
+ created_at: item.created_at,
173
+ });
174
+ });
175
+ return server;
176
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@wavespeed/mcp",
3
+ "version": "1.0.0",
4
+ "description": "WaveSpeed MCP server \u2014 run any model on the live WaveSpeed catalog (image, video, audio, 3D) with schema introspection, local-file upload, and price quotes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "wavespeed-mcp": "dist/index.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "README.zh.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "dev": "tsx src/index.ts",
19
+ "lint": "tsc --noEmit",
20
+ "test": "vitest run",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/WaveSpeedAI/mcp-server.git"
30
+ },
31
+ "homepage": "https://wavespeed.ai",
32
+ "keywords": [
33
+ "mcp",
34
+ "model-context-protocol",
35
+ "wavespeed",
36
+ "ai-image-generation",
37
+ "ai-video-generation",
38
+ "mcp-server"
39
+ ],
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.12.0",
42
+ "wavespeed": "^0.2.6",
43
+ "zod": "^3.24.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.0.0",
47
+ "tsx": "^4.19.0",
48
+ "typescript": "^5.7.0",
49
+ "vitest": "^3.0.0"
50
+ }
51
+ }