@contenthero/mcp 0.4.8 → 0.4.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/format.d.ts +100 -5
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +177 -11
- package/dist/format.js.map +1 -1
- package/dist/server.d.ts +68 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +387 -26
- package/dist/server.js.map +1 -1
- package/dist/widget/generation.html +516 -0
- package/dist/widget-uri.d.ts +12 -0
- package/dist/widget-uri.d.ts.map +1 -0
- package/dist/widget-uri.js +12 -0
- package/dist/widget-uri.js.map +1 -0
- package/package.json +13 -4
package/dist/server.js
CHANGED
|
@@ -33,11 +33,28 @@ import { readFileSync } from 'node:fs';
|
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { dirname, join } from 'node:path';
|
|
35
35
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️⚠️ **THE CONSTANTS ONLY, NOT THE `./server` HELPERS, AND THAT IS DELIBERATE.**
|
|
38
|
+
*
|
|
39
|
+
* `@modelcontextprotocol/ext-apps@2` targets the SPLIT packages (`@modelcontextprotocol/server`), while this
|
|
40
|
+
* server is built on the monolithic `@modelcontextprotocol/sdk@1.26`. Its `registerAppResource` therefore
|
|
41
|
+
* typechecks against a different `ResourceMetadata` than ours and rejects `description`.
|
|
42
|
+
*
|
|
43
|
+
* ⭐ The helper is convenience over a two-line contract: a resource whose mimeType is the app profile, and a
|
|
44
|
+
* tool result whose `_meta` names it. Registering through OUR `server.registerResource` keeps one server
|
|
45
|
+
* abstraction instead of two, and the STRINGS still come from the package, so the part that must match the
|
|
46
|
+
* spec has a single source. Migrating to the split SDK is its own piece of work, not a prerequisite for this.
|
|
47
|
+
*/
|
|
48
|
+
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from '@modelcontextprotocol/ext-apps';
|
|
36
49
|
import { z } from 'zod';
|
|
37
50
|
import { GenerationTimeoutError, pendingOutputId, } from '@contenthero/sdk';
|
|
38
51
|
import { getClient as defaultGetClient } from './client.js';
|
|
52
|
+
/** This module's own directory, so the widget is read from the PACKAGE rather than from the cwd. */
|
|
53
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
export { GENERATION_WIDGET_URI } from './widget-uri.js';
|
|
55
|
+
import { GENERATION_WIDGET_URI } from './widget-uri.js';
|
|
39
56
|
import { resolveModelEnums, BOARD_TYPES, BOARD_TYPE_GUIDANCE, IMAGE_MODEL_GUIDANCE, VIDEO_MODEL_GUIDANCE, AUDIO_MODEL_GUIDANCE, EDIT_AUDIO_MODEL_GUIDANCE, UPSCALE_MODEL_GUIDANCE, LIP_SYNC_MODEL_GUIDANCE, } from './models.js';
|
|
40
|
-
import { audioResult, avatarListResult, avatarResult, avatarPendingResult, balanceResult, brandKitListResult, brandKitResult, brandKnowledgeListResult, brandKnowledgeDetailResult, brandKnowledgeSearchResult, brandKnowledgeItemResult, completedResult, connectedAccountListResult, connectedAccountResult, costResult, accountDetailResult, inspirationContentResult, mediaListResult, mediaSearchResult, folderListResult, folderContentsResult, mediaBatchResult, mediaUploadResult, importedMediaResult, uploadedMediaResult, tagListResult, tagResult, tagDeletedResult, modelListResult, modelResult, platformListResult, platformResult, elementListResult, elementResult, elementDeletedResult, errorResult, generationBatchResult, outlierListResult, enhanceClipsResult, pendingResult, stageListResult, stageResult, stageDeletedResult, spaceDeletedResult, spaceListResult, spaceResult, cardListResult, cardResult, postSummaryResult, publishResult, statusActionResult, editorOpsResult, text, projectDetailResult, liveContextResult, projectListResult, projectCreatedResult, projectDeletedResult, layerTypesResult, timelineTypesResult, editorTranscriptResult, exportJobResult, exportFormatsResult, trackedAccountListResult, transcriptResult, voiceListResult, voiceResult, } from './format.js';
|
|
57
|
+
import { audioResult, avatarListResult, avatarResult, avatarPendingResult, balanceResult, brandKitListResult, brandKitResult, brandKnowledgeListResult, brandKnowledgeDetailResult, brandKnowledgeSearchResult, brandKnowledgeItemResult, completedResult, connectedAccountListResult, connectedAccountResult, costResult, accountDetailResult, inspirationContentResult, mediaListResult, mediaSearchResult, folderListResult, folderContentsResult, mediaBatchResult, mediaUploadResult, importedMediaResult, uploadedMediaResult, tagListResult, tagResult, tagDeletedResult, modelListResult, modelResult, platformListResult, platformResult, elementListResult, elementResult, elementDeletedResult, errorResult, generationBatchResult, outlierListResult, enhanceClipsResult, pendingResult, pollAfterSecondsFor, stageListResult, stageResult, stageDeletedResult, spaceDeletedResult, spaceListResult, spaceResult, cardListResult, cardResult, postSummaryResult, publishResult, statusActionResult, editorOpsResult, text, projectDetailResult, liveContextResult, projectListResult, projectCreatedResult, projectDeletedResult, layerTypesResult, timelineTypesResult, editorTranscriptResult, exportJobResult, exportFormatsResult, trackedAccountListResult, transcriptResult, voiceListResult, voiceResult, } from './format.js';
|
|
41
58
|
/** Platforms a card or one of its posts may target. */
|
|
42
59
|
const POST_PLATFORMS = [
|
|
43
60
|
'youtube',
|
|
@@ -57,6 +74,33 @@ const POST_PLATFORMS = [
|
|
|
57
74
|
* than tripping the client's timeout.
|
|
58
75
|
*/
|
|
59
76
|
const SMART_WAIT_MS = 50_000;
|
|
77
|
+
/**
|
|
78
|
+
* What a still-running generation can already say about the shape of its own result, read from the tool's
|
|
79
|
+
* own ARGUMENTS.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ **FROM `args`, NOT FROM THE BUILT REQUEST.** Every one of these sites builds its request inside a
|
|
82
|
+
* `try`, so the request is out of scope in the `catch` where a pending outputId surfaces. `args` is the
|
|
83
|
+
* handler's parameter and is always in scope, and it is also the more honest source: it is what the caller
|
|
84
|
+
* asked for, which is exactly what the placeholders should depict.
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ Read defensively because the count is spelled `numImages` on some tools and `numGenerations` on
|
|
87
|
+
* others. A widened type here would be a third spelling; reading both is the whole reconciliation.
|
|
88
|
+
*
|
|
89
|
+
* ⛔ `auto` and `adaptive` are legal aspect inputs meaning "the model decides", so they are NOT ratios.
|
|
90
|
+
* Passing one through would have the widget lay placeholders out against a string it cannot parse. Null
|
|
91
|
+
* lets it fall back to its unshaped box, which is the honest state while nothing is known.
|
|
92
|
+
*/
|
|
93
|
+
function pendingShapeFrom(args, contentType) {
|
|
94
|
+
const a = (args ?? {});
|
|
95
|
+
const ar = a.aspectRatio;
|
|
96
|
+
const displayAspect = !ar || ar === 'auto' || ar === 'adaptive' || !ar.includes(':') ? null : ar;
|
|
97
|
+
return {
|
|
98
|
+
contentType,
|
|
99
|
+
modelId: a.modelId ?? '',
|
|
100
|
+
displayAspect,
|
|
101
|
+
expected: a.numImages ?? a.numGenerations ?? 1,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
60
104
|
/**
|
|
61
105
|
* Tool annotations drive how MCP clients group the surface. readOnlyHint=true
|
|
62
106
|
* tools list under "Read-only"; the rest list under "Interactive". publish is
|
|
@@ -171,19 +215,106 @@ const LINK_MIME = {
|
|
|
171
215
|
video: 'video/mp4',
|
|
172
216
|
audio: 'audio/mpeg',
|
|
173
217
|
};
|
|
174
|
-
|
|
218
|
+
/**
|
|
219
|
+
* ⛔⛔⛔ **A `resource_link` DOES NOT RENDER. MEASURED IN BOTH HOSTS, IN PRODUCTION, 2026-09-19.**
|
|
220
|
+
*
|
|
221
|
+
* This returned `kind: 'link'` for every output, including images, and the result was the feature not
|
|
222
|
+
* working at all:
|
|
223
|
+
*
|
|
224
|
+
* - **ChatGPT** showed the output id and a "View the generated image" hyperlink. Clicking it opened the
|
|
225
|
+
* asset in a NEW TAB, which is the opposite of inline.
|
|
226
|
+
* - **Claude** showed NOTHING. No image, no link, no output id.
|
|
227
|
+
*
|
|
228
|
+
* ⚠️ **THE FORMATTER COULD ALWAYS DO THIS AND NOTHING EVER ASKED IT TO.** `completedResult` has handled a
|
|
229
|
+
* `kind: 'bytes'` attachment since it was written, and its own docblock claims images get first-class
|
|
230
|
+
* blocks. This function never produced one, so the branch had no caller. Same shape as the capability-url
|
|
231
|
+
* no-op: a path that exists, typechecks, passes tests, and is unreachable.
|
|
232
|
+
*
|
|
233
|
+
* ⭐ **AN `image` BLOCK IS THE ONLY THING A HOST ACTUALLY RENDERS**, and it feeds the model's vision as
|
|
234
|
+
* well, so the earlier reasoning that `get_media` covers the looking case was answering a different
|
|
235
|
+
* question than the one the user asked: they wanted to SEE it.
|
|
236
|
+
*
|
|
237
|
+
* ## Both, not either
|
|
238
|
+
*
|
|
239
|
+
* Images get a block AND a link. The block is the small `.preview.webp` sibling, so inline display costs a
|
|
240
|
+
* few hundred tokens rather than the megabytes a 2736x1536 original would. The link is the capability url:
|
|
241
|
+
* permanent, full resolution, and the thing to click when the preview is not enough.
|
|
242
|
+
*
|
|
243
|
+
* ⚠️ VIDEO STAYS LINK-ONLY. MCP has no video content block, and base64 video in a transcript is not a
|
|
244
|
+
* trade worth making. Audio likewise has no small derivative to send, so it stays a link too.
|
|
245
|
+
*/
|
|
246
|
+
/**
|
|
247
|
+
* ⛔⛔ **THERE IS NO HOST DETECTION HERE, AND THAT IS NOT AN OVERSIGHT.**
|
|
248
|
+
*
|
|
249
|
+
* The obvious optimization is to skip the bytes when the host will mount the widget, since the widget loads
|
|
250
|
+
* media from a URL and the blocks are only a fallback. I wrote it, and it could never work: MCP Apps
|
|
251
|
+
* declares its support under `capabilities.extensions["io.modelcontextprotocol/ui"]`, and
|
|
252
|
+
* **`@modelcontextprotocol/sdk@1.26` does not know the word `extensions`** (measured: zero occurrences in
|
|
253
|
+
* its types). The schema strips it, so `getClientCapabilities()` returns the same answer for a host that
|
|
254
|
+
* mounts widgets and one that cannot, and the check silently reduced to a constant.
|
|
255
|
+
*
|
|
256
|
+
* ⭐ A CHECK THAT ALWAYS ANSWERS THE SAME WAY IS WORSE THAN NO CHECK: it reads as a decision being made.
|
|
257
|
+
* Deleted, and the budget below is what keeps every result under the host's ceiling on its own.
|
|
258
|
+
*
|
|
259
|
+
* ⏭️ The split packages (`@modelcontextprotocol/server@2`) carry the field. Migrating to them is what
|
|
260
|
+
* unlocks this, and it is its own piece of work rather than a prerequisite for rendering.
|
|
261
|
+
*/
|
|
262
|
+
export async function attachmentsFor(gen) {
|
|
263
|
+
/**
|
|
264
|
+
* ⛔⛔⛔ **ONE BUDGET FOR THE WHOLE RESULT, BECAUSE THE HOST'S CEILING IS PER RESULT.**
|
|
265
|
+
*
|
|
266
|
+
* This was a PER-ITEM cap, which is the same defect one level up from the one it replaced. Four images at
|
|
267
|
+
* 600 KB each pass individually (822 KB encoded, under the budget) and total 3.3 MB, so the host rejects
|
|
268
|
+
* the call and the person pays for four generations they cannot reach. Today's assets are ~3.6 MB apiece
|
|
269
|
+
* and fail the per-item check anyway, so the batch case was safe BY ACCIDENT rather than by design.
|
|
270
|
+
*
|
|
271
|
+
* ⭐ Spending a single budget makes the envelope bounded no matter the count or the resolution: four 4K
|
|
272
|
+
* images, ten variations, a 60 second video. Whatever does not fit degrades to a link, and the widget
|
|
273
|
+
* renders it from a URL regardless, so nothing is lost but the fallback for hosts that cannot mount apps.
|
|
274
|
+
*/
|
|
275
|
+
let budget = MAX_INLINE_BASE64_CHARS;
|
|
175
276
|
const urls = (gen.outputUrls ?? []).filter((u) => typeof u === 'string' && u.length > 0);
|
|
176
277
|
const mimeType = LINK_MIME[gen.contentType];
|
|
177
278
|
if (!mimeType)
|
|
178
279
|
return [];
|
|
179
280
|
const ext = mimeType.split('/')[1];
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const [i, uri] of urls.entries()) {
|
|
283
|
+
/**
|
|
284
|
+
* ⭐ AUDIO HAS A FIRST-CLASS BLOCK TOO, and it plays inline exactly as an image draws. It is fetched the
|
|
285
|
+
* same best-effort way: a miss degrades this one output to a link rather than failing a generation the
|
|
286
|
+
* person already paid for.
|
|
287
|
+
*
|
|
288
|
+
* ⚠️ NO PREVIEW DERIVATIVE EXISTS FOR AUDIO, so this is the real file and the size cap is what stops a
|
|
289
|
+
* long track going into the transcript. A voiceover is small; an hour of music is not, and that one
|
|
290
|
+
* degrades to a link, which the widget renders anyway.
|
|
291
|
+
*/
|
|
292
|
+
if (gen.contentType === 'audio') {
|
|
293
|
+
const bytes = await fetchAudioBytes(uri, budget);
|
|
294
|
+
if (bytes) {
|
|
295
|
+
budget -= bytes.data.length;
|
|
296
|
+
out.push({ kind: 'bytes', type: 'audio', data: bytes.data, mimeType: bytes.mimeType });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (gen.contentType === 'image') {
|
|
300
|
+
// Best-effort: a miss (host not allowlisted, over the budget, network hiccup) degrades this one output
|
|
301
|
+
// to a link instead of failing a generation the user already paid for.
|
|
302
|
+
const bytes = await fetchMediaImageBase64(uri, budget);
|
|
303
|
+
if (bytes) {
|
|
304
|
+
budget -= bytes.data.length;
|
|
305
|
+
out.push({ kind: 'bytes', type: 'image', data: bytes.data, mimeType: bytes.mimeType });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* ⛔ NO `resource_link` PER OUTPUT ANY MORE. It was a THIRD representation of a url the text list and the
|
|
310
|
+
* widget's `structuredContent` both already carry, and hosts render a run of them as `name: uri` with no
|
|
311
|
+
* separator, producing tokens that read as corrupted. The link added nothing the text did not, and cost
|
|
312
|
+
* a per-output block to say it.
|
|
313
|
+
*/
|
|
314
|
+
void ext;
|
|
315
|
+
void mimeType;
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
187
318
|
}
|
|
188
319
|
/**
|
|
189
320
|
* True when an image URL is safe to fetch into an image block. SSRF allowlist:
|
|
@@ -232,17 +363,87 @@ function optimizedImageSibling(url) {
|
|
|
232
363
|
return url;
|
|
233
364
|
return query ? `${rewritten}?${query}` : rewritten;
|
|
234
365
|
}
|
|
235
|
-
|
|
366
|
+
/**
|
|
367
|
+
* ⛔⛔⛔ **A HOST ENFORCES A 1 MB CEILING ON A WHOLE TOOL RESULT, AND THIS IS SIZED AGAINST THAT.**
|
|
368
|
+
*
|
|
369
|
+
* Claude Desktop rejects an oversized result outright with "Tool result is too large. Maximum size is 1MB",
|
|
370
|
+
* which fails the CALL rather than degrading the picture. Measured 2026-09-19 on a real `generate_image`:
|
|
371
|
+
* the generation succeeded and was charged, and the person could not retrieve it.
|
|
372
|
+
*
|
|
373
|
+
* ⚠️ I SET THIS CAP WRONG TWICE BEFORE GETTING HERE. 1.5 MB was too low and would have silently degraded
|
|
374
|
+
* real outputs back to links; 8 MB was too high and broke the host. Both were reasoned from what the BYTES
|
|
375
|
+
* cost us. The number that actually governs belongs to the host, and it bounds the ENTIRE result: text,
|
|
376
|
+
* structured content, every block. So the budget is expressed in BASE64 LENGTH, which is what travels, and
|
|
377
|
+
* leaves room for everything else in the envelope.
|
|
378
|
+
*
|
|
379
|
+
* ⭐ Over budget, the item degrades to a link and the widget still renders it, because the widget loads from
|
|
380
|
+
* a url rather than from bytes.
|
|
381
|
+
*
|
|
382
|
+
* ⚠️ **DERIVED, NOT PICKED.** The ceiling is 1,000,000 bytes for the ENTIRE serialized result. Measured on a
|
|
383
|
+
* real one: text, `structuredContent` and the links together weigh about 2 KB. 900,000 base64 characters
|
|
384
|
+
* leaves roughly 100 KB of headroom, and admits a 657 KB source asset. A 613 KB PNG measured in production
|
|
385
|
+
* encodes to about 840 KB, so it fits with room to spare, where the 700,000 I first wrote would have thrown
|
|
386
|
+
* it away. That was the third time I set this number from reasoning instead of from a measurement.
|
|
387
|
+
*/
|
|
388
|
+
const MAX_INLINE_BASE64_CHARS = 900_000;
|
|
389
|
+
/**
|
|
390
|
+
* The audio equivalent of `fetchImageBytes`, sharing its host allowlist, its size cap and its timeout.
|
|
391
|
+
*
|
|
392
|
+
* ⚠️ SEPARATE RATHER THAN A `kind` PARAMETER because the content-type CHECK is the difference, and a single
|
|
393
|
+
* function taking "which prefix do I accept" is the shape that eventually accepts the wrong one.
|
|
394
|
+
*/
|
|
395
|
+
async function fetchAudioBytes(url, budget) {
|
|
236
396
|
if (!isAllowedImageHost(url))
|
|
237
397
|
return null;
|
|
238
398
|
try {
|
|
239
|
-
const res = await fetch(url);
|
|
399
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
400
|
+
if (!res.ok)
|
|
401
|
+
return null;
|
|
402
|
+
const mimeType = res.headers.get('content-type') || 'audio/mpeg';
|
|
403
|
+
if (!mimeType.startsWith('audio/'))
|
|
404
|
+
return null;
|
|
405
|
+
// Base64 inflates by about a third, so the declared byte length is checked against the budget it will
|
|
406
|
+
// BECOME rather than against itself.
|
|
407
|
+
const declared = Number(res.headers.get('content-length') ?? '');
|
|
408
|
+
if (Number.isFinite(declared) && declared * 1.37 > budget)
|
|
409
|
+
return null;
|
|
410
|
+
const data = Buffer.from(await res.arrayBuffer()).toString('base64');
|
|
411
|
+
if (data.length > budget)
|
|
412
|
+
return null;
|
|
413
|
+
return { data, mimeType };
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function fetchImageBytes(url, budget) {
|
|
420
|
+
if (!isAllowedImageHost(url))
|
|
421
|
+
return null;
|
|
422
|
+
try {
|
|
423
|
+
/**
|
|
424
|
+
* ⚠️⚠️ **A BARE `fetch` HAS NO TIMEOUT, AND THIS ONE IS ON THE PATH OF EVERY GENERATION RESULT.**
|
|
425
|
+
*
|
|
426
|
+
* One unresponsive asset would hang the whole tool call rather than degrading that item to a link, and
|
|
427
|
+
* the caller would see a dead generation they had already paid for. Found by `verify:inline` hanging on
|
|
428
|
+
* its first run after the status path started attaching.
|
|
429
|
+
*
|
|
430
|
+
* ⭐ Failing is CHEAP here and the fallback is good: no block, keep the link. Waiting is what is
|
|
431
|
+
* expensive, so the budget is deliberately short.
|
|
432
|
+
*/
|
|
433
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
240
434
|
if (!res.ok)
|
|
241
435
|
return null;
|
|
242
436
|
const mimeType = res.headers.get('content-type') || 'image/jpeg';
|
|
243
437
|
if (!mimeType.startsWith('image/'))
|
|
244
438
|
return null;
|
|
439
|
+
// Checked BEFORE reading the body where the server declares it, and again after, because
|
|
440
|
+
// `content-length` is absent on a chunked response and a header is not a measurement.
|
|
441
|
+
const declared = Number(res.headers.get('content-length') ?? '');
|
|
442
|
+
if (Number.isFinite(declared) && declared * 1.37 > budget)
|
|
443
|
+
return null;
|
|
245
444
|
const data = Buffer.from(await res.arrayBuffer()).toString('base64');
|
|
445
|
+
if (data.length > budget)
|
|
446
|
+
return null;
|
|
246
447
|
return { data, mimeType };
|
|
247
448
|
}
|
|
248
449
|
catch {
|
|
@@ -255,15 +456,111 @@ async function fetchImageBytes(url) {
|
|
|
255
456
|
* auto-upgrades as the optimization pipeline backfills derivatives, with no code
|
|
256
457
|
* change here. Best-effort: any failure returns null and the item stays text-only.
|
|
257
458
|
*/
|
|
258
|
-
async function fetchMediaImageBase64(url) {
|
|
459
|
+
async function fetchMediaImageBase64(url, budget) {
|
|
259
460
|
const optimized = optimizedImageSibling(url);
|
|
260
461
|
if (optimized !== url) {
|
|
261
|
-
const hit = await fetchImageBytes(optimized);
|
|
462
|
+
const hit = await fetchImageBytes(optimized, budget);
|
|
262
463
|
if (hit)
|
|
263
464
|
return hit;
|
|
264
465
|
}
|
|
265
|
-
return fetchImageBytes(url);
|
|
466
|
+
return fetchImageBytes(url, budget);
|
|
266
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* ⭐⭐⭐ **THE ONE PLACE THAT DECIDES HOW MUCH OF A RESULT MAY BE BYTES.**
|
|
470
|
+
*
|
|
471
|
+
* Two call sites needed this and each had its own answer, which is how the same defect appeared twice at
|
|
472
|
+
* different levels: `attachmentsFor` capped PER ITEM while the host's ceiling is per RESULT, and `get_media`
|
|
473
|
+
* fetched up to TEN images in parallel with no cap at all (10 x 840 KB is 8 MB against a 1 MB limit).
|
|
474
|
+
*
|
|
475
|
+
* ⛔ SEQUENTIAL, DELIBERATELY. The budget is shared state, so a parallel fetch cannot know what the others
|
|
476
|
+
* already spent and every one of them would pass a check the set as a whole fails. Most calls carry one to
|
|
477
|
+
* three items, so the latency is small and the alternative is a ceiling that holds only by luck.
|
|
478
|
+
*
|
|
479
|
+
* Returns one entry per input, `null` where the asset did not fit or could not be read, so callers keep
|
|
480
|
+
* positional alignment with what they asked for.
|
|
481
|
+
*/
|
|
482
|
+
async function inlineImagesWithinBudget(urls) {
|
|
483
|
+
let budget = MAX_INLINE_BASE64_CHARS;
|
|
484
|
+
const out = [];
|
|
485
|
+
for (const url of urls) {
|
|
486
|
+
if (!url) {
|
|
487
|
+
out.push(null);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const hit = await fetchMediaImageBase64(url, budget);
|
|
491
|
+
if (hit)
|
|
492
|
+
budget -= hit.data.length;
|
|
493
|
+
out.push(hit);
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* ⛔⛔⛔ **THE WIDGET IS DECLARED BY THE TOOL, NOT BY THE RESULT. I HAD IT ON THE RESULT.**
|
|
499
|
+
*
|
|
500
|
+
* A host reads `tool._meta` at `tools/list` time to learn that a tool renders a widget. Putting the binding
|
|
501
|
+
* only on the CallToolResult means the host never knows to mount anything, so the result arrives as plain
|
|
502
|
+
* blocks and the widget silently never appears. Measured in Claude Desktop 2026-09-19: four URLs as text,
|
|
503
|
+
* no viewer, no error anywhere.
|
|
504
|
+
*
|
|
505
|
+
* ⚠️ BOTH SPELLINGS, DELIBERATELY. `_meta.ui.resourceUri` is the current format and `ui/resourceUri` the
|
|
506
|
+
* legacy one, and the spec's own guidance is that hosts must accept either. Emitting both costs nothing and
|
|
507
|
+
* removes a whole class of "works in one client" from the table.
|
|
508
|
+
*
|
|
509
|
+
* ⭐ Spread into the tools whose results are MEDIA. Not onto all 87: a tool that returns a card or a folder
|
|
510
|
+
* has nothing for this widget to show, and claiming otherwise would put an empty frame under every call.
|
|
511
|
+
*/
|
|
512
|
+
const RENDERS_GENERATION = {
|
|
513
|
+
_meta: {
|
|
514
|
+
ui: { resourceUri: GENERATION_WIDGET_URI },
|
|
515
|
+
[RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* ⛔⛔⛔ **WITHOUT THIS THE FRAME LOADS NOTHING, AND THE SPEC SAYS SO PLAINLY:**
|
|
520
|
+
* "Empty or omitted → no network resources (secure default)."
|
|
521
|
+
*
|
|
522
|
+
* Measured in Claude Desktop 2026-09-19: the widget mounted, the chrome rendered, the variation strip and
|
|
523
|
+
* the buttons worked, and every image was a broken icon showing its own filename. The frame was doing
|
|
524
|
+
* exactly what it was told, which was to permit nothing.
|
|
525
|
+
*
|
|
526
|
+
* `resourceDomains` maps to `img-src`, `media-src`, `script-src`, `style-src` and `font-src`, so it is the
|
|
527
|
+
* one field that decides whether an `<img>` or a `<video>` in this widget can reach our storage.
|
|
528
|
+
*
|
|
529
|
+
* ⚠️ NO `connectDomains`. The widget never calls `fetch`: it points element sources at urls and lets the
|
|
530
|
+
* browser load them. Granting network access it does not use would widen the sandbox for nothing.
|
|
531
|
+
*
|
|
532
|
+
* ⚠️ These are the hosts that actually serve generated media, which is a SMALLER set than the server's SSRF
|
|
533
|
+
* allowlist. That list governs what the SERVER may fetch and inline; this governs what the FRAME may load.
|
|
534
|
+
* Two different questions, deliberately not one constant.
|
|
535
|
+
*/
|
|
536
|
+
const WIDGET_CSP = {
|
|
537
|
+
_meta: {
|
|
538
|
+
ui: {
|
|
539
|
+
csp: {
|
|
540
|
+
resourceDomains: [
|
|
541
|
+
// Capability urls for generated assets: the token rides in the query string, so an element src
|
|
542
|
+
// loads one directly with no header to set.
|
|
543
|
+
'https://media.contenthero.ai',
|
|
544
|
+
// Public-class objects (posters, gallery, stock).
|
|
545
|
+
'https://cdn.contenthero.ai',
|
|
546
|
+
],
|
|
547
|
+
/**
|
|
548
|
+
* ⛔⛔ **A SEPARATE FIELD, AND OMITTING IT BLOCKS `fetch` ENTIRELY.**
|
|
549
|
+
*
|
|
550
|
+
* `resourceDomains` maps to `img-src`, `media-src` and friends, which is why the pictures render.
|
|
551
|
+
* `connectDomains` maps to `connect-src`, and the spec's default for an omitted list is "no network
|
|
552
|
+
* connections (secure default)". So the frame could DISPLAY our media and could not READ it, which
|
|
553
|
+
* is exactly the shape needed to save a file: downloading means holding the bytes.
|
|
554
|
+
*
|
|
555
|
+
* ⚠️ Same origins, deliberately repeated rather than shared with a constant. They answer different
|
|
556
|
+
* questions (may the frame paint this, may the frame read this) and a future answer to one is not
|
|
557
|
+
* automatically the answer to the other.
|
|
558
|
+
*/
|
|
559
|
+
connectDomains: ['https://media.contenthero.ai', 'https://cdn.contenthero.ai'],
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
};
|
|
267
564
|
/** Drop undefined values so the request payload stays minimal. */
|
|
268
565
|
function compact(obj) {
|
|
269
566
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
@@ -299,6 +596,57 @@ export function registerTools(server, opts) {
|
|
|
299
596
|
* shape per op type. Strictness here applies to the TOP-LEVEL argument object only, so those keep taking
|
|
300
597
|
* varied op payloads while still rejecting an undeclared top-level parameter.
|
|
301
598
|
*/
|
|
599
|
+
/**
|
|
600
|
+
* ⭐⭐⭐ **THE GENERATION WIDGET: THE ONLY THING THAT PUTS A PLAYING VIDEO IN A CONVERSATION.**
|
|
601
|
+
*
|
|
602
|
+
* MCP's content blocks are `text | image | audio | resource | resource_link`. **There is no video block**,
|
|
603
|
+
* so no arrangement of them can render video, and a `resource_link` renders as a hyperlink in ChatGPT and
|
|
604
|
+
* as NOTHING in Claude. Measured in production 2026-09-19.
|
|
605
|
+
*
|
|
606
|
+
* MCP Apps is the mechanism that works. The server publishes an HTML resource under `ui://`, the host
|
|
607
|
+
* mounts it, and the widget reads the tool's `structuredContent`. It is an open standard with an official
|
|
608
|
+
* SDK (`@modelcontextprotocol/ext-apps`), verified against a working implementation before adoption.
|
|
609
|
+
*
|
|
610
|
+
* ⚠️ **THE HTML IS READ FROM THE PACKAGE, NOT FETCHED.** It ships inside the published tarball, so a local
|
|
611
|
+
* install renders the same thing the hosted server does. Fetching it from our app would make the widget
|
|
612
|
+
* depend on a deploy and break every offline or self-hosted install.
|
|
613
|
+
*
|
|
614
|
+
* ⛔ **REGISTERING THIS COSTS NOTHING FOR HOSTS THAT DO NOT SUPPORT IT.** A client that ignores `ui://`
|
|
615
|
+
* resources simply never reads it, and the image and audio BLOCKS remain the fallback. That is why the
|
|
616
|
+
* blocks stay rather than being replaced: two mechanisms, and the widget is the better one where it exists.
|
|
617
|
+
*/
|
|
618
|
+
/**
|
|
619
|
+
* ⚠️⚠️ **REGISTERED UNCONDITIONALLY, AND READ LAZILY.**
|
|
620
|
+
*
|
|
621
|
+
* This used to read the bundle at startup and register the resource only if it was found. Two problems.
|
|
622
|
+
* A missing bundle produced a server that silently had no widget, which is the failure mode hardest to
|
|
623
|
+
* notice: every tool still worked and nothing rendered. And the resource then did not exist when running
|
|
624
|
+
* from `src/`, so the guard that checks tools point at a real resource could not run at all.
|
|
625
|
+
*
|
|
626
|
+
* ⭐ The resource is part of this server's contract. Advertising it always and throwing a NAMED error at
|
|
627
|
+
* read time turns "no widget, no reason" into one line that says exactly what is missing.
|
|
628
|
+
*/
|
|
629
|
+
server.registerResource('generation', GENERATION_WIDGET_URI, {
|
|
630
|
+
description: 'Shows what a generation produced: every variation, playable and downloadable.',
|
|
631
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
632
|
+
...WIDGET_CSP,
|
|
633
|
+
}, async () => {
|
|
634
|
+
const path = join(MODULE_DIR, 'widget', 'generation.html');
|
|
635
|
+
let text;
|
|
636
|
+
try {
|
|
637
|
+
text = readFileSync(path, 'utf8');
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
throw new Error(`The generation widget is missing at ${path}. It is built by \`npm run build\` ` +
|
|
641
|
+
'(`node widget/build.mjs`) and ships inside the package; a server running from source has not built it.');
|
|
642
|
+
}
|
|
643
|
+
// ⚠️ REPEATED ON THE READ RESULT, not just the listing. The spec reads csp from the `resources/read`
|
|
644
|
+
// content item and treats the `resources/list` entry as a FALLBACK, so a host that only consults the
|
|
645
|
+
// read path would otherwise see no policy and apply the secure default of blocking everything.
|
|
646
|
+
return {
|
|
647
|
+
contents: [{ uri: GENERATION_WIDGET_URI, mimeType: RESOURCE_MIME_TYPE, text, ...WIDGET_CSP }],
|
|
648
|
+
};
|
|
649
|
+
});
|
|
302
650
|
const rawRegisterTool = server.registerTool.bind(server);
|
|
303
651
|
server.registerTool = ((name, config, cb) => {
|
|
304
652
|
const shape = config.inputSchema;
|
|
@@ -366,6 +714,7 @@ export function registerTools(server, opts) {
|
|
|
366
714
|
});
|
|
367
715
|
// -- generate_image -------------------------------------------------------
|
|
368
716
|
server.registerTool('generate_image', {
|
|
717
|
+
...RENDERS_GENERATION,
|
|
369
718
|
title: 'Generate Image',
|
|
370
719
|
annotations: WRITE,
|
|
371
720
|
description: 'Generate one or more images from a text prompt (optionally image-to-image with reference images). Waits for the result and returns the image URLs. Optionally pass projectId to place the generated image onto that project in the same call, controlled by an optional placement: a VIDEO timeline places a clip on a track, a CANVAS design places a layer on a slide (defaulting to the slide the user is focused on). Omit projectId to save a standalone library output. The result LINKS each output so the user sees it inline; to SEE it yourself (judge a face, check legibility, pick between variations) call get_media with the outputId. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
|
|
@@ -415,7 +764,7 @@ export function registerTools(server, opts) {
|
|
|
415
764
|
if (args.getCost)
|
|
416
765
|
return costResult(await client.estimateCost(request));
|
|
417
766
|
const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
|
|
418
|
-
return completedResult(gen, attachmentsFor(gen));
|
|
767
|
+
return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
|
|
419
768
|
}
|
|
420
769
|
catch (err) {
|
|
421
770
|
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
@@ -423,12 +772,13 @@ export function registerTools(server, opts) {
|
|
|
423
772
|
// dropping it invites a retry that generates and charges a second time.
|
|
424
773
|
const pending = pendingOutputId(err);
|
|
425
774
|
if (pending)
|
|
426
|
-
return pendingResult(pending);
|
|
775
|
+
return pendingResult(pending, pollAfterSecondsFor('image'), pendingShapeFrom(args, 'image'));
|
|
427
776
|
return errorResult(err);
|
|
428
777
|
}
|
|
429
778
|
});
|
|
430
779
|
// -- generate_board -------------------------------------------------------
|
|
431
780
|
server.registerTool('generate_board', {
|
|
781
|
+
...RENDERS_GENERATION,
|
|
432
782
|
title: 'Generate Reference Board',
|
|
433
783
|
annotations: WRITE,
|
|
434
784
|
description: 'Generate a Reference Board: a dense multi-panel reference sheet (3:4, 4K) built from a source image and/or a written description, used to keep a subject on-model across later generations (feed the board back in as a referenceImage). Provide referenceImages and/or a prompt (at least one is required). Waits up to ~50s; boards render slowly (minutes), so it usually returns an outputId to poll with get_generation_status. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
|
|
@@ -470,7 +820,7 @@ export function registerTools(server, opts) {
|
|
|
470
820
|
if (args.getCost)
|
|
471
821
|
return costResult(await client.estimateBoardCost(request));
|
|
472
822
|
const gen = await client.generateBoardAndWait(request, { timeoutMs: SMART_WAIT_MS });
|
|
473
|
-
return completedResult(gen, attachmentsFor(gen));
|
|
823
|
+
return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
|
|
474
824
|
}
|
|
475
825
|
catch (err) {
|
|
476
826
|
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
@@ -478,12 +828,13 @@ export function registerTools(server, opts) {
|
|
|
478
828
|
// dropping it invites a retry that generates and charges a second time.
|
|
479
829
|
const pending = pendingOutputId(err);
|
|
480
830
|
if (pending)
|
|
481
|
-
return pendingResult(pending);
|
|
831
|
+
return pendingResult(pending, pollAfterSecondsFor('image'), pendingShapeFrom(args, 'image'));
|
|
482
832
|
return errorResult(err);
|
|
483
833
|
}
|
|
484
834
|
});
|
|
485
835
|
// -- generate_video -------------------------------------------------------
|
|
486
836
|
server.registerTool('generate_video', {
|
|
837
|
+
...RENDERS_GENERATION,
|
|
487
838
|
title: 'Generate Video',
|
|
488
839
|
annotations: WRITE,
|
|
489
840
|
description: 'Generate a video from a text prompt (optionally from a start/end frame or reference images/videos/audio). Waits up to ~50s; if the render is still running it returns an outputId to poll with get_generation_status. Seedance 2.0 has two input modes selected by which references you pass: a startFrame (and optional endFrame) runs start/end-frame mode; referenceImages / referenceVideos / referenceAudio (without a startFrame) run references mode. Optionally pass projectId to place the generated video onto that project in the same call, controlled by an optional placement: a VIDEO timeline places a clip on a track, a CANVAS design places a layer on a slide (defaulting to the slide the user is focused on). Omit projectId to save a standalone library output. The result LINKS each output so the user sees it inline; to SEE it yourself (judge a face, check legibility, pick between variations) call get_media with the outputId. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
|
|
@@ -571,7 +922,7 @@ export function registerTools(server, opts) {
|
|
|
571
922
|
if (args.getCost)
|
|
572
923
|
return costResult(await client.estimateCost(request));
|
|
573
924
|
const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
|
|
574
|
-
return completedResult(gen, attachmentsFor(gen));
|
|
925
|
+
return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
|
|
575
926
|
}
|
|
576
927
|
catch (err) {
|
|
577
928
|
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
@@ -579,7 +930,7 @@ export function registerTools(server, opts) {
|
|
|
579
930
|
// dropping it invites a retry that generates and charges a second time.
|
|
580
931
|
const pending = pendingOutputId(err);
|
|
581
932
|
if (pending)
|
|
582
|
-
return pendingResult(pending);
|
|
933
|
+
return pendingResult(pending, pollAfterSecondsFor('video'), pendingShapeFrom(args, 'video'));
|
|
583
934
|
return errorResult(err);
|
|
584
935
|
}
|
|
585
936
|
});
|
|
@@ -688,6 +1039,7 @@ export function registerTools(server, opts) {
|
|
|
688
1039
|
});
|
|
689
1040
|
// -- upscale --------------------------------------------------------------
|
|
690
1041
|
server.registerTool('upscale', {
|
|
1042
|
+
...RENDERS_GENERATION,
|
|
691
1043
|
title: 'Upscale',
|
|
692
1044
|
annotations: WRITE,
|
|
693
1045
|
description: 'Upscale an existing image or video to a higher resolution. Provide the source media URL and a model-supported factor. Waits for the result; if the job is still running it returns an outputId to poll with get_generation_status. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
|
|
@@ -715,7 +1067,7 @@ export function registerTools(server, opts) {
|
|
|
715
1067
|
if (args.getCost)
|
|
716
1068
|
return costResult(await client.estimateCost(request));
|
|
717
1069
|
const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
|
|
718
|
-
return completedResult(gen, attachmentsFor(gen));
|
|
1070
|
+
return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
|
|
719
1071
|
}
|
|
720
1072
|
catch (err) {
|
|
721
1073
|
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
@@ -723,12 +1075,13 @@ export function registerTools(server, opts) {
|
|
|
723
1075
|
// dropping it invites a retry that generates and charges a second time.
|
|
724
1076
|
const pending = pendingOutputId(err);
|
|
725
1077
|
if (pending)
|
|
726
|
-
return pendingResult(pending);
|
|
1078
|
+
return pendingResult(pending, pollAfterSecondsFor('image'), pendingShapeFrom(args, 'image'));
|
|
727
1079
|
return errorResult(err);
|
|
728
1080
|
}
|
|
729
1081
|
});
|
|
730
1082
|
// -- generate_lip_sync ----------------------------------------------------
|
|
731
1083
|
server.registerTool('generate_lip_sync', {
|
|
1084
|
+
...RENDERS_GENERATION,
|
|
732
1085
|
title: 'Generate Lip Sync',
|
|
733
1086
|
annotations: WRITE,
|
|
734
1087
|
description: 'Animate a portrait image so the subject speaks. Provide imageUrl (the face) plus a voice source: either audioUrl (an existing speech clip) or script + voiceId (we synthesize the speech). Optional motionPrompt nudges expression/motion. Waits up to ~50s; if still rendering it returns an outputId to poll with get_generation_status. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
|
|
@@ -776,7 +1129,7 @@ export function registerTools(server, opts) {
|
|
|
776
1129
|
if (args.getCost)
|
|
777
1130
|
return costResult(await client.estimateCost(request));
|
|
778
1131
|
const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
|
|
779
|
-
return completedResult(gen, attachmentsFor(gen));
|
|
1132
|
+
return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
|
|
780
1133
|
}
|
|
781
1134
|
catch (err) {
|
|
782
1135
|
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
@@ -784,7 +1137,7 @@ export function registerTools(server, opts) {
|
|
|
784
1137
|
// dropping it invites a retry that generates and charges a second time.
|
|
785
1138
|
const pending = pendingOutputId(err);
|
|
786
1139
|
if (pending)
|
|
787
|
-
return pendingResult(pending);
|
|
1140
|
+
return pendingResult(pending, pollAfterSecondsFor('video'), pendingShapeFrom(args, 'video'));
|
|
788
1141
|
return errorResult(err);
|
|
789
1142
|
}
|
|
790
1143
|
});
|
|
@@ -1538,7 +1891,9 @@ export function registerTools(server, opts) {
|
|
|
1538
1891
|
// Image blocks are an MCP-layer concern: fetch the resolver-chosen still
|
|
1539
1892
|
// (imageUrl) for each item that has one (images + video posters). audio /
|
|
1540
1893
|
// transcript / posterless items stay text-only. See get-context §9.5.
|
|
1541
|
-
|
|
1894
|
+
// ⚠️ Ten items at 840 KB each is 8 MB against a 1 MB ceiling, and this used to fetch them all in
|
|
1895
|
+
// parallel with no bound. One shared budget, spent in order.
|
|
1896
|
+
const images = await inlineImagesWithinBudget(result.items.map((it) => (it.ok ? it.imageUrl : null)));
|
|
1542
1897
|
return mediaBatchResult(result, images);
|
|
1543
1898
|
}
|
|
1544
1899
|
catch (err) {
|
|
@@ -1778,6 +2133,7 @@ export function registerTools(server, opts) {
|
|
|
1778
2133
|
});
|
|
1779
2134
|
// -- get_generation_status ------------------------------------------------
|
|
1780
2135
|
server.registerTool('get_generation_status', {
|
|
2136
|
+
...RENDERS_GENERATION,
|
|
1781
2137
|
title: 'Get Generation Status',
|
|
1782
2138
|
annotations: READ,
|
|
1783
2139
|
description: "Check one or more in-progress generations (outputIds from generate_image / generate_video / upscale / generate_lip_sync / generate_board) and get their final URLs. BY DEFAULT THIS BLOCKS until they finish, up to ~50s per call, because that is almost always what you want after starting a render; if one is still running it comes back with the current status and a poll_after_seconds hint, so call again. Pass wait:false for an instant snapshot with no blocking. Accepts 1-8 outputIds in one call.",
|
|
@@ -1816,7 +2172,12 @@ export function registerTools(server, opts) {
|
|
|
1816
2172
|
}
|
|
1817
2173
|
}
|
|
1818
2174
|
}));
|
|
1819
|
-
|
|
2175
|
+
// ⭐ Only the single-generation case is attached: a batch of ten would embed ten sets of bytes into
|
|
2176
|
+
// one result. Polling ONE generation is the case a person is watching, and the one worth rendering.
|
|
2177
|
+
const attachments = gens.length === 1 && gens[0]
|
|
2178
|
+
? { [gens[0].outputId]: await attachmentsFor(gens[0]) }
|
|
2179
|
+
: {};
|
|
2180
|
+
return generationBatchResult(gens, attachments, client.baseUrl);
|
|
1820
2181
|
}
|
|
1821
2182
|
catch (err) {
|
|
1822
2183
|
return errorResult(err);
|