@wavespeed/mcp 1.1.2 → 1.2.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.
@@ -0,0 +1,272 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>WaveSpeed result</title>
7
+ <style>
8
+ /* Tokens follow the WaveSpeed site (api-dashboard globals.css): near-black/white ink, card surfaces one step off the
9
+ page, 0.5rem radius, and buttons that are black on light and white on dark. */
10
+ :root { color-scheme: light dark; --fg: #0a0a0a; --muted: #3c4a5c; --line: #dde2ea; --card: #f5f5f7; --primary: #3f74ff; --bad: #dc2626; --hover: #eef2f7; --bg: #fff; }
11
+ :root[data-theme="dark"] { --fg: #fff; --muted: #94a3b8; --line: #404040; --card: #262626; --hover: #383838; --bad: #f05252; --bg: #171717; }
12
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --fg: #fff; --muted: #94a3b8; --line: #404040; --card: #262626; --hover: #383838; --bad: #f05252; --bg: #171717; } }
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; padding: 12px; font: 14px/1.5 var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif); color: var(--fg); background: transparent; -webkit-font-smoothing: antialiased; }
15
+ .head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
16
+ .model { font-weight: 600; word-break: break-all; }
17
+ .state { color: var(--muted); font-size: 12px; white-space: nowrap; }
18
+ .prompt { color: var(--muted); margin: 0 0 10px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
19
+ .grid { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
20
+ .grid.one { grid-template-columns: 1fr; }
21
+ .tile { background: var(--card); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
22
+ .tile img, .tile video { display: block; width: 100%; max-height: 520px; object-fit: contain; background: #000; }
23
+ .tile img { cursor: zoom-in; }
24
+ .tile audio { display: block; width: 100%; padding: 10px; }
25
+ .tile .meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 8px 6px 12px; font-size: 12px; color: var(--muted); }
26
+ /* Outline button, like the site's Button variant="outline" (small). */
27
+ button, a.btn { font: inherit; font-size: 12px; font-weight: 500; height: 32px; padding: 0 12px; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; cursor: pointer; text-decoration: none; transition: background-color .15s; }
28
+ button:hover, a.btn:hover { background: var(--hover); }
29
+ button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
30
+ /* Primary action, like the site's default Button: black on light, white on dark. */
31
+ button.primary { height: 36px; padding: 0 16px; font-size: 14px; color: var(--bg); background: var(--fg); border-color: var(--fg); }
32
+ button.primary:hover { background: var(--fg); opacity: .9; }
33
+ .wait { display: flex; align-items: center; gap: 10px; padding: 16px; background: var(--card); border: 1px solid var(--line); border-radius: 8px; color: var(--muted); }
34
+ .spin { width: 18px; height: 18px; border: 2px solid var(--line); border-top-color: var(--primary); border-radius: 50%; animation: s 0.9s linear infinite; flex: none; }
35
+ @keyframes s { to { transform: rotate(360deg); } }
36
+ .err { padding: 12px; border: 1px solid var(--bad); border-radius: 8px; color: var(--bad); }
37
+ .actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
38
+ .note { margin-top: 8px; color: var(--muted); font-size: 12px; }
39
+ [hidden] { display: none !important; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div class="head"><span class="model" id="model">WaveSpeed</span><span class="state" id="state"></span></div>
44
+ <p class="prompt" id="prompt" hidden></p>
45
+ <div id="body"></div>
46
+ <div class="actions" id="actions" hidden></div>
47
+ <div class="note" id="note" hidden></div>
48
+ <script>
49
+ (function () {
50
+ "use strict";
51
+ // A small MCP Apps view (https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp):
52
+ // it speaks JSON-RPC to the host over postMessage and shows what the tool returned.
53
+ var PROTOCOL = "2026-01-26";
54
+ var nextId = 1, pending = {}, args = {}, current = null, pollToken = 0, started = 0, ticker = null;
55
+ var $ = function (id) { return document.getElementById(id); };
56
+
57
+ function send(msg) { try { window.parent.postMessage(msg, "*"); } catch (e) {} }
58
+ function request(method, params) {
59
+ return new Promise(function (resolve, reject) {
60
+ var id = nextId++;
61
+ pending[id] = { resolve: resolve, reject: reject };
62
+ send({ jsonrpc: "2.0", id: id, method: method, params: params || {} });
63
+ setTimeout(function () { if (pending[id]) { delete pending[id]; reject(new Error("timeout")); } }, 130000);
64
+ });
65
+ }
66
+ function notify(method, params) { send({ jsonrpc: "2.0", method: method, params: params || {} }); }
67
+
68
+ window.addEventListener("message", function (e) {
69
+ if (e.source !== window.parent) return;
70
+ var m = e.data;
71
+ if (!m || m.jsonrpc !== "2.0") return;
72
+ if (m.id !== undefined && !m.method) {
73
+ var p = pending[m.id];
74
+ if (!p) return;
75
+ delete pending[m.id];
76
+ if (m.error) p.reject(new Error(m.error.message || "error")); else p.resolve(m.result);
77
+ return;
78
+ }
79
+ if (m.method === "ui/notifications/tool-input") { args = (m.params && m.params.arguments) || {}; showPrompt(); }
80
+ else if (m.method === "ui/notifications/tool-result") { show(m.params || {}); }
81
+ else if (m.method === "ui/notifications/host-context-changed") { applyContext(m.params || {}); }
82
+ else if (m.method === "ui/notifications/tool-cancelled") { pollToken++; setState("cancelled"); }
83
+ else if (m.method === "ui/resource-teardown" && m.id !== undefined) { pollToken++; send({ jsonrpc: "2.0", id: m.id, result: {} }); }
84
+ else if (m.method === "ping" && m.id !== undefined) { send({ jsonrpc: "2.0", id: m.id, result: {} }); }
85
+ });
86
+
87
+ // The host's own look: its theme, its font variables (--font-sans) and the @font-face rules that load them.
88
+ function applyContext(ctx) {
89
+ if (ctx.theme === "dark" || ctx.theme === "light") document.documentElement.setAttribute("data-theme", ctx.theme);
90
+ var st = ctx.styles || {};
91
+ var vars = st.variables || {};
92
+ Object.keys(vars).forEach(function (k) {
93
+ if (/^--font-/.test(k) && typeof vars[k] === "string") document.documentElement.style.setProperty(k, vars[k]);
94
+ });
95
+ var fonts = st.css && st.css.fonts;
96
+ if (typeof fonts === "string" && fonts) {
97
+ var tag = document.getElementById("host-fonts");
98
+ if (!tag) { tag = document.createElement("style"); tag.id = "host-fonts"; document.head.appendChild(tag); }
99
+ tag.textContent = fonts;
100
+ }
101
+ }
102
+ function openLink(url) {
103
+ if (!/^https:\/\//i.test(url)) return;
104
+ request("ui/open-link", { url: url }).catch(function () {});
105
+ }
106
+ function setState(t) { $("state").textContent = t || ""; }
107
+ function showPrompt() {
108
+ var text = args.prompt || (args.input && args.input.prompt) || "";
109
+ var el = $("prompt");
110
+ if (text) { el.textContent = String(text); el.hidden = false; } else { el.hidden = true; }
111
+ resize();
112
+ }
113
+
114
+ function urlOf(o) {
115
+ if (typeof o === "string") return o;
116
+ if (o && typeof o === "object") return o.url || o.download_url || o.href || "";
117
+ return "";
118
+ }
119
+ function kindOf(url) {
120
+ var path = url.split("?")[0].split("#")[0].toLowerCase();
121
+ var ext = path.slice(path.lastIndexOf(".") + 1);
122
+ if (/^(png|jpe?g|webp|gif|avif|bmp)$/.test(ext)) return "image";
123
+ if (/^(mp4|webm|mov|m4v)$/.test(ext)) return "video";
124
+ if (/^(mp3|wav|m4a|ogg|flac|aac)$/.test(ext)) return "audio";
125
+ return "file";
126
+ }
127
+ function el(tag, cls, text) {
128
+ var n = document.createElement(tag);
129
+ if (cls) n.className = cls;
130
+ if (text !== undefined) n.textContent = text;
131
+ return n;
132
+ }
133
+ function openButton(url, label) {
134
+ var b = el("button", "", label || "Open");
135
+ b.addEventListener("click", function () { openLink(url); });
136
+ return b;
137
+ }
138
+
139
+ function tile(url) {
140
+ var kind = kindOf(url), t = el("div", "tile"), media;
141
+ if (!/^https?:\/\//i.test(url)) return null;
142
+ if (kind === "image") {
143
+ media = el("img"); media.src = url; media.alt = "Generated image"; media.loading = "lazy";
144
+ media.addEventListener("click", function () { openLink(url); });
145
+ media.addEventListener("load", resize);
146
+ t.appendChild(media);
147
+ } else if (kind === "video") {
148
+ media = el("video"); media.src = url; media.controls = true; media.playsInline = true; media.preload = "metadata";
149
+ media.addEventListener("loadedmetadata", resize);
150
+ t.appendChild(media);
151
+ } else if (kind === "audio") {
152
+ media = el("audio"); media.src = url; media.controls = true; media.preload = "metadata";
153
+ t.appendChild(media);
154
+ }
155
+ var meta = el("div", "meta"), name = url.split("?")[0].split("/").pop() || "file";
156
+ meta.appendChild(el("span", "", kind === "file" ? name : kind));
157
+ meta.appendChild(openButton(url, kind === "file" ? "Open" : "Open / save"));
158
+ t.appendChild(meta);
159
+ return t;
160
+ }
161
+
162
+ function show(result) {
163
+ var data = result.structuredContent;
164
+ if (!data) {
165
+ // No structured data (an error, or a host that drops it): say what the tool said.
166
+ var text = (result.content || []).map(function (c) { return c && c.text || ""; }).join("\n").trim();
167
+ try { var parsed = JSON.parse(text); if (parsed && typeof parsed === "object") data = parsed; } catch (e) {}
168
+ if (!data) data = result.isError ? { error: text || "The request failed." } : { note: text };
169
+ }
170
+ current = data;
171
+ pollToken++;
172
+ render(data);
173
+ var running = data.status && !data.done && !data.error && ["created", "processing", "queued", "pending"].indexOf(data.status) >= 0;
174
+ if (running && data.id) poll(data.id, pollToken);
175
+ }
176
+
177
+ function render(d) {
178
+ var body = $("body"), actions = $("actions"), note = $("note");
179
+ body.textContent = ""; actions.textContent = ""; actions.hidden = true; note.hidden = true;
180
+ if (d.model) $("model").textContent = d.model;
181
+ clearInterval(ticker);
182
+
183
+ if (d.error) {
184
+ body.appendChild(el("div", "err", String(d.error)));
185
+ setState("failed");
186
+ } else {
187
+ var urls = (d.outputs || []).map(urlOf).filter(Boolean);
188
+ if (urls.length) {
189
+ var grid = el("div", "grid" + (urls.length === 1 ? " one" : ""));
190
+ urls.forEach(function (u) { var t = tile(u); if (t) grid.appendChild(t); });
191
+ body.appendChild(grid);
192
+ setState(d.status === "completed" || d.done ? "done" : (d.status || ""));
193
+ } else if (d.status && !d.done) {
194
+ var w = el("div", "wait"); w.appendChild(el("div", "spin"));
195
+ var label = el("span", "", "Generating…");
196
+ w.appendChild(label); body.appendChild(w);
197
+ started = started || Date.now();
198
+ var tick = function () { var s = Math.round((Date.now() - started) / 1000); label.textContent = "Generating… " + s + " s. Video can take a few minutes."; };
199
+ tick(); ticker = setInterval(tick, 1000);
200
+ setState(d.status);
201
+ } else if (d.note) {
202
+ body.appendChild(el("p", "", String(d.note)));
203
+ } else if (d.status) {
204
+ setState(d.status);
205
+ }
206
+ if (d.price !== undefined && d.price !== null) {
207
+ note.textContent = "Charged about $" + (typeof d.price === "number" ? d.price.toFixed(4) : d.price);
208
+ note.hidden = false;
209
+ }
210
+ }
211
+ if (d.top_up_url) {
212
+ var b = el("button", "primary", "Top up credits");
213
+ b.addEventListener("click", function () { openLink(d.top_up_url); });
214
+ actions.appendChild(b); actions.hidden = false;
215
+ }
216
+ resize();
217
+ }
218
+
219
+ // Keep asking the server while the run is going: this is what turns a video's wait into a card that fills in.
220
+ function poll(id, token) {
221
+ var asked = 0;
222
+ (function again() {
223
+ if (token !== pollToken) return;
224
+ asked++;
225
+ request("tools/call", { name: "get_prediction", arguments: { id: id, wait_seconds: 25 } }).then(function (res) {
226
+ if (token !== pollToken) return;
227
+ var d = res && res.structuredContent;
228
+ if (!d) {
229
+ var text = ((res && res.content) || []).map(function (c) { return c && c.text || ""; }).join("");
230
+ try { d = JSON.parse(text); } catch (e) {}
231
+ }
232
+ if (res && res.isError && !(d && d.error)) d = { error: ((res.content || [])[0] || {}).text || "The run failed." };
233
+ if (!d) { $("note").textContent = "Still running. Ask the assistant to check on it."; $("note").hidden = false; return; }
234
+ if (d.error || d.done || ["completed", "failed", "cancelled", "timeout", "deleted"].indexOf(d.status) >= 0) {
235
+ if (d.status && d.status !== "completed" && !d.error) d.error = "Prediction " + d.status + (d.error ? ": " + d.error : "");
236
+ current = d; render(d); return;
237
+ }
238
+ if (asked < 80) again(); else { $("note").textContent = "Still running on the server. Ask the assistant to check on it."; $("note").hidden = false; }
239
+ }).catch(function () {
240
+ if (token !== pollToken) return;
241
+ $("note").textContent = "This chat cannot refresh the card by itself. Ask the assistant to check on the run.";
242
+ $("note").hidden = false; resize();
243
+ });
244
+ })();
245
+ }
246
+
247
+ var lastHeight = 0;
248
+ function resize() {
249
+ var h = Math.ceil(document.documentElement.getBoundingClientRect().height);
250
+ if (h === lastHeight) return;
251
+ lastHeight = h;
252
+ notify("ui/notifications/size-changed", { width: Math.ceil(document.documentElement.getBoundingClientRect().width), height: h });
253
+ }
254
+ if (window.ResizeObserver) new ResizeObserver(resize).observe(document.body);
255
+
256
+ // Handshake with the host, then wait for tool-input and tool-result.
257
+ request("ui/initialize", { protocolVersion: PROTOCOL, appInfo: { name: "wavespeed-result", version: "1.0.0" }, appCapabilities: {} })
258
+ .then(function (r) { if (r && r.hostContext) applyContext(r.hostContext); notify("ui/notifications/initialized", {}); })
259
+ .catch(function () { notify("ui/notifications/initialized", {}); });
260
+
261
+ // ChatGPT's own bridge, when the host offers it instead of the standard messages.
262
+ function fromOpenAI() {
263
+ var o = window.openai;
264
+ if (o && o.toolOutput && !current) show({ structuredContent: o.toolOutput });
265
+ if (o && o.theme) applyContext({ theme: o.theme });
266
+ }
267
+ window.addEventListener("openai:set_globals", fromOpenAI);
268
+ setTimeout(fromOpenAI, 300);
269
+ })();
270
+ </script>
271
+ </body>
272
+ </html>
@@ -0,0 +1,45 @@
1
+ // MCP Apps (SEP-1865): the tools that start or read a run point at one HTML
2
+ // view, which a supporting client draws as a result card. Other clients ignore
3
+ // the metadata and show the text as before. The view is the same file the
4
+ // remote server (api-server /mcp) serves; keep the two copies identical.
5
+ import { readFileSync } from 'node:fs';
6
+ export const RESULT_VIEW_URI = 'ui://wavespeed/result.html';
7
+ export const APP_MIME_TYPE = 'text/html;profile=mcp-app';
8
+ export const TOP_UP_URL = 'https://wavespeed.ai/top-up';
9
+ export const UI_TOOLS = new Set([
10
+ 'run_model',
11
+ 'get_prediction',
12
+ 'generate_image',
13
+ 'edit_image',
14
+ 'generate_video',
15
+ 'edit_video',
16
+ 'extend_video',
17
+ ]);
18
+ // Outputs normally sit on our CDNs, but not always: any https source may load.
19
+ export const VIEW_RESOURCE_DOMAINS = ['https:'];
20
+ export function loadView() {
21
+ return readFileSync(new URL('../../assets/result.html', import.meta.url), 'utf8');
22
+ }
23
+ export function isInsufficientCredits(message) {
24
+ const m = message.toLowerCase();
25
+ return m.includes('insufficient credits') || m.includes('top-up is required');
26
+ }
27
+ export function topUpLink(model) {
28
+ const q = new URLSearchParams({ tab: 'top-up', source: 'mcp' });
29
+ if (typeof model === 'string' && model)
30
+ q.set('model', model);
31
+ return `${TOP_UP_URL}?${q.toString()}`;
32
+ }
33
+ export function insufficientCreditsResult(message, model) {
34
+ const link = topUpLink(model);
35
+ return {
36
+ isError: true,
37
+ content: [
38
+ {
39
+ type: 'text',
40
+ text: `${message} Tell the user their balance is too low and give them this link to add credits, then retry: ${link}`,
41
+ },
42
+ ],
43
+ structuredContent: { error: message, top_up_url: link },
44
+ };
45
+ }
@@ -6,9 +6,30 @@
6
6
  // asked to share based on a heuristic; a value that merely looks like a
7
7
  // filename must never leave the machine. No `@`, no upload: bare paths pass
8
8
  // through to the API untouched and fail model validation there if they were
9
- // meant to be files.
9
+ // meant to be files. The `@` is only read in media fields (isMediaField):
10
+ // prompts legitimately start with "@Image 1".
10
11
  import fs from 'node:fs';
11
12
  import path from 'node:path';
13
+ // The same rule api-server (pkg/openapi IsMediaField) and the dashboard use to
14
+ // tell which inputs carry media, so a text field such as `prompt` that merely
15
+ // starts with "@" ("@Image 1 waves") is never read as a file path:
16
+ // - an enum is never media;
17
+ // - an explicit x-ui-component decides: uploader, uploaders and mask are
18
+ // media, any other component is not;
19
+ // - otherwise the name decides: image, *_image, *_image_url, video…, audio…
20
+ // (and their plurals), plus mask (api-server needs the uploader component
21
+ // for masks; the CLI also takes the bare name, which no text field uses).
22
+ const MEDIA_NAME = /(^|_)(image|image_url|video|video_url|audio|audio_url|images|image_urls|videos|video_urls|audios|audio_urls|mask)$/;
23
+ export function isMediaField(name, prop) {
24
+ const def = (prop && typeof prop === 'object' ? prop : {});
25
+ if (Array.isArray(def.enum) && def.enum.length > 0)
26
+ return false;
27
+ const component = def['x-ui-component'];
28
+ if (typeof component === 'string' && component !== '') {
29
+ return ['uploader', 'uploaders', 'mask'].includes(component.toLowerCase());
30
+ }
31
+ return MEDIA_NAME.test(name.toLowerCase());
32
+ }
12
33
  function isRegularFile(p) {
13
34
  try {
14
35
  return fs.statSync(p).isFile();
@@ -21,24 +42,27 @@ function isRegularFile(p) {
21
42
  * Walk the input and list every `@path` value. Pure over `exists`, so the
22
43
  * rules can be tested without touching the disk.
23
44
  */
24
- export function collectLocalFiles(input, _opts = {}) {
45
+ export function collectLocalFiles(input, opts = {}) {
25
46
  const found = [];
26
- const walk = (node, at) => {
47
+ // `media` is whether the nearest named field (an array's elements take the
48
+ // array's name) carries media.
49
+ const walk = (node, at, media, depth) => {
27
50
  if (Array.isArray(node)) {
28
- node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i)));
51
+ node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i), media, depth));
29
52
  return;
30
53
  }
31
54
  if (node && typeof node === 'object') {
32
55
  for (const [k, v] of Object.entries(node)) {
33
- walk(v, at ? `${at}.${k}` : k);
56
+ const prop = depth === 0 ? opts.props?.[k] : undefined;
57
+ walk(v, at ? `${at}.${k}` : k, opts.allFields || isMediaField(k, prop), depth + 1);
34
58
  }
35
59
  return;
36
60
  }
37
- if (typeof node !== 'string' || !node.startsWith('@'))
61
+ if (!media || typeof node !== 'string' || !node.startsWith('@'))
38
62
  return;
39
63
  found.push({ path: node.slice(1), at });
40
64
  };
41
- walk(input, '');
65
+ walk(input, '', !!opts.allFields, 0);
42
66
  return found;
43
67
  }
44
68
  function setAt(root, dotted, value) {
package/dist/server.js CHANGED
@@ -24,6 +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
28
  import { uploadWithCache } from './lib/upload-cache.js';
28
29
  import { findUnknownInputs } from './lib/validate-inputs.js';
29
30
  import { IMAGE_MODEL_KEYS, VIDEO_MODEL_KEYS } from './lib/media-models.js';
@@ -150,7 +151,13 @@ more = {}) {
150
151
  });
151
152
  }
152
153
  function ok(payload) {
153
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
154
+ const res = {
155
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
156
+ };
157
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
158
+ res.structuredContent = payload;
159
+ }
160
+ return res;
154
161
  }
155
162
  export function createServer() {
156
163
  const server = new McpServer({ name: 'wavespeed', version: PKG_VERSION }, {
@@ -169,6 +176,42 @@ export function createServer() {
169
176
  'run_model returns done=false, keep the id and poll get_prediction.',
170
177
  ].join(' '),
171
178
  });
179
+ // Every tool gets the same two additions: a balance that is too low comes back with the top-up link, and the
180
+ // tools that show a result point at the result card.
181
+ const registerTool = server.tool.bind(server);
182
+ server.tool = (...args) => {
183
+ const name = args[0];
184
+ const cb = args[args.length - 1];
185
+ args[args.length - 1] = async (...a) => {
186
+ try {
187
+ return await cb(...a);
188
+ }
189
+ catch (err) {
190
+ const message = String(err?.message ?? err);
191
+ if (!isInsufficientCredits(message))
192
+ throw err;
193
+ return insufficientCreditsResult(message, a[0]?.model);
194
+ }
195
+ };
196
+ const tool = registerTool(...args);
197
+ if (UI_TOOLS.has(name))
198
+ tool.update({ _meta: { ui: { resourceUri: RESULT_VIEW_URI } } });
199
+ return tool;
200
+ };
201
+ server.registerResource('result-card', RESULT_VIEW_URI, {
202
+ title: 'WaveSpeed result card',
203
+ description: 'Shows the images, videos and audio of a WaveSpeed run, and refreshes while it is running.',
204
+ mimeType: APP_MIME_TYPE,
205
+ }, async (uri) => ({
206
+ contents: [
207
+ {
208
+ uri: uri.href,
209
+ mimeType: APP_MIME_TYPE,
210
+ text: loadView(),
211
+ _meta: { ui: { csp: { resourceDomains: VIEW_RESOURCE_DOMAINS, connectDomains: [] }, prefersBorder: true } },
212
+ },
213
+ ],
214
+ }));
172
215
  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.', {
173
216
  query: z.string().optional().describe('Free-text filter on id/name/description'),
174
217
  type: z
@@ -211,7 +254,7 @@ export function createServer() {
211
254
  playground_url: modelPlaygroundUrl(meta.model_id),
212
255
  });
213
256
  });
