@wavespeed/mcp 1.0.8 → 1.0.10

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
@@ -64,9 +64,11 @@ async function httpError(res, method, apiPath) {
64
64
  catch {
65
65
  /* non-JSON body — fall back below */
66
66
  }
67
- if (detail)
68
- return new Error(detail);
69
- return new Error(`${method} ${apiPath} failed: ${res.status} ${res.statusText}`);
67
+ const err = detail
68
+ ? new Error(detail)
69
+ : new Error(`${method} ${apiPath} failed: ${res.status} ${res.statusText}`);
70
+ err.status = res.status;
71
+ return err;
70
72
  }
71
73
  function authHeaders() {
72
74
  const apiKey = getApiKey();
@@ -75,8 +77,17 @@ function authHeaders() {
75
77
  }
76
78
  return { Authorization: `Bearer ${apiKey}`, ...clientAttributionHeaders() };
77
79
  }
78
- async function apiGet(apiPath) {
79
- const res = await fetch(`${getBaseUrl()}${apiPath}`, { headers: authHeaders() });
80
+ // Per-request timeouts (no overall cap). A hung socket would otherwise stall
81
+ // the tool call. Read-only GETs are far below 30s in practice (result p99.9
82
+ // < 1s); the async submit gets more headroom because a timeout there is an
83
+ // ambiguous outcome, not a clean failure.
84
+ export const GET_TIMEOUT_MS = 30_000;
85
+ export const SUBMIT_TIMEOUT_MS = 60_000;
86
+ async function apiGetOnce(apiPath) {
87
+ const res = await fetch(`${getBaseUrl()}${apiPath}`, {
88
+ headers: authHeaders(),
89
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
90
+ });
80
91
  if (!res.ok)
81
92
  throw await httpError(res, 'GET', apiPath);
82
93
  const json = (await res.json());
@@ -84,11 +95,39 @@ async function apiGet(apiPath) {
84
95
  throw new Error(json.message || `API returned code ${json.code}`);
85
96
  return json.data;
86
97
  }
87
- async function apiPost(apiPath, body) {
98
+ // Same set as wavespeed-python. An error with no HTTP status is a network
99
+ // failure or timeout; envelope/JSON errors are not transient.
100
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
101
+ const MAX_GET_RETRIES = 5;
102
+ function isTransient(err) {
103
+ if (typeof err?.status === 'number')
104
+ return RETRYABLE_STATUS.has(err.status);
105
+ return err instanceof TypeError || err?.name === 'TimeoutError' || err?.name === 'AbortError';
106
+ }
107
+ // Read-only GETs only — submit (POST) is deliberately never retried.
108
+ async function apiGet(apiPath, opts = {}) {
109
+ const delay = opts.retryDelayMs ?? 1000;
110
+ for (let attempt = 0;; attempt++) {
111
+ try {
112
+ return await apiGetOnce(apiPath);
113
+ }
114
+ catch (err) {
115
+ const reason = err?.message ?? String(err);
116
+ if (!isTransient(err) && !opts.describe)
117
+ throw err;
118
+ if (!isTransient(err) || attempt >= MAX_GET_RETRIES) {
119
+ throw new Error(opts.describe ? opts.describe(reason, attempt + 1) : reason);
120
+ }
121
+ await new Promise((r) => setTimeout(r, delay * (attempt + 1)));
122
+ }
123
+ }
124
+ }
125
+ async function apiPost(apiPath, body, opts = {}) {
88
126
  const res = await fetch(`${getBaseUrl()}${apiPath}`, {
89
127
  method: 'POST',
90
128
  headers: { ...authHeaders(), 'Content-Type': 'application/json' },
91
129
  body: JSON.stringify(body),
130
+ signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined,
92
131
  });
93
132
  if (!res.ok)
94
133
  throw await httpError(res, 'POST', apiPath);
@@ -133,10 +172,26 @@ export async function fetchPricing(modelId, inputs) {
133
172
  * mid-generation must never orphan a paid task.
134
173
  */
135
174
  export async function submitPrediction(model, input) {
136
- return apiPost(`/api/v3/${model}`, { ...input });
175
+ try {
176
+ return await apiPost(`/api/v3/${model}`, { ...input }, { timeoutMs: SUBMIT_TIMEOUT_MS });
177
+ }
178
+ catch (err) {
179
+ // Never retried: the server may already have created (and billed) the task.
180
+ if (err?.name === 'TimeoutError') {
181
+ throw new Error(`Submission outcome unknown: no response within ${SUBMIT_TIMEOUT_MS / 1000}s. The task may have been created and billed — do not resubmit blindly; if you have a prediction id, check it with get_prediction.`);
182
+ }
183
+ throw err;
184
+ }
137
185
  }
138
- export async function fetchPrediction(id) {
139
- return apiGet(`/api/v3/predictions/${id}/result`);
186
+ // The message always carries the task id and says the task was not declared
187
+ // failed — a status read that dies after submit must not look like a lost
188
+ // (or failed) paid task.
189
+ export async function fetchPrediction(id, opts = {}) {
190
+ return apiGet(`/api/v3/predictions/${id}/result`, {
191
+ retryDelayMs: opts.retryDelayMs,
192
+ describe: (reason, attempts) => `Could not read the status of task ${id} after ${attempts} attempt${attempts > 1 ? 's' : ''}: ${reason}. ` +
193
+ `The task was created and may still be running — call get_prediction with id "${id}" to check it (task_id: ${id}).`,
194
+ });
140
195
  }
141
196
  export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
142
197
  /**
@@ -145,13 +200,13 @@ export const TERMINAL_FAILURES = ['failed', 'cancelled', 'timeout', 'deleted'];
145
200
  * budget runs out. Running out is NOT an error: generations can take hours,
146
201
  * so the caller gets the still-running prediction back (done=false) and
147
202
  * reports its id instead of losing it in a thrown message. `onTick` fires
148
- * after every non-terminal poll.
203
+ * after every non-terminal poll. Transient status-read failures are retried.
149
204
  */
150
205
  export async function waitForPrediction(id, opts = {}) {
151
206
  const interval = opts.intervalMs ?? 1000;
152
207
  const deadline = opts.timeoutMs !== undefined ? Date.now() + opts.timeoutMs : undefined;
153
208
  for (;;) {
154
- const item = await fetchPrediction(id);
209
+ const item = await fetchPrediction(id, { retryDelayMs: opts.retryDelayMs });
155
210
  if (item.status === 'completed' || TERMINAL_FAILURES.includes(item.status)) {
156
211
  return { item, done: true };
157
212
  }
@@ -16,6 +16,31 @@ export function requestSchemaProps(model) {
16
16
  const keys = Object.keys(props);
17
17
  return keys.length > 0 ? keys : null;
18
18
  }
19
+ /**
20
+ * Legacy field names a model still accepts, as `legacy -> canonical`.
21
+ *
22
+ * The API renames public fields without breaking old callers by declaring the
23
+ * old name in the property's `x-aliases` (e.g. `last_image` carries
24
+ * `["end_image"]`) and folding it into the canonical name before validation.
25
+ * A legacy name is therefore a valid input, not a typo — it just is not listed
26
+ * as an accepted input, so schema-driven UIs show only the current name.
27
+ */
28
+ export function requestSchemaAliases(model) {
29
+ const aliases = new Map();
30
+ const props = model?.api_schema?.api_schemas?.[0]?.request_schema?.properties;
31
+ if (!props)
32
+ return aliases;
33
+ for (const [name, def] of Object.entries(props)) {
34
+ const declared = def?.['x-aliases'];
35
+ if (!Array.isArray(declared))
36
+ continue;
37
+ for (const alias of declared) {
38
+ if (typeof alias === 'string' && alias !== name)
39
+ aliases.set(alias, name);
40
+ }
41
+ }
42
+ return aliases;
43
+ }
19
44
  // Small edit distance for typo hints — inputs are short snake_case tokens, so
20
45
  // a full Levenshtein over ≤40 keys is nothing.
21
46
  function editDistance(a, b) {
@@ -49,7 +74,8 @@ export function findUnknownInputs(input, model) {
49
74
  if (!known)
50
75
  return null;
51
76
  const knownSet = new Set(known);
52
- const unknown = Object.keys(input).filter((k) => !knownSet.has(k));
77
+ const aliases = requestSchemaAliases(model);
78
+ const unknown = Object.keys(input).filter((k) => !knownSet.has(k) && !aliases.has(k));
53
79
  if (unknown.length === 0)
54
80
  return null;
55
81
  const suggestions = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/mcp",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
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": {
@@ -39,7 +39,7 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/sdk": "^1.12.0",
42
- "wavespeed": "^0.3.2",
42
+ "wavespeed": "^0.3.4",
43
43
  "zod": "^3.24.0"
44
44
  },
45
45
  "devDependencies": {