@wavespeed/mcp 1.2.2 → 1.2.4

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.
@@ -56,7 +56,7 @@
56
56
  // A small MCP Apps view (https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp):
57
57
  // it speaks JSON-RPC to the host over postMessage and shows what the tool returned.
58
58
  var PROTOCOL = "2026-01-26";
59
- var nextId = 1, pending = {}, args = {}, current = null, pollToken = 0, started = 0, ticker = null;
59
+ var nextId = 1, pending = {}, args = {}, current = null, pollToken = 0, started = 0, ticker = null, hostCaps = null;
60
60
  var $ = function (id) { return document.getElementById(id); };
61
61
 
62
62
  function send(msg) { try { window.parent.postMessage(msg, "*"); } catch (e) {} }
@@ -78,7 +78,11 @@
78
78
  var p = pending[m.id];
79
79
  if (!p) return;
80
80
  delete pending[m.id];
81
- if (m.error) p.reject(new Error(m.error.message || "error")); else p.resolve(m.result);
81
+ if (m.error) {
82
+ var err = new Error(m.error.message || "error");
83
+ err.code = m.error.code;
84
+ p.reject(err);
85
+ } else p.resolve(m.result);
82
86
  return;
83
87
  }
84
88
  if (m.method === "ui/notifications/tool-input") { args = (m.params && m.params.arguments) || {}; showPrompt(); }
@@ -251,7 +255,9 @@
251
255
  var label = el("span", "", "Generating…");
252
256
  w.appendChild(label); body.appendChild(w);
253
257
  started = started || Date.now();
254
- var tick = function () { var s = Math.round((Date.now() - started) / 1000); label.textContent = "Generating… " + s + " s. Video can take a few minutes."; };
258
+ // Only a video run says it can take minutes; images and audio finish much sooner.
259
+ var isVideo = /video|t2v|i2v/i.test(String(d.model || ""));
260
+ var tick = function () { var s = Math.round((Date.now() - started) / 1000); label.textContent = "Generating… " + s + " s." + (isVideo ? " Video can take a few minutes." : ""); };
255
261
  tick(); ticker = setInterval(tick, 1000);
256
262
  setState(d.status);
257
263
  } else if (d.note) {
@@ -272,13 +278,51 @@
272
278
  resize();
273
279
  }
274
280
 
281
+ // The card can no longer follow the run (the host refused its tool call, or it gave up): stop the timer and
282
+ // the spinner so it does not look as if it is still generating, and say where the result will appear.
283
+ function stopFollowing(message) {
284
+ clearInterval(ticker);
285
+ var body = $("body");
286
+ if (!(current && (current.outputs || []).length)) {
287
+ body.textContent = "";
288
+ body.appendChild(el("p", "", "Started. This card stops here; the result appears when the assistant checks on the run."));
289
+ setState("submitted");
290
+ }
291
+ $("note").textContent = message; $("note").hidden = false; resize();
292
+ }
293
+
294
+ // Say what actually went wrong when the host refused the card's tool call, so it can be told apart from a timeout.
295
+ function whyNot(err) {
296
+ if (window.console) console.warn("wavespeed-result: get_prediction failed", err, "hostCapabilities:", hostCaps);
297
+ if (err && err.message === "timeout") return "no answer from this chat in 130 seconds";
298
+ var said = "this chat said: " + ((err && err.message) || "error") + (err && err.code !== undefined ? " [" + err.code + "]" : "");
299
+ if (hostCaps && !hostCaps.serverTools) said += "; it does not declare tool calls for cards";
300
+ return said;
301
+ }
302
+
303
+ // Ask the server for the run through the host: the standard bridge first (tools/call), then ChatGPT's own
304
+ // window.openai.callTool when the standard call is refused. Both answer with the tool result (ChatGPT's may come back
305
+ // as {structuredContent}, {content} or {result: "<json text>"}).
306
+ function callServerTool(name, toolArgs) {
307
+ return request("tools/call", { name: name, arguments: toolArgs }).catch(function (err) {
308
+ var o = window.openai;
309
+ if (!o || typeof o.callTool !== "function" || (err && err.message === "timeout")) throw err;
310
+ return Promise.resolve(o.callTool(name, toolArgs)).then(function (r) {
311
+ if (r && typeof r.result === "string" && !r.structuredContent && !r.content) {
312
+ try { return { structuredContent: JSON.parse(r.result) }; } catch (e) { return { content: [{ type: "text", text: r.result }] }; }
313
+ }
314
+ return r;
315
+ }, function () { throw err; });
316
+ });
317
+ }
318
+
275
319
  // Keep asking the server while the run is going: this is what turns a video's wait into a card that fills in.