214
- server.tool('run_model', 'Run any WaveSpeed model. input keys come from get_model_schema; unknown keys are rejected before submission. Local files: pass "@./path" string values — they are uploaded and replaced with hosted URLs (bare paths are NOT uploaded). Returns the prediction id right away (done=false); call get_prediction with that id and wait_seconds until done=true to get the output URLs. The task keeps running on the server. Pass wait_seconds here to wait for a quick model in the same call.', {
257
+ server.tool('run_model', 'Run any WaveSpeed model. input keys come from get_model_schema; unknown keys are rejected before submission. Local files: pass "@./path" in a media field (image, images, video, audio, mask …) — it is uploaded and replaced with hosted URLs (bare paths are NOT uploaded). Returns the prediction id right away (done=false); call get_prediction with that id and wait_seconds until done=true to get the output URLs. The task keeps running on the server. Pass wait_seconds here to wait for a quick model in the same call.', {
215
258
  model: z.string().describe('Model ID from list_models'),
216
259
  input: z
217
260
  .record(z.unknown())
@@ -225,6 +268,7 @@ export function createServer() {
225
268
  .describe(`Max seconds to wait for the result in this call (default ${DEFAULT_WAIT_SECONDS}: return the prediction id right away)`),
226
269
  }, { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ model, input, wait_seconds }, extra) => {
227
270
  const resolved = await resolveLocalFiles(input, {
271
+ props: (await catalogProps(model).catch(() => null)) ?? undefined,
228
272
  upload: async (p) => (await uploadFile(p)).url,
229
273
  });
230
274
  await assertKnownInputs(model, resolved.input);
@@ -289,7 +333,7 @@ export function createServer() {
289
333
  for (const k of keys)
290
334
  if (args[k] !== undefined)
291
335
  media[k] = args[k];
292
- const resolved = await resolveLocalFiles(media, { upload: async (p) => (await uploadFile(p)).url });
336
+ const resolved = await resolveLocalFiles(media, { allFields: true, upload: async (p) => (await uploadFile(p)).url });
293
337
  return { args: { ...args, ...resolved.input }, uploaded: resolved.uploaded };
294
338
  }
295
339
  const imageShape = {
@@ -376,6 +420,7 @@ export function createServer() {
376
420
  // would be wrong. The 24h content-hash cache means the later run reuses
377
421
  // this upload.
378
422
  const resolved = await resolveLocalFiles(input, {
423
+ props: (await catalogProps(model).catch(() => null)) ?? undefined,
379
424
  upload: async (p) => (await uploadFile(p)).url,
380
425
  });
381
426
  const priceInput = resolved.input;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavespeed/mcp",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
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": {