@wavespeed/mcp 1.2.4 → 1.2.6

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/dist/lib/api.js CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { createRequire } from 'node:module';
10
- import { getApiKey, getBaseUrl } from './config.js';
10
+ import { callerCacheId, getApiKey, getBaseUrl } from './config.js';
11
11
  const require = createRequire(import.meta.url);
12
12
  const { version: PKG_VERSION } = require('../../package.json');
13
13
  // Channel-attribution headers (X-Client-Name / X-Client-Version / X-Client-OS),
@@ -24,12 +24,13 @@ export function clientAttributionHeaders() {
24
24
  };
25
25
  }
26
26
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
27
- function cachePath() {
28
- return path.join(os.homedir(), '.cache', 'wavespeed', 'models.json');
27
+ // One file per base URL + API key: the catalog differs per caller.
28
+ function cachePath(cacheId) {
29
+ return path.join(os.homedir(), '.cache', 'wavespeed', `models-${cacheId}.json`);
29
30
  }
30
- function readCache(baseUrl) {
31
+ function readCache(baseUrl, cacheId) {
31
32
  try {
32
- const parsed = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
33
+ const parsed = JSON.parse(fs.readFileSync(cachePath(cacheId), 'utf8'));
33
34
  if (parsed.base_url !== baseUrl)
34
35
  return null;
35
36
  if (Date.now() - parsed.fetched_at > CACHE_TTL_MS)
@@ -40,11 +41,11 @@ function readCache(baseUrl) {
40
41
  return null;
41
42
  }
42
43
  }
43
- function writeCache(baseUrl, models) {
44
+ function writeCache(baseUrl, cacheId, models) {
44
45
  try {
45
- fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
46
+ fs.mkdirSync(path.dirname(cachePath(cacheId)), { recursive: true });
46
47
  const body = { fetched_at: Date.now(), base_url: baseUrl, models };
47
- fs.writeFileSync(cachePath(), JSON.stringify(body));
48
+ fs.writeFileSync(cachePath(cacheId), JSON.stringify(body));
48
49
  }
49
50
  catch {
50
51
  /* best-effort cache; ignore failures */
@@ -142,21 +143,22 @@ async function apiPost(apiPath, body, opts = {}) {
142
143
  let memCache = null;
143
144
  export async function fetchModels(opts = {}) {
144
145
  const baseUrl = getBaseUrl();
146
+ const cacheId = callerCacheId(baseUrl, getApiKey());
145
147
  if (!opts.refresh) {
146
148
  if (memCache &&
147
- memCache.baseUrl === baseUrl &&
149
+ memCache.cacheId === cacheId &&
148
150
  Date.now() - memCache.fetchedAt <= CACHE_TTL_MS) {
149
151
  return memCache.models;
150
152
  }
151
- const cached = readCache(baseUrl);
153
+ const cached = readCache(baseUrl, cacheId);
152
154
  if (cached) {
153
- memCache = { baseUrl, fetchedAt: cached.fetched_at, models: cached.models };
155
+ memCache = { cacheId, fetchedAt: cached.fetched_at, models: cached.models };
154
156
  return cached.models;
155
157
  }
156
158
  }
157
159
  const models = await apiGet('/api/v3/models');
158
- writeCache(baseUrl, models);
159
- memCache = { baseUrl, fetchedAt: Date.now(), models };
160
+ writeCache(baseUrl, cacheId, models);
161
+ memCache = { cacheId, fetchedAt: Date.now(), models };
160
162
  return models;
161
163
  }
162
164
  export async function fetchBalance() {
@@ -236,9 +238,18 @@ export async function fetchPrediction(id, opts = {}) {
236
238
  });
237
239
  }
238
240
  export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
241
+ // The only statuses of a run that is still going. Anything else is final:
242
+ // completed, a failure, or a status this client does not know — including the
243
+ // empty status of a row whose outputs were archived (the API then sends empty
244
+ // outputs and an error saying they expired). Waiting on those would never end.
245
+ export const PENDING_STATUSES = ['created', 'processing'];
246
+ export function isPending(status) {
247
+ return PENDING_STATUSES.includes(status ?? '');
248
+ }
239
249
  /**
240
- * Poll a prediction until it reaches a terminal status (done=true, whether
241
- * completed or failed — the caller decides what a failure means) or the wait
250
+ * Poll a prediction until it is no longer pending (done=true, whether
251
+ * completed, failed or an unknown/empty status — the caller decides what it
252
+ * means, and gets the API's error with it) or the wait
242
253
  * budget runs out. Running out is NOT an error: generations can take hours,
243
254
  * so the caller gets the still-running prediction back (done=false) and
244
255
  * reports its id instead of losing it in a thrown message. `onTick` fires
@@ -249,9 +260,8 @@ export async function waitForPrediction(id, opts = {}) {
249
260
  const deadline = opts.timeoutMs !== undefined ? Date.now() + opts.timeoutMs : undefined;
250
261
  for (;;) {
251
262
  const item = await fetchPrediction(id, { retryDelayMs: opts.retryDelayMs });
252
- if (item.status === 'completed' || TERMINAL_FAILURES.includes(item.status)) {
263
+ if (!isPending(item.status))
253
264
  return { item, done: true };
254
- }
255
265
  await opts.onTick?.(item);
256
266
  if (opts.signal?.aborted)
257
267
  return { item, done: false };
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import os from 'node:os';
10
+ import crypto from 'node:crypto';
10
11
  export const DEFAULT_BASE_URL = 'https://api.wavespeed.ai';
11
12
  // Mirrors conf's env-paths resolution (projectName "wavespeed", suffix
12
13
  // "nodejs"), which is per-platform — a Linux-only path would miss every
@@ -45,3 +46,13 @@ export function requireApiKey() {
45
46
  }
46
47
  return key;
47
48
  }
49
+ /**
50
+ * Names the caller's own cache files (catalog, uploads): the first 16 hex of
51
+ * sha256(base_url + "\n" + api_key). The model catalog and the uploads
52
+ * differ per account, so two keys on one machine must not share a cache. Only
53
+ * the hash is ever written; the same scheme as the wavespeed CLI, which shares
54
+ * ~/.cache/wavespeed.
55
+ */
56
+ export function callerCacheId(baseUrl, apiKey) {
57
+ return crypto.createHash('sha256').update(`${baseUrl}\n${apiKey ?? ''}`).digest('hex').slice(0, 16);
58
+ }
@@ -1,14 +1,13 @@
1
1
  // delete_predictions: the checks and the report around POST /api/v3/predictions/delete.
2
- // The API only touches the key's own organization and returns the ids it deleted.
2
+ // The API only touches the key's own organization and returns the ids it deleted. Deleting is a soft
3
+ // delete: the record stays, with status "deleted" and no outputs.
4
+ import { checkPredictionId } from './ids.js';
3
5
  export const MAX_DELETE_IDS = 100;
4
- const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
5
6
  /** The ids to send: trimmed, unique, valid, and only once the user agreed (confirm). */
6
7
  export function prepareDelete(ids, confirm) {
7
8
  const unique = [];
8
9
  for (const raw of ids) {
9
- const id = raw.trim();
10
- if (!ID_PATTERN.test(id))
11
- throw new Error(`Invalid prediction id "${raw}".`);
10
+ const id = checkPredictionId(raw);
12
11
  if (!unique.includes(id))
13
12
  unique.push(id);
14
13
  }
@@ -16,7 +15,7 @@ export function prepareDelete(ids, confirm) {
16
15
  throw new Error(`Pass between 1 and ${MAX_DELETE_IDS} prediction ids.`);
17
16
  }
18
17
  if (!confirm) {
19
- throw new Error(`Nothing was deleted. This removes ${unique.length} prediction(s) from the history and cannot be undone here: ask the user, then call again with confirm: true.`);
18
+ throw new Error(`Nothing was deleted. This marks ${unique.length} prediction(s) as deleted (their outputs are no longer returned) and cannot be undone here: ask the user, then call again with confirm: true.`);
20
19
  }
21
20
  return unique;
22
21
  }
@@ -0,0 +1,16 @@
1
+ // Ids that go into an API path, checked before the request is built: the same
2
+ // patterns as the remote MCP (api-server handler/mcp_tools.go mcpModelIDPattern
3
+ // and mcpPredictionIDPattern), so a stray "../" or "?" never reaches the URL.
4
+ export const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*){1,3}$/;
5
+ export const PREDICTION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
6
+ export function checkModelId(id) {
7
+ if (!MODEL_ID_PATTERN.test(id))
8
+ throw new Error(`Invalid model id "${id}". Use an id returned by list_models.`);
9
+ return id;
10
+ }
11
+ export function checkPredictionId(raw) {
12
+ const id = raw.trim();
13
+ if (!PREDICTION_ID_PATTERN.test(id))
14
+ throw new Error(`Invalid prediction id "${raw}".`);
15
+ return id;
16
+ }
@@ -5,16 +5,73 @@
5
5
  // the same words give the same model, endpoint and input on both.
6
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
7
  import { renameRefs } from './prompt-refs.js';
8
- /** Keep only fields the model accepts, snapping values onto its enums and ranges. */
8
+ // Size tiers: 480p, 720p, 1080p (rank = N) and 0.5k, 1k, 2k, 4k (rank = X*1000),
9
+ // so 480p < 720p < 768p < 1080p < 2k < 4k on one scale.
10
+ function tierRank(v) {
11
+ if (typeof v !== 'string')
12
+ return undefined;
13
+ const m = /^(\d+(?:\.\d+)?)([pk])$/i.exec(v.trim());
14
+ if (!m)
15
+ return undefined;
16
+ const n = Number(m[1]);
17
+ return m[2].toLowerCase() === 'k' ? n * 1000 : n;
18
+ }
19
+ // ln(W/H) of a W:H ratio, so 2:1 and 1:2 are equally far from 1:1.
20
+ function ratioLog(v) {
21
+ if (typeof v !== 'string')
22
+ return undefined;
23
+ const m = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(v.trim());
24
+ if (!m)
25
+ return undefined;
26
+ const w = Number(m[1]);
27
+ const h = Number(m[2]);
28
+ return w > 0 && h > 0 ? Math.log(w / h) : undefined;
29
+ }
30
+ // Distances within this count as a tie (4:3 and 3:4 around 1:1 differ only in the last bit).
31
+ const TIE_EPSILON = 1e-9;
32
+ /** The member whose score is closest to target; on a tie, the one `better` prefers (default: the first). */
33
+ function closestMember(members, score, target, better = () => false) {
34
+ let best;
35
+ for (const v of members) {
36
+ const s = score(v);
37
+ if (s === undefined)
38
+ continue;
39
+ const d = Math.abs(s - target);
40
+ if (!best || d < best.d - TIE_EPSILON || (Math.abs(d - best.d) <= TIE_EPSILON && better({ v, s }, best)))
41
+ best = { v, s, d };
42
+ }
43
+ return best?.v;
44
+ }
45
+ /**
46
+ * Keep only fields the model accepts and fit each value to its schema. The
47
+ * rule is shared with the remote MCP (api-server) and the dashboard /chat:
48
+ * - a size tier (resolution, or an enum of Np / Nk values) the model does not
49
+ * offer becomes the closest tier it does; a tie goes to the lower one;
50
+ * - a W:H aspect ratio becomes the closest ratio (in ln(W/H)) among the
51
+ * members that are W:H; a tie goes to the first in enum order;
52
+ * - any other value outside an enum is left out, so the model default applies;
53
+ * - numbers go to the nearest numeric enum value, or are rounded (integers)
54
+ * and clamped to the range.
55
+ * Every value not sent as requested gets a note.
56
+ */
9
57
  export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
10
58
  const properties = await propsOf(modelId);
11
59
  if (!properties)
12
60
  throw new Error(`No schema for ${modelId} in the model catalog.`);
13
61
  const input = {};
62
+ const notes = [];
63
+ // Defaults filled in only to show the price are not the caller's request: no note for them.
64
+ const fromDefault = new Set();
65
+ const used = (key, from, to) => {
66
+ input[key] = to;
67
+ if (to !== from && !fromDefault.has(key))
68
+ notes.push(`${key} ${String(from)} is not offered by ${modelId}; used ${String(to)}.`);
69
+ };
14
70
  // Spell out the defaults that set the price, so the estimate shows them.
15
71
  for (const key of showDefaults) {
16
72
  if (properties[key]?.default !== undefined && (wanted[key] === undefined || wanted[key] === null || wanted[key] === '')) {
17
73
  wanted = { ...wanted, [key]: properties[key].default };
74
+ fromDefault.add(key);
18
75
  }
19
76
  }
20
77
  for (const [key, value] of Object.entries(wanted)) {
@@ -28,11 +85,28 @@ export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
28
85
  continue;
29
86
  }
30
87
  if (prop.enum?.length) {
31
- if (prop.enum.includes(value))
88
+ const members = prop.enum;
89
+ if (members.includes(value)) {
32
90
  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));
91
+ continue;
92
+ }
93
+ if (typeof value === 'number' && members.every((v) => typeof v === 'number')) {
94
+ used(key, value, members.reduce((a, b) => (Math.abs(b - value) < Math.abs(a - value) ? b : a)));
95
+ continue;
96
+ }
97
+ const rank = tierRank(value);
98
+ const tierMembers = members.filter((v) => tierRank(v) !== undefined);
99
+ if (rank !== undefined && tierMembers.length && (key === 'resolution' || tierMembers.length === members.length)) {
100
+ used(key, value, closestMember(tierMembers, tierRank, rank, (a, b) => a.s < b.s));
101
+ continue;
102
+ }
103
+ const ratio = ratioLog(value);
104
+ if (ratio !== undefined && members.some((v) => ratioLog(v) !== undefined)) {
105
+ used(key, value, closestMember(members, ratioLog, ratio));
106
+ continue;
35
107
  }
108
+ if (!fromDefault.has(key))
109
+ notes.push(`${key} ${String(value)} is not accepted by ${modelId}; the model default is used.`);
36
110
  continue;
37
111
  }
38
112
  if (typeof value === 'number' && (prop.type === 'integer' || prop.type === 'number')) {
@@ -41,12 +115,12 @@ export async function fitInput(modelId, wanted, propsOf, showDefaults = []) {
41
115
  v = Math.max(prop.minimum, v);
42
116
  if (typeof prop.maximum === 'number')
43
117
  v = Math.min(prop.maximum, v);
44
- input[key] = v;
118
+ used(key, value, v);
45
119
  continue;
46
120
  }
47
121
  input[key] = value;
48
122
  }
49
- return input;
123
+ return { input, notes };
50
124
  }
51
125
  function imageModelKey(named) {
52
126
  if (named === undefined || named === '')
@@ -64,10 +138,9 @@ export async function planImage(args, propsOf) {
64
138
  const wanted = { prompt: args.prompt, aspect_ratio: args.aspect_ratio, resolution: args.resolution };
65
139
  if (edit)
66
140
  wanted.images = images;
67
- const input = await fitInput(modelId, wanted, propsOf);
141
+ const { input, notes } = await fitInput(modelId, wanted, propsOf);
68
142
  if (typeof input.prompt === 'string')
69
143
  input.prompt = renameRefs(input.prompt, input, modelId);
70
- const notes = [];
71
144
  if (edit && Array.isArray(input.images) && input.images.length < images.length) {
72
145
  notes.push(`${model.label} takes at most ${input.images.length} images, so only the first ${input.images.length} were used.`);
73
146
  }
@@ -167,7 +240,8 @@ export async function planVideo(args, action, propsOf) {
167
240
  const model = VIDEO_MODELS[chosen.key];
168
241
  const modelId = endpoints(chosen.key)[chosen.mode];
169
242
  // 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']);
243
+ const fitted = await fitInput(modelId, chosen.wanted, propsOf, chosen.mode === 'image' ? ['duration', 'resolution'] : ['duration', 'resolution', 'aspect_ratio']);
244
+ const input = fitted.input;
171
245
  if (chosen.mode === 'image')
172
246
  delete input.aspect_ratio;
173
247
  if (typeof input.prompt === 'string')
@@ -184,5 +258,6 @@ export async function planVideo(args, action, propsOf) {
184
258
  ? `${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
259
  : `No available video model takes ${dropped.join(', ')} for this request, so those were left out.`);
186
260
  }
261
+ notes.push(...fitted.notes);
187
262
  return { modelId, label: model.label, mode: MODE_LABEL[chosen.mode], input, notes };
188
263
  }
@@ -12,21 +12,24 @@ import path from 'node:path';
12
12
  import os from 'node:os';
13
13
  import crypto from 'node:crypto';
14
14
  const TTL_MS = 24 * 60 * 60 * 1000;
15
- function cacheFile() {
16
- return path.join(os.homedir(), '.cache', 'wavespeed', 'uploads.json');
15
+ // One file per caller (callerCacheId: a hash of base URL + API key, never the
16
+ // key itself): an upload belongs to the account that made it, so another key
17
+ // on the same machine must not reuse it.
18
+ export function uploadCacheFile(cacheId) {
19
+ return path.join(os.homedir(), '.cache', 'wavespeed', `uploads-${cacheId}.json`);
17
20
  }
18
- function readCache() {
21
+ function readCache(cacheId) {
19
22
  try {
20
- return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
23
+ return JSON.parse(fs.readFileSync(uploadCacheFile(cacheId), 'utf8'));
21
24
  }
22
25
  catch {
23
26
  return {};
24
27
  }
25
28
  }
26
- function writeCache(entries) {
29
+ function writeCache(cacheId, entries) {
27
30
  try {
28
- fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
29
- fs.writeFileSync(cacheFile(), JSON.stringify(entries));
31
+ fs.mkdirSync(path.dirname(uploadCacheFile(cacheId)), { recursive: true });
32
+ fs.writeFileSync(uploadCacheFile(cacheId), JSON.stringify(entries));
30
33
  }
31
34
  catch {
32
35
  /* best-effort; a failed cache write must never fail the upload */
@@ -36,12 +39,13 @@ export function hashFile(filePath) {
36
39
  return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
37
40
  }
38
41
  /**
39
- * Upload through `doUpload` unless the identical bytes already went up within
40
- * the TTL. Returns the hosted URL either way.
42
+ * Upload through `doUpload` unless the identical bytes already went up for
43
+ * this caller (`cacheId`, see callerCacheId) within the TTL. Returns the
44
+ * hosted URL either way.
41
45
  */
42
- export async function uploadWithCache(filePath, doUpload) {
46
+ export async function uploadWithCache(filePath, cacheId, doUpload) {
43
47
  const key = hashFile(filePath);
44
- const entries = readCache();
48
+ const entries = readCache(cacheId);
45
49
  const hit = entries[key];
46
50
  if (hit && Date.now() - hit.uploaded_at < TTL_MS) {
47
51
  return { url: hit.url, cached: true };
@@ -53,6 +57,6 @@ export async function uploadWithCache(filePath, doUpload) {
53
57
  if (Date.now() - v.uploaded_at >= TTL_MS)
54
58
  delete entries[k];
55
59
  }
56
- writeCache(entries);
60
+ writeCache(cacheId, entries);
57
61
  return { url, cached: false };
58
62
  }
@@ -65,6 +65,13 @@ function closest(key, known) {
65
65
  // A hint is only a hint when it's plausibly the same word.
66
66
  return bestD <= Math.max(2, Math.floor(key.length / 3)) ? best : undefined;
67
67
  }
68
+ /**
69
+ * Request controls the API accepts for every model without any schema
70
+ * declaring them (api-server pkg/openapi/validate.go publicInputControlProperties
71
+ * and the enable_content_filter opt-in in handler/model_run.go). They are never
72
+ * unknown inputs.
73
+ */
74
+ export const GLOBAL_INPUT_CONTROLS = ['enable_sync_mode', 'enable_base64_output', 'enable_content_filter'];
68
75
  /**
69
76
  * Compare top-level input keys with the schema's properties.
70
77
  * Returns null when everything checks out or no schema is available.
@@ -75,7 +82,7 @@ export function findUnknownInputs(input, model) {
75
82
  return null;
76
83
  const knownSet = new Set(known);
77
84
  const aliases = requestSchemaAliases(model);
78
- const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k));
85
+ const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k) && !GLOBAL_INPUT_CONTROLS.includes(k));
79
86
  if (unknown.length === 0)
80
87
  return null;
81
88
  const suggestions = new Map();
package/dist/server.js CHANGED
@@ -19,7 +19,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
19
19
  import { z } from 'zod';
20
20
  import { Client } from 'wavespeed';
21
21
  import { fetchModels, fetchBalance, fetchHistory, fetchBillings, fetchUsageStats, deletePredictions, PREDICTION_STATUSES, fetchPricing, modelPlaygroundUrl, submitPrediction, waitForPrediction, MCP_CLIENT_NAME, TERMINAL_FAILURES, } from './lib/api.js';
22
- import { getApiKey, getBaseUrl } from './lib/config.js';
22
+ import { callerCacheId, getApiKey, getBaseUrl } from './lib/config.js';
23
23
  import { MAX_DELETE_IDS, prepareDelete, summarizeDelete } from './lib/delete-predictions.js';
24
24
  // Where to add credit; handed to the assistant next to the balance.
25
25
  const TOP_UP_URL = 'https://wavespeed.ai/top-up';
@@ -27,6 +27,7 @@ import { resolveLocalFiles } from './lib/local-files.js';
27
27
  import { APP_MIME_TYPE, RESULT_VIEW_URI, toolMeta, VIEW_RESOURCE_DOMAINS, insufficientCreditsResult, isInsufficientCredits, loadView, } from './lib/app.js';
28
28
  import { uploadWithCache } from './lib/upload-cache.js';
29
29
  import { findUnknownInputs } from './lib/validate-inputs.js';
30
+ import { checkModelId, checkPredictionId } from './lib/ids.js';
30
31
  import { IMAGE_MODEL_KEYS, VIDEO_MODEL_KEYS } from './lib/media-models.js';
31
32
  import { planImage, planVideo } from './lib/media-plan.js';
32
33
  const require = createRequire(import.meta.url);
@@ -56,8 +57,9 @@ async function uploadFile(filePath) {
56
57
  const apiKey = getApiKey();
57
58
  if (!apiKey)
58
59
  throw new Error('No API key configured (WAVESPEED_API_KEY or `wavespeed login`).');
59
- const client = new Client(apiKey, { baseUrl: getBaseUrl(), clientName: MCP_CLIENT_NAME });
60
- return uploadWithCache(filePath, (p) => client.upload(p));
60
+ const baseUrl = getBaseUrl();
61
+ const client = new Client(apiKey, { baseUrl, clientName: MCP_CLIENT_NAME });
62
+ return uploadWithCache(filePath, callerCacheId(baseUrl, apiKey), (p) => client.upload(p));
61
63
  }
62
64
  // run_model submits and returns the prediction id right away (same as the
63
65
  // hosted server); the assistant then waits with get_prediction. Many MCP
@@ -144,6 +146,7 @@ more = {}) {
144
146
  status: item.status,
145
147
  done,
146
148
  outputs: item.outputs ?? [],
149
+ ...(item.error ? { error: item.error } : {}),
147
150
  elapsed_ms: Date.now() - started,
148
151
  ...more.waited,
149
152
  ...more.always,
@@ -283,6 +286,7 @@ export function createServer() {
283
286
  .default(DEFAULT_WAIT_SECONDS)
284
287
  .describe(`Max seconds to wait for the result in this call (default ${DEFAULT_WAIT_SECONDS}: return the prediction id right away)`),
285
288
  }, { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ model, input, wait_seconds }, extra) => {
289
+ checkModelId(model);
286
290
  const resolved = await resolveLocalFiles(input, {
287
291
  props: (await catalogProps(model).catch(() => null)) ?? undefined,
288
292
  upload: async (p) => (await uploadFile(p)).url,
@@ -352,11 +356,16 @@ export function createServer() {
352
356
  const resolved = await resolveLocalFiles(media, { allFields: true, upload: async (p) => (await uploadFile(p)).url });
353
357
  return { args: { ...args, ...resolved.input }, uploaded: resolved.uploaded };
354
358
  }
359
+ // Every size any /chat image model offers; each request is fitted to its model's own list.
360
+ const IMAGE_RESOLUTIONS = ['0.5k', '1k', '1.5k', '2k', '4k'];
355
361
  const imageShape = {
356
362
  prompt: z.string().min(1).describe('Complete visual prompt: subject, composition, style, lighting, mood.'),
357
363
  model: z.enum(IMAGE_MODEL_KEYS).optional().describe('Only when the user names a model; default is the first (Seedream 5 Pro).'),
358
- 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.'),
359
- resolution: z.enum(['1k', '2k', '4k']).optional().describe('Only when the user asks for a size.'),
364
+ aspect_ratio: z.string().optional().describe('e.g. 1:1, 16:9, 9:16, 4:3, 3:4, 21:9, 9:21. The closest ratio the model supports is used; a note says when. If omitted: text-to-image is 1:1; with input images it is determined automatically from the input image.'),
365
+ resolution: z
366
+ .enum(IMAGE_RESOLUTIONS)
367
+ .optional()
368
+ .describe('Only when the user asks for a size. The closest size the model supports is used (a tie goes to the smaller one); a note says when.'),
360
369
  wait_seconds: waitSchema,
361
370
  estimate_only: estimateSchema,
362
371
  };
@@ -375,11 +384,18 @@ export function createServer() {
375
384
  .enum(VIDEO_MODEL_KEYS)
376
385
  .optional()
377
386
  .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.`);
387
+ // The union of the resolution enums of every /chat video endpoint (live catalog, 2026-11-03);
388
+ // each request is fitted to its endpoint's own list.
389
+ const VIDEO_RESOLUTIONS = ['480p', '540p', '720p', '768p', '1080p', '4k'];
378
390
  const resolutionSchema = z
379
- .enum(['480p', '540p', '720p', '768p', '1080p'])
391
+ .enum(VIDEO_RESOLUTIONS)
392
+ .optional()
393
+ .describe('Only when the user asks for a resolution, or 480p for a cheaper draft; otherwise omit. The closest resolution the model supports is used (a tie goes to the lower one); a note says when. MiniMax H3 defaults to 768p (its equivalent of 720p).');
394
+ const durationSchema = z
395
+ .number()
396
+ .int()
380
397
  .optional()
381
- .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).');
382
- const durationSchema = z.number().int().optional().describe('Seconds, only when the user asks for a length; omit to use the model default.');
398
+ .describe("Seconds, only when the user asks for a length; omit to use the model default. A length outside the model's range is moved into it; a note says when.");
383
399
  const VIDEO_KEYS = ['first_frame', 'last_frame', 'reference_images', 'reference_videos', 'reference_audios', 'video'];
384
400
  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}`, {
385
401
  prompt: z.string().min(1).describe('Shot description: subject, action, camera movement, style, sound.'),
@@ -391,7 +407,10 @@ export function createServer() {
391
407
  reference_audios: mediaList('Optional audio for voice, music or sound to use').optional(),
392
408
  duration: durationSchema,
393
409
  resolution: resolutionSchema,
394
- 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."),
410
+ aspect_ratio: z
411
+ .string()
412
+ .optional()
413
+ .describe("e.g. 16:9, 9:16, 1:1, 4:3, 3:4, 21:9, 9:21. The closest ratio the model supports is used; a note says when. If omitted, the model's default ratio is used; with first_frame the video follows that image's shape and this is ignored."),
395
414
  wait_seconds: waitSchema,
396
415
  estimate_only: estimateSchema,
397
416
  }, shortcutAnnotations, async (raw, extra) => {
@@ -417,7 +436,7 @@ export function createServer() {
417
436
  prompt: z.string().min(1).describe('What happens next.'),
418
437
  video: z.string().describe('The video to continue (URL or "@./path").'),
419
438
  last_frame: z.string().optional().describe('Optional image the continuation should end on.'),
420
- duration: durationSchema.describe('Seconds of new footage; omit to use the model default.'),
439
+ duration: durationSchema.describe("Seconds of new footage; omit to use the model default. A length outside the model's range is moved into it; a note says when."),
421
440
  model: videoModelSchema,
422
441
  resolution: resolutionSchema,
423
442
  wait_seconds: waitSchema,
@@ -430,7 +449,11 @@ export function createServer() {
430
449
  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.', {
431
450
  model: z.string().describe('Model ID from list_models'),
432
451
  input: z.record(z.unknown()).default({}).describe('Inputs the quote should account for'),
433
- }, { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ model, input }) => {
452
+ },
453
+ // Not read-only, unlike the remote server's get_price: here "@./path" inputs upload local files
454
+ // (a write to the account's uploads) before the quote.
455
+ { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ model, input }) => {
456
+ checkModelId(model);
434
457
  // Resolve @path markers exactly like run_model: some prices are measured
435
458
  // from the file itself, so quoting with the literal "@./a.mp3" string
436
459
  // would be wrong. The 24h content-hash cache means the later run reuses
@@ -467,7 +490,7 @@ export function createServer() {
467
490
  page: z.number().int().min(1).default(1),
468
491
  limit: z.number().int().min(1).max(100).default(20).describe('Page size'),
469
492
  type: z.enum(['deduct', 'refund']).optional(),
470
- model: z.string().optional().describe('Filter by model uuid'),
493
+ model: z.string().optional().describe('Filter by model ID as list_models returns it, e.g. bytedance/seedream-v5.0-pro'),
471
494
  key: z.string().optional().describe('Filter by the API key uuid that generated the charge'),
472
495
  since: z.string().optional().describe('Records after this time (ISO or YYYY-MM-DD)'),
473
496
  until: z.string().optional().describe('Records before this time (ISO or YYYY-MM-DD)'),
@@ -485,13 +508,13 @@ export function createServer() {
485
508
  server.tool('get_usage', 'Usage statistics: spend, request counts, daily series and per-model breakdown. Defaults to the last 7 days. Requires an API key with Billing access.', {
486
509
  since: z.string().optional().describe('Start (ISO or YYYY-MM-DD); default 7 days ago'),
487
510
  until: z.string().optional().describe('End (ISO or YYYY-MM-DD); default now'),
488
- model: z.string().optional().describe('Filter by model uuid'),
511
+ model: z.string().optional().describe('Filter by model ID as list_models returns it, e.g. bytedance/seedream-v5.0-pro'),
489
512
  }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ since, until, model }) => {
490
513
  const end = toIso(until, 'until') ?? new Date().toISOString();
491
514
  const start = toIso(since, 'since') ?? new Date(Date.parse(end) - 7 * 24 * 60 * 60 * 1000).toISOString();
492
515
  return ok(await fetchUsageStats(start, end, model));
493
516
  });
494
- server.tool('delete_predictions', `Delete finished predictions from this account's run history, up to ${MAX_DELETE_IDS} at a time. They stop appearing in list_predictions and get_prediction, and this tool cannot bring them back. Runs that are still going are left alone, and only the key's own organization's records are deleted. Ask the user first, and pass confirm: true only after they agreed to delete these ids.`, {
517
+ server.tool('delete_predictions', `Delete finished predictions from this account's run history, up to ${MAX_DELETE_IDS} at a time. This is a soft delete: each record stays, with status "deleted" and without its outputs, and list_predictions and get_prediction still return it that way; the output links are revoked (best effort). This tool cannot undo it. Runs that are still going are left alone, and only the key's own organization's records are deleted. Ask the user first, and pass confirm: true only after they agreed to delete these ids.`, {
495
518
  ids: z.array(z.string()).min(1).max(MAX_DELETE_IDS).describe('Prediction ids to delete'),
496
519
  confirm: z.boolean().describe('Must be true: the user has agreed to delete these predictions'),
497
520
  }, { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async ({ ids, confirm }) => {
@@ -511,7 +534,8 @@ export function createServer() {
511
534
  .max(1800)
512
535
  .default(0)
513
536
  .describe('Max seconds to wait for a terminal status; 0 = return the current status now'),
514
- }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ id, wait_seconds }, extra) => {
537
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ id: rawId, wait_seconds }, extra) => {
538
+ const id = checkPredictionId(rawId);
515
539
  const started = Date.now();
516
540
  const { item, done } = await waitForPrediction(id, {
517
541
  intervalMs: POLL_INTERVAL_MS,
@@ -520,7 +544,7 @@ export function createServer() {
520
544
  signal: extra.signal,
521
545
  });
522
546
  return ok({
523
- id: item.id,
547
+ id: item.id || id,
524
548
  model: item.model,
525
549
  status: item.status,
526
550
  done,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/mcp",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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": {