276
320
  function poll(id, token) {
277
321
  var asked = 0;
278
322
  (function again() {
279
323
  if (token !== pollToken) return;
280
324
  asked++;
281
- request("tools/call", { name: "get_prediction", arguments: { id: id, wait_seconds: 25 } }).then(function (res) {
325
+ callServerTool("get_prediction", { id: id, wait_seconds: 25 }).then(function (res) {
282
326
  if (token !== pollToken) return;
283
327
  var d = res && res.structuredContent;
284
328
  if (!d) {
@@ -286,16 +330,15 @@
286
330
  try { d = JSON.parse(text); } catch (e) {}
287
331
  }
288
332
  if (res && res.isError && !(d && d.error)) d = { error: ((res.content || [])[0] || {}).text || "The run failed." };
289
- if (!d) { $("note").textContent = "Still running. Ask the assistant to check on it."; $("note").hidden = false; return; }
333
+ if (!d) { stopFollowing("Still running. Ask the assistant to check on it."); return; }
290
334
  if (d.error || d.done || ["completed", "failed", "cancelled", "timeout", "deleted"].indexOf(d.status) >= 0) {
291
335
  if (d.status && d.status !== "completed" && !d.error) d.error = "Prediction " + d.status + (d.error ? ": " + d.error : "");
292
336
  current = d; render(d); return;
293
337
  }
294
- if (asked < 80) again(); else { $("note").textContent = "Still running on the server. Ask the assistant to check on it."; $("note").hidden = false; }
295
- }).catch(function () {
338
+ if (asked < 80) again(); else stopFollowing("Still running on the server. Ask the assistant to check on it.");
339
+ }).catch(function (err) {
296
340
  if (token !== pollToken) return;
297
- $("note").textContent = "This chat cannot refresh the card by itself. Ask the assistant to check on the run.";
298
- $("note").hidden = false; resize();
341
+ stopFollowing("The card could not check on the run (" + whyNot(err) + "). Ask the assistant to check on it.");
299
342
  });
300
343
  })();
301
344
  }
@@ -311,7 +354,7 @@
311
354
 
312
355
  // Handshake with the host, then wait for tool-input and tool-result.
313
356
  request("ui/initialize", { protocolVersion: PROTOCOL, appInfo: { name: "wavespeed-result", version: "1.0.0" }, appCapabilities: {} })
314
- .then(function (r) { if (r && r.hostContext) applyContext(r.hostContext); notify("ui/notifications/initialized", {}); })
357
+ .then(function (r) { hostCaps = (r && r.hostCapabilities) || {}; if (r && r.hostContext) applyContext(r.hostContext); notify("ui/notifications/initialized", {}); })
315
358
  .catch(function () { notify("ui/notifications/initialized", {}); });
316
359
 
317
360
  // ChatGPT's own bridge, when the host offers it instead of the standard messages.
package/dist/lib/app.js CHANGED
@@ -6,9 +6,10 @@ import { readFileSync } from 'node:fs';
6
6
  export const RESULT_VIEW_URI = 'ui://wavespeed/result.html';
7
7
  export const APP_MIME_TYPE = 'text/html;profile=mcp-app';
8
8
  export const TOP_UP_URL = 'https://wavespeed.ai/top-up';
9
+ // get_prediction is not one of them: the card asks it for itself while the run goes on, and an assistant that polls it
10
+ // would draw a second card for the same result. It is marked callable from the card instead (see toolMeta).
9
11
  export const UI_TOOLS = new Set([
10
12
  'run_model',
11
- 'get_prediction',
12
13
  'generate_image',
13
14
  'edit_image',
14
15
  'generate_video',
@@ -18,6 +19,38 @@ export const UI_TOOLS = new Set([
18
19
  // "https:" alone should allow every https source, but a host may only honour named hosts when it builds the
19
20
  // card's CSP, so the usual media hosts are named too, as wildcards.
20
21
  export const VIEW_RESOURCE_DOMAINS = ['https:', 'https://*.cloudfront.net', 'https://*.wavespeed.ai', 'https://*.amazonaws.com'];
22
+ // The one-line status ChatGPT shows while a tool runs and after it ran (it allows 64 characters).
23
+ const TOOL_STATUS = {
24
+ run_model: ['Starting the run…', 'Run started'],
25
+ generate_image: ['Starting the run…', 'Run started'],
26
+ edit_image: ['Starting the run…', 'Run started'],
27
+ generate_video: ['Starting the run…', 'Run started'],
28
+ edit_video: ['Starting the run…', 'Run started'],
29
+ extend_video: ['Starting the run…', 'Run started'],
30
+ get_prediction: ['Checking the run…', 'Run checked'],
31
+ };
32
+ /**
33
+ * The tool's _meta for the card: the run tools open it; get_prediction does not but says the card may call it
34
+ * (ChatGPT only lets a widget call a tool marked openai/widgetAccessible; MCP Apps hosts read ui.visibility, whose
35
+ * default already includes "app"). Same as the remote server (api-server mcp_app.go).
36
+ */
37
+ export function toolMeta(name) {
38
+ const linked = UI_TOOLS.has(name);
39
+ if (!linked && name !== 'get_prediction')
40
+ return undefined;
41
+ const meta = {
42
+ ui: linked ? { resourceUri: RESULT_VIEW_URI, visibility: ['model', 'app'] } : { visibility: ['model', 'app'] },
43
+ 'openai/widgetAccessible': true,
44
+ };
45
+ if (linked)
46
+ meta['openai/outputTemplate'] = RESULT_VIEW_URI;
47
+ const status = TOOL_STATUS[name];
48
+ if (status) {
49
+ meta['openai/toolInvocation/invoking'] = status[0];
50
+ meta['openai/toolInvocation/invoked'] = status[1];
51
+ }
52
+ return meta;
53
+ }
21
54
  export function loadView() {
22
55
  return readFileSync(new URL('../../assets/result.html', import.meta.url), 'utf8');
23
56
  }
package/dist/server.js CHANGED
@@ -24,7 +24,7 @@ import { MAX_DELETE_IDS, prepareDelete, summarizeDelete } from './lib/delete-pre
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';
26
26
  import { resolveLocalFiles } from './lib/local-files.js';
27
- import { APP_MIME_TYPE, RESULT_VIEW_URI, UI_TOOLS, VIEW_RESOURCE_DOMAINS, insufficientCreditsResult, isInsufficientCredits, loadView, } from './lib/app.js';
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
30
  import { IMAGE_MODEL_KEYS, VIDEO_MODEL_KEYS } from './lib/media-models.js';
@@ -160,7 +160,16 @@ function ok(payload) {
160
160
  return res;
161
161
  }
162
162
  export function createServer() {
163
- const server = new McpServer({ name: 'wavespeed', version: PKG_VERSION }, {
163
+ const server = new McpServer({
164
+ name: 'wavespeed',
165
+ title: 'WaveSpeed',
166
+ version: PKG_VERSION,
167
+ websiteUrl: 'https://wavespeed.ai',
168
+ icons: [
169
+ { src: 'https://wavespeed.ai/favicon.svg', mimeType: 'image/svg+xml', sizes: ['any'] },
170
+ { src: 'https://wavespeed.ai/apple-touch-icon.png', mimeType: 'image/png', sizes: ['192x192'] },
171
+ ],
172
+ }, {
164
173
  instructions: [
165
174
  'WaveSpeed AI media generation (image, video, audio, 3D).',
166
175
  'Pattern: list_models to find a model, get_model_schema to see its',
@@ -194,8 +203,9 @@ export function createServer() {
194
203
  }
195
204
  };
196
205
  const tool = registerTool(...args);
197
- if (UI_TOOLS.has(name))
198
- tool.update({ _meta: { ui: { resourceUri: RESULT_VIEW_URI } } });
206
+ const meta = toolMeta(name);
207
+ if (meta)
208
+ tool.update({ _meta: meta });
199
209
  return tool;
200
210
  };
201
211
  server.registerResource('result-card', RESULT_VIEW_URI, {
@@ -208,7 +218,13 @@ export function createServer() {
208
218
  uri: uri.href,
209
219
  mimeType: APP_MIME_TYPE,
210
220
  text: loadView(),
211
- _meta: { ui: { csp: { resourceDomains: VIEW_RESOURCE_DOMAINS, connectDomains: [] }, prefersBorder: true } },
221
+ _meta: {
222
+ ui: { csp: { resourceDomains: VIEW_RESOURCE_DOMAINS, connectDomains: [] }, prefersBorder: true },
223
+ // The same, in the names ChatGPT's own extensions use, for versions that read only those.
224
+ 'openai/widgetCSP': { resource_domains: VIEW_RESOURCE_DOMAINS, connect_domains: [] },
225
+ 'openai/widgetPrefersBorder': true,
226
+ 'openai/widgetDescription': 'Shows the images, videos and audio of a WaveSpeed run and follows it until it finishes.',
227
+ },
212
228
  },
213
229
  ],
214
230
  }));
@@ -220,7 +236,7 @@ export function createServer() {
220
236
  .describe('Filter by modality type, e.g. text-to-image, image-to-video'),
221
237
  limit: z.number().int().min(1).max(200).default(30).describe('Max results'),
222
238
  refresh: z.boolean().default(false).describe('Bypass the 1h catalog cache'),
223
- }, { readOnlyHint: true, openWorldHint: true }, async ({ query, type, limit, refresh }) => {
239
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query, type, limit, refresh }) => {
224
240
  const models = await fetchModels({ refresh });
225
241
  const q = query?.toLowerCase();
226
242
  const filtered = models.filter((m) => {
@@ -237,7 +253,7 @@ export function createServer() {
237
253
  models: filtered.slice(0, limit).map(compactModel),
238
254
  });
239
255
  });
240
- 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. bytedance/seedream-v5.0-pro') }, { readOnlyHint: true, openWorldHint: true }, async ({ model }) => {
256
+ 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. bytedance/seedream-v5.0-pro') }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ model }) => {
241
257
  const models = await fetchModels();
242
258
  const meta = models.find((m) => m.model_id === model);
243
259
  if (!meta) {
@@ -427,7 +443,7 @@ export function createServer() {
427
443
  const data = await fetchPricing(model, priceInput);
428
444
  return ok({ ...data, estimate: true, disclaimer: PRICE_DISCLAIMER, playground_url: modelPlaygroundUrl(model) });
429
445
  });
430
- server.tool('get_balance', 'Show the WaveSpeed account credit balance, with the link to top up.', {}, { readOnlyHint: true, openWorldHint: true }, async () => {
446
+ server.tool('get_balance', 'Show the WaveSpeed account credit balance, with the link to top up.', {}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async () => {
431
447
  return ok({ ...(await fetchBalance()), top_up_url: TOP_UP_URL });
432
448
  });
433
449
  server.tool('list_predictions', 'List past predictions (run history), newest window first. Defaults to the last 24 hours; pass since/until (ISO or YYYY-MM-DD) for other ranges. Use get_prediction with an id for one task in full.', {
@@ -437,7 +453,7 @@ export function createServer() {
437
453
  status: z.enum(PREDICTION_STATUSES).optional(),
438
454
  since: z.string().optional().describe('Created after this time (ISO or YYYY-MM-DD)'),
439
455
  until: z.string().optional().describe('Created before this time (ISO or YYYY-MM-DD)'),
440
- }, { readOnlyHint: true, openWorldHint: true }, async ({ page, limit, model, status, since, until }) => {
456
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ page, limit, model, status, since, until }) => {
441
457
  return ok(await fetchHistory({
442
458
  page,
443
459
  pageSize: limit,
@@ -455,7 +471,7 @@ export function createServer() {
455
471
  key: z.string().optional().describe('Filter by the API key uuid that generated the charge'),
456
472
  since: z.string().optional().describe('Records after this time (ISO or YYYY-MM-DD)'),
457
473
  until: z.string().optional().describe('Records before this time (ISO or YYYY-MM-DD)'),
458
- }, { readOnlyHint: true, openWorldHint: true }, async ({ page, limit, type, model, key, since, until }) => {
474
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ page, limit, type, model, key, since, until }) => {
459
475
  return ok(await fetchBillings({
460
476
  page,
461
477
  pageSize: limit,
@@ -470,7 +486,7 @@ export function createServer() {
470
486
  since: z.string().optional().describe('Start (ISO or YYYY-MM-DD); default 7 days ago'),
471
487
  until: z.string().optional().describe('End (ISO or YYYY-MM-DD); default now'),
472
488
  model: z.string().optional().describe('Filter by model uuid'),
473
- }, { readOnlyHint: true, openWorldHint: true }, async ({ since, until, model }) => {
489
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ since, until, model }) => {
474
490
  const end = toIso(until, 'until') ?? new Date().toISOString();
475
491
  const start = toIso(since, 'since') ?? new Date(Date.parse(end) - 7 * 24 * 60 * 60 * 1000).toISOString();
476
492
  return ok(await fetchUsageStats(start, end, model));
@@ -495,7 +511,7 @@ export function createServer() {
495
511
  .max(1800)
496
512
  .default(0)
497
513
  .describe('Max seconds to wait for a terminal status; 0 = return the current status now'),
498
- }, { readOnlyHint: true, openWorldHint: true }, async ({ id, wait_seconds }, extra) => {
514
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ id, wait_seconds }, extra) => {
499
515
  const started = Date.now();
500
516
  const { item, done } = await waitForPrediction(id, {
501
517
  intervalMs: POLL_INTERVAL_MS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/mcp",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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": {