@contenthero/mcp 0.4.11 → 0.4.12

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.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  // The spec's own spelling of the key that binds a result to its widget. See `server.ts` for why only the
7
7
  // constants come from this package and not its server helpers.
8
+ import { aspectLabel } from '@contenthero-ai/brand-ui';
8
9
  import { RESOURCE_URI_META_KEY } from '@modelcontextprotocol/ext-apps';
9
10
  import { GENERATION_WIDGET_URI } from './widget-uri.js';
10
11
  import { ContentHeroError, InsufficientCreditsError, RateLimitError } from '@contenthero/sdk';
@@ -72,42 +73,82 @@ export function text(body, isError = false) {
72
73
  export const DEFAULT_APP_URL = 'https://app.contenthero.ai';
73
74
  export function studioUrlFor(baseUrl, outputId, index, total) {
74
75
  const root = baseUrl.replace(/\/+$/, '');
75
- const variation = total > 1 ? `&variation=${index}` : '';
76
+ /**
77
+ * ⭐⭐⭐ **ONE-BASED, BECAUSE EVERY OTHER THING A PERSON SEES IS.**
78
+ *
79
+ * The reference they copy is `<id>-1`. The detail view says "Variation 1 of 4". A url saying
80
+ * `variation=0` for that same picture made three surfaces disagree, and the one that disagreed was the
81
+ * only one anybody would ever paste into a message or a bug report.
82
+ *
83
+ * ⛔ The studio's `imageIndex` is a ZERO-BASED slot and stays that way. Slot space is an internal fact
84
+ * about `task_outcomes` and `item_statuses`, not a number to show anyone. The app subtracts one when it
85
+ * reads this parameter, so the conversion sits at the boundary where the two vocabularies meet rather
86
+ * than leaking slot space into a shareable link.
87
+ */
88
+ const variation = total > 1 ? `&variation=${index + 1}` : '';
76
89
  return `${root}/studio?output=${encodeURIComponent(outputId)}${variation}`;
77
90
  }
91
+ export function mediaWidgetData(input) {
92
+ return {
93
+ outputId: input.outputId ?? null,
94
+ contentType: input.contentType ?? null,
95
+ modelId: input.modelId ?? '',
96
+ /**
97
+ * ⛔⛔ **NULL WHEN THERE IS NOTHING TO NAME, AND THE WIDGET MUST RENDER NO CHIP.**
98
+ *
99
+ * This used to be `displayName ?? modelId`, fed by a catalog fetch with a `catch` that returned the id.
100
+ * The id reads like a label, so a failed fetch showed as a chip flickering between kebab case and title
101
+ * case rather than as a failure. The name now arrives on the row.
102
+ */
103
+ modelName: input.modelDisplayName ?? null,
104
+ modelBrandColor: input.modelBrandColor ?? null,
105
+ modelIconKey: input.modelIconKey ?? null,
106
+ displayAspect: input.displayAspect ?? null,
107
+ /** Verbatim. Some prompts are JSON-shaped because the person authored one; that object IS the prompt. */
108
+ prompt: input.prompt ?? null,
109
+ items: input.items.map((it) => ({
110
+ url: it.url,
111
+ posterUrl: it.posterUrl ?? null,
112
+ name: it.name,
113
+ contentType: it.contentType,
114
+ displayAspect: it.displayAspect ?? null,
115
+ openUrl: it.openUrl ?? null,
116
+ reference: it.reference ?? null,
117
+ modelName: it.modelName ?? null,
118
+ modelBrandColor: it.modelBrandColor ?? null,
119
+ modelIconKey: it.modelIconKey ?? null,
120
+ })),
121
+ };
122
+ }
123
+ /** A generation's widget payload. A thin adapter over {@link mediaWidgetData}, not a second builder. */
78
124
  export function generationWidgetData(gen, posterUrls = [], baseUrl = DEFAULT_APP_URL) {
79
125
  const urls = gen.outputUrls ?? [];
80
- return {
126
+ return mediaWidgetData({
81
127
  outputId: gen.outputId,
82
128
  contentType: gen.contentType,
83
129
  modelId: gen.modelId,
130
+ modelDisplayName: gen.modelDisplayName,
131
+ modelBrandColor: gen.modelBrandColor,
132
+ modelIconKey: gen.modelIconKey,
84
133
  /**
85
- * ⛔⛔ **NULL WHEN THERE IS NOTHING TO NAME, AND THE WIDGET MUST RENDER NO CHIP.**
86
- *
87
- * This used to be `displayName ?? gen.modelId`, fed by a catalog fetch in the server with a `catch`
88
- * that returned the id. The id reads like a label, so a failed fetch showed as a chip flickering
89
- * between kebab case and title case rather than as a failure. The name now arrives on the row.
90
- *
91
- * ⚠️ It is not always this generation's own model: an upload names itself, and a look names the model
92
- * that produced the image it was assembled from.
134
+ * SHARED, which is what makes a generation render as a ROW. Every variation of one generation has
135
+ * the same shape by construction, so the widget lays them out side by side for comparison rather than
136
+ * as a mixed grid.
93
137
  */
94
- modelName: gen.modelDisplayName ?? null,
95
- /** Hex brand accent from the registry, or null. The widget colors its chip with it. */
96
- modelBrandColor: gen.modelBrandColor ?? null,
97
- /** Brand family key (e.g. `"openai"`) for choosing a glyph. NOT the model id. */
98
- modelIconKey: gen.modelIconKey ?? null,
99
- /** `"W:H"`, or null for audio. Drives the column count and the tile's own shape. */
100
- displayAspect: gen.displayAspect ?? null,
101
- /** Verbatim. Some prompts are JSON-shaped because the person authored one; that object IS the prompt. */
102
- prompt: gen.prompt ?? null,
103
- outputs: urls.map((url, i) => ({
138
+ displayAspect: gen.displayAspect,
139
+ prompt: gen.prompt,
140
+ items: urls.map((url, i) => ({
104
141
  url,
105
142
  posterUrl: posterUrls[i] ?? null,
106
143
  name: `${gen.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
107
- /** Where the Open button goes: this asset, in the studio, with its siblings and every action. */
108
- studioUrl: studioUrlFor(baseUrl, gen.outputId, i, urls.length),
144
+ contentType: gen.contentType,
145
+ displayAspect: gen.displayAspect ?? null,
146
+ // A generation lives in the studio. Another producer supplies its own destination.
147
+ openUrl: studioUrlFor(baseUrl, gen.outputId, i, urls.length),
148
+ // Every generation output is referenceable by id, which is what makes Animate and Edit meaningful.
149
+ reference: `${gen.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
109
150
  })),
110
- };
151
+ });
111
152
  }
112
153
  export function completedResult(gen, attachments = [], posterUrls = [],
113
154
  /** The server this client talks to, so Open deep-links to it rather than always to production. */
@@ -215,7 +256,13 @@ export function getStatusCall(outputIds) {
215
256
  * case. The widget polls `get_generation_status`, and the name arrives with the first response.
216
257
  */
217
258
  export function pendingResult(outputId, pollAfterSeconds = 15, shape) {
218
- const prose = `Still rendering (outputId ${outputId}). This is normal for video. Call ${getStatusCall([outputId])} in ~${pollAfterSeconds}s [poll_after_seconds: ${pollAfterSeconds}] to get the final URLs.`;
259
+ /**
260
+ * ⚠️ THE REASSURANCE MUST MATCH THE MEDIUM. This said "This is normal for video" on every pending result,
261
+ * including image jobs, where it reads as the server describing something other than what was asked for.
262
+ * `shape` is present precisely when we know which medium it is.
263
+ */
264
+ const normal = shape?.contentType === 'image' ? '' : ' This is normal for video.';
265
+ const prose = `Still rendering (outputId ${outputId}).${normal} Call ${getStatusCall([outputId])} in ~${pollAfterSeconds}s [poll_after_seconds: ${pollAfterSeconds}] to get the final URLs.`;
219
266
  if (!shape)
220
267
  return text(prose);
221
268
  return {
@@ -233,16 +280,48 @@ export function pendingResult(outputId, pollAfterSeconds = 15, shape) {
233
280
  /** At least one, or the widget renders a grid with nothing in it and looks broken rather than busy. */
234
281
  expected: Math.max(1, shape.expected ?? 1),
235
282
  pollAfterSeconds,
236
- outputs: [],
283
+ items: [],
237
284
  },
238
285
  _meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
239
286
  };
240
287
  }
241
288
  /** Synchronous audio result (already complete on submit). */
242
- export function audioResult(result) {
289
+ /**
290
+ * Synchronous audio: already complete when the call returns.
291
+ *
292
+ * ## ⛔⛔⛔ THIS RENDERED NOTHING FOR THE ENTIRE LIFE OF THE WIDGET
293
+ *
294
+ * Audio has a first-class MCP block AND the widget plays it, and neither reached anyone, because this
295
+ * builder returned plain text and `generate_audio` was never added to the hand-maintained list of tools
296
+ * that declare a widget. Nobody decided audio should be invisible; it is what an allowlist does to
297
+ * anything nobody remembered to add.
298
+ *
299
+ * ⚠️ No model name, brand or aspect here: a synchronous result carries none of them, and inventing them
300
+ * would be worse than a chip that renders nothing. Audio has no shape, so `displayAspect` is genuinely
301
+ * null rather than unknown.
302
+ */
303
+ export function audioResult(result, baseUrl = DEFAULT_APP_URL) {
243
304
  const urls = result.outputUrls ?? [];
244
305
  const header = `Done. Audio generated (outputId ${result.outputId}):`;
245
- return text([header, ...urls.map((u, i) => `${i + 1}. ${u}`)].join('\n'));
306
+ const prose = [header, ...urls.map((u, i) => `${i + 1}. ${u}`)].join('\n');
307
+ if (!urls.length)
308
+ return text(prose);
309
+ return {
310
+ content: [{ type: 'text', text: prose }],
311
+ structuredContent: mediaWidgetData({
312
+ outputId: result.outputId,
313
+ contentType: 'audio',
314
+ items: urls.map((url, i) => ({
315
+ url,
316
+ name: `${result.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
317
+ contentType: 'audio',
318
+ // Audio has no shape, so there is nothing for a tile to take.
319
+ displayAspect: null,
320
+ openUrl: studioUrlFor(baseUrl, result.outputId, i, urls.length),
321
+ })),
322
+ }),
323
+ _meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
324
+ };
246
325
  }
247
326
  /**
248
327
  * In-place clip enhancement: ONE JOB PER SOURCE, so the agent gets every outputId.
@@ -299,8 +378,21 @@ export function generationStatusResult(gen, attachments = [], baseUrl) {
299
378
  * ⭐ Found by the `verify:inline` harness in its first run, minutes after it existed. The unit tests could
300
379
  * not see it: they call `completedResult` directly and never go through here.
301
380
  */
302
- if (gen.status === 'completed')
303
- return completedResult(gen, attachments, [], baseUrl);
381
+ if (gen.status === 'completed') {
382
+ const res = completedResult(gen, attachments, [], baseUrl);
383
+ /**
384
+ * ⛔⛔ **A REPORT CARRIES NO DISPLAY PAYLOAD, AND STRIPPING IT IS NOT COSMETIC.**
385
+ *
386
+ * This tool does not declare the widget, because `generate_*` already returns one that polls itself to
387
+ * completion and a second card for the same generation is the duplicate we removed. A result that still
388
+ * carried `structuredContent` and `_meta` would be ignored by the host, but it would also make
389
+ * "emits widget data" and "declares the widget" disagree, and that equivalence is exactly what the
390
+ * completeness guard checks in both directions. An invariant with an exemption is a list again.
391
+ */
392
+ delete res.structuredContent;
393
+ delete res._meta;
394
+ return res;
395
+ }
304
396
  if (gen.status === 'failed') {
305
397
  return text(`Generation ${gen.outputId} failed: ${gen.error ?? 'unknown error'}`, true);
306
398
  }
@@ -632,7 +724,54 @@ function batchItemLine(it, index, hasImage) {
632
724
  * array (fetched + base64-encoded by the caller, image items only; null for
633
725
  * video/audio/errors). This just assembles the result. See get-context §9.5.
634
726
  */
635
- export function mediaBatchResult(result, images) {
727
+ /**
728
+ * Turn resolved media into tiles.
729
+ *
730
+ * ## ⭐⭐⭐ THE BYTES ARE FOR THE AGENT, THE URLS ARE FOR THE PERSON, AND ONE CALL DOES BOTH
731
+ *
732
+ * The image blocks above are the agent's vision and they cost context, which is why they run through one
733
+ * shared budget. The widget renders from URLS, which cost nothing. So a call can attach as many pixels as
734
+ * the budget allows AND display every item, and the two limits do not fight: more items means fewer
735
+ * inlined images, never a card that shows less than was asked for.
736
+ *
737
+ * ⛔ **NO CHIP, BECAUSE `model` HERE IS A RAW ID.** `gpt-image-2` reads like a label, and substituting one
738
+ * is the exact defect that made the generation chip flicker between kebab case and title case. Resolving
739
+ * it needs the registry, the same way the generation path got its name, so until this payload carries a
740
+ * display name the tiles render their media and no label.
741
+ *
742
+ * ⚠️ Transcripts are skipped: the widget has no element for text, and a tile that renders nothing is worse
743
+ * than an item the summary already describes in words.
744
+ */
745
+ function mediaBatchItems(result, baseUrl) {
746
+ const items = [];
747
+ for (const it of result.items) {
748
+ if (!it.ok || !it.url)
749
+ continue;
750
+ if (it.type !== 'image' && it.type !== 'video' && it.type !== 'audio')
751
+ continue;
752
+ const g = it.geometry;
753
+ items.push({
754
+ url: it.url,
755
+ // A video's still, so a tile shows something before anyone presses play.
756
+ posterUrl: it.type === 'video' ? it.imageUrl : null,
757
+ /** The reference the API takes: `<id>` or `<id>-<n>`, one-based, matching what a person reads. */
758
+ name: it.mediaId ? `${it.mediaId}${it.variation && it.variation > 1 ? `-${it.variation}` : ''}` : it.url,
759
+ // ⚠️ Only a mediaId is referenceable. A raw url resolved here is not a library item the API can name.
760
+ reference: it.mediaId
761
+ ? `${it.mediaId}${it.variation && it.variation > 1 ? `-${it.variation}` : ''}`
762
+ : undefined,
763
+ contentType: it.type,
764
+ // MEASURED, from the storage spine. Null when nothing measured it, which the tile handles.
765
+ displayAspect: g ? aspectLabel(g.width, g.height) : null,
766
+ openUrl: it.mediaId
767
+ ? `${baseUrl.replace(/\/+$/, '')}/studio?output=${encodeURIComponent(it.mediaId)}` +
768
+ (it.variation && it.variation > 1 ? `&variation=${it.variation}` : '')
769
+ : undefined,
770
+ });
771
+ }
772
+ return items;
773
+ }
774
+ export function mediaBatchResult(result, images, baseUrl = DEFAULT_APP_URL) {
636
775
  const { items } = result;
637
776
  const okCount = items.filter((i) => i.ok).length;
638
777
  const keyframeCount = items.reduce((n, it) => n + (it.keyframes?.length ?? 0), 0);
@@ -658,7 +797,18 @@ export function mediaBatchResult(result, images) {
658
797
  }
659
798
  }
660
799
  });
661
- return { content };
800
+ /**
801
+ * ⭐ DISPLAY COSTS NOTHING EXTRA. The blocks above are the agent's vision and are budget-bounded; this is
802
+ * urls, so every resolved item renders whether or not its pixels fit that budget.
803
+ */
804
+ const tiles = mediaBatchItems(result, baseUrl);
805
+ if (tiles.length === 0)
806
+ return { content };
807
+ return {
808
+ content,
809
+ structuredContent: mediaWidgetData({ items: tiles }),
810
+ _meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
811
+ };
662
812
  }
663
813
  /** Phase 1 of an upload: the signed URL + the PUT-then-complete instructions. */
664
814
  export function mediaUploadResult(r) {
@@ -679,8 +829,53 @@ export function mediaUploadResult(r) {
679
829
  ]));
680
830
  }
681
831
  /** A finalized upload or import: a first-class media output. */
682
- export function uploadedMediaResult(r) {
683
- return text(`Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`);
832
+ /**
833
+ * Media the person's own bytes just became.
834
+ *
835
+ * ## ⭐ AN UPLOAD IS NEW MEDIA IN THEIR LIBRARY, SO IT DISPLAYS
836
+ *
837
+ * The rule is that a tool returning newly created or newly acquired media shows it, and an upload is the
838
+ * second. Confirmation is the value: a thumbnail says the right file landed, where a line of text says
839
+ * only that something did.
840
+ *
841
+ * ⛔ This could not render at all until the API started returning `contentType`. Guessing image from a
842
+ * url's extension is what renders a video as a broken image, so text was the honest answer while the type
843
+ * was unknown, and it remains the answer for a `document`, which has no element.
844
+ *
845
+ * ⚠️ Referenceable, unlike an export: `outputId` is exactly what `generate_*` accepts, which is what the
846
+ * prose has always told the caller.
847
+ */
848
+ export function uploadedMediaResult(r, baseUrl = DEFAULT_APP_URL) {
849
+ const prose = `Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`;
850
+ return renderableMedia(prose, r.outputId, r.url, r.contentType, baseUrl);
851
+ }
852
+ /**
853
+ * The shared tail of every "here is one new library item" result.
854
+ *
855
+ * ⚠️ ONE PLACE, because an upload and an import differ in their prose and in nothing else that matters
856
+ * here. Written twice they would drift the first time one of them learned something the other did not.
857
+ */
858
+ function renderableMedia(prose, outputId, url, contentType, baseUrl) {
859
+ const medium = contentType === 'image' || contentType === 'video' || contentType === 'audio' ? contentType : undefined;
860
+ if (!outputId || !medium)
861
+ return text(prose);
862
+ return {
863
+ content: [{ type: 'text', text: prose }],
864
+ structuredContent: mediaWidgetData({
865
+ outputId,
866
+ contentType: medium,
867
+ items: [
868
+ {
869
+ url,
870
+ name: outputId,
871
+ contentType: medium,
872
+ reference: outputId,
873
+ openUrl: studioUrlFor(baseUrl, outputId, 0, 1),
874
+ },
875
+ ],
876
+ }),
877
+ _meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
878
+ };
684
879
  }
685
880
  /**
686
881
  * The result of an import, which may have created nothing.
@@ -699,9 +894,9 @@ export function uploadedMediaResult(r) {
699
894
  * Saying "already imported" without naming what it is would send someone hunting for a library item that
700
895
  * does not exist. That is the exact confusion this whole fix came from.
701
896
  */
702
- export function importedMediaResult(r) {
897
+ export function importedMediaResult(r, baseUrl = DEFAULT_APP_URL) {
703
898
  if (!r.alreadyExisted) {
704
- return text(`Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`);
899
+ return renderableMedia(`Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`, r.outputId, r.url, r.contentType, baseUrl);
705
900
  }
706
901
  if (r.outputId) {
707
902
  return text(`Already in your library (id ${r.outputId}): ${r.url}. Nothing was imported: these exact bytes are already there. Reference it by outputId as usual.`);
@@ -1465,6 +1660,53 @@ export function layerTypesResult(cat) {
1465
1660
  JSON.stringify(cat, null, 2));
1466
1661
  }
1467
1662
  /** A completed export -> the download URL; an in-flight one -> the exportId to poll. */
1663
+ /**
1664
+ * Which formats the widget can actually draw.
1665
+ *
1666
+ * ⚠️ `pdf` and `pptx` have no element, and a multi-slide `png`/`jpg` export comes back as a ZIP rather than
1667
+ * an image. Rendering a tile for any of them would show a broken picture where the text already gives a
1668
+ * working download link, so they stay text.
1669
+ */
1670
+ const EXPORT_MEDIUM = { mp4: 'video', png: 'image', jpg: 'image' };
1671
+ /**
1672
+ * An export, DISPLAYED. Separate from {@link exportJobResult} by name, not by a flag.
1673
+ *
1674
+ * ## ⛔⛔ ONLY THE TOOL THAT STARTED THE EXPORT KNOWS ITS FORMAT
1675
+ *
1676
+ * `get_export` polls by exportId alone, so it cannot know whether the file is an mp4 or a pptx and can
1677
+ * never render one. Leaving both paths inside one builder made the completeness guard read `get_export` as
1678
+ * a tool that emits a widget, which it does not, and an invariant that has to be argued with is not one.
1679
+ * Two names, each true on its own.
1680
+ */
1681
+ export function completedExportResult(job, format, baseUrl = DEFAULT_APP_URL) {
1682
+ const prose = `Export ${job.exportId} completed.\nDownload: ${job.outputUrl}`;
1683
+ const medium = EXPORT_MEDIUM[format];
1684
+ if (job.status !== 'completed' || !job.outputUrl || !medium)
1685
+ return exportJobResult(job);
1686
+ {
1687
+ /**
1688
+ * ## ⛔⛔ AN EXPORT RENDERS, AND IT IS NOT REFERENCEABLE
1689
+ *
1690
+ * Someone waited for a render, so they should see it. But an `exportId` is not an `outputId`: no
1691
+ * generate tool resolves one, so Animate, Edit and Recreate would emit messages the agent cannot act
1692
+ * on. Omitting `reference` is what hides them, and Download, the verb that actually applies to a
1693
+ * rendered file, stays.
1694
+ *
1695
+ * ⚠️ NO `openUrl` EITHER, and not because it is hard to compute. An export's home is a download; the
1696
+ * project it came from is a different destination with a different meaning, and `/editor/{id}` versus
1697
+ * `/canvas/{id}` is not knowable from here anyway.
1698
+ */
1699
+ return {
1700
+ content: [{ type: 'text', text: prose }],
1701
+ structuredContent: mediaWidgetData({
1702
+ contentType: medium,
1703
+ items: [{ url: job.outputUrl, name: job.exportId, contentType: medium }],
1704
+ }),
1705
+ _meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
1706
+ };
1707
+ }
1708
+ }
1709
+ /** An export, REPORTED. No widget: a poll does not know the format, so it cannot draw the file. */
1468
1710
  export function exportJobResult(job) {
1469
1711
  if (job.status === 'completed') {
1470
1712
  return text(`Export ${job.exportId} completed.\nDownload: ${job.outputUrl}`);