@contenthero/mcp 0.4.7 → 0.4.8

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/server.js CHANGED
@@ -119,6 +119,72 @@ async function fetchSnapshotBase64(url) {
119
119
  return null;
120
120
  }
121
121
  }
122
+ /**
123
+ * The asset itself, ready to attach to a finished generation.
124
+ *
125
+ * ## The rule, per medium
126
+ *
127
+ * ⭐⭐⭐ **BYTES FOR WHAT MCP CAN CARRY, A LINK FOR WHAT IT CANNOT.** Images and audio have first-class
128
+ * content blocks and modest sizes, so they are embedded: that is what makes them render in the chat, and it
129
+ * is also what makes them permanent, because bytes in a transcript cannot expire. Video has no block of its
130
+ * own and a ten-second 1080p clip would be megabytes of base64 in every future turn of the conversation, so
131
+ * it travels as a `resource_link` pointing at a capability url.
132
+ *
133
+ * ⛔ **FAILING TO FETCH IS NOT AN ERROR.** A generation that succeeded must never be reported as failed
134
+ * because we could not inline a preview of it. Every failure path returns no attachment and the text result
135
+ * stands on its own, which is exactly what the caller got before this existed.
136
+ *
137
+ * ⚠️ ONLY THE FIRST OUTPUT IS EMBEDDED WHEN THERE ARE MANY. A four-image batch as four base64 payloads is a
138
+ * large multiple of the same conversation cost, and the urls for the rest are already in the text. The cap
139
+ * is stated here rather than left implicit, because a silent truncation reads as "that is all there was".
140
+ */
141
+ /**
142
+ * ⭐⭐⭐ **EVERY OUTPUT, AS A LINK, NOT THE FIRST ONE AS BYTES.**
143
+ *
144
+ * The first version embedded base64 for images and audio and capped at one attachment. Both halves were
145
+ * wrong, and they were wrong together:
146
+ *
147
+ * - **The cap made a four-variation batch show one variation.** Generating four and seeing one is not a
148
+ * smaller version of the feature, it is a broken one: the whole point of a batch is to compare them.
149
+ * - **Base64 is charged to the user's context on every subsequent turn.** A four-image batch embedded as
150
+ * bytes is a large multiple of the same cost, repeated for the rest of the conversation.
151
+ *
152
+ * ⭐ A `resource_link` costs a URL and renders in the host's UI, so ALL of them can come back. Verified
153
+ * against a working implementation: the Higgsfield MCP returns `resource_link` for its media and it renders.
154
+ *
155
+ * ## ⛔ THE THING THIS DELIBERATELY GIVES UP, AND WHERE IT WENT INSTEAD
156
+ *
157
+ * An `image` block feeds the MODEL's vision; a `resource_link` gives the HOST something to render for the
158
+ * human. Measured in this very session: when a link came back from another MCP, the model received text and
159
+ * could not see the picture.
160
+ *
161
+ * So the model can no longer critique a generation it just made from this result alone. That is the correct
162
+ * trade, because **`get_media` already exists to embed bytes for exactly that purpose** and an agent calls
163
+ * it when it actually needs to look. Deciding on every generation that the model probably wants to look was
164
+ * the wrong default: it spent the user's context to answer a question nobody asked.
165
+ *
166
+ * ⚠️ NO SSRF FETCH HAPPENS HERE ANY MORE. Nothing is downloaded, so the allowlist that guards
167
+ * `fetchSnapshotBase64` is not on this path; the url is handed to the host to fetch under its own rules.
168
+ */
169
+ const LINK_MIME = {
170
+ image: 'image/png',
171
+ video: 'video/mp4',
172
+ audio: 'audio/mpeg',
173
+ };
174
+ function attachmentsFor(gen) {
175
+ const urls = (gen.outputUrls ?? []).filter((u) => typeof u === 'string' && u.length > 0);
176
+ const mimeType = LINK_MIME[gen.contentType];
177
+ if (!mimeType)
178
+ return [];
179
+ const ext = mimeType.split('/')[1];
180
+ return urls.map((uri, i) => ({
181
+ kind: 'link',
182
+ uri,
183
+ mimeType,
184
+ // Named per output so a batch reads as four distinct things rather than four copies of one name.
185
+ name: `${gen.outputId}${urls.length > 1 ? `-${i + 1}` : ''}.${ext}`,
186
+ }));
187
+ }
122
188
  /**
123
189
  * True when an image URL is safe to fetch into an image block. SSRF allowlist:
124
190
  * our storage hosts plus the finite set of generation-provider CDNs that our
@@ -134,6 +200,11 @@ function isAllowedImageHost(url) {
134
200
  if (u.username || u.password)
135
201
  return false;
136
202
  return (u.host === 'cloud.contenthero.ai' ||
203
+ // ⭐ THE MEDIA GATEWAY. Generated assets now address through it with a capability token rather than a
204
+ // presigned R2 url, so without this every inline attachment would be silently dropped by the SSRF
205
+ // allowlist and the agent would be back to a bare link.
206
+ u.host === 'media.contenthero.ai' ||
207
+ u.host === 'cdn.contenthero.ai' ||
137
208
  u.host.endsWith('.supabase.co') ||
138
209
  u.host.endsWith('.fal.media') ||
139
210
  u.host.endsWith('.cloudinary.com'));
@@ -297,7 +368,7 @@ export function registerTools(server, opts) {
297
368
  server.registerTool('generate_image', {
298
369
  title: 'Generate Image',
299
370
  annotations: WRITE,
300
- 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. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
371
+ 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.',
301
372
  inputSchema: {
302
373
  modelId: z.enum(models.image).describe(IMAGE_MODEL_GUIDANCE),
303
374
  prompt: z
@@ -344,7 +415,7 @@ export function registerTools(server, opts) {
344
415
  if (args.getCost)
345
416
  return costResult(await client.estimateCost(request));
346
417
  const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
347
- return completedResult(gen);
418
+ return completedResult(gen, attachmentsFor(gen));
348
419
  }
349
420
  catch (err) {
350
421
  // A SUBMITTED generation is running and charged. Whether the wait timed out or a
@@ -399,7 +470,7 @@ export function registerTools(server, opts) {
399
470
  if (args.getCost)
400
471
  return costResult(await client.estimateBoardCost(request));
401
472
  const gen = await client.generateBoardAndWait(request, { timeoutMs: SMART_WAIT_MS });
402
- return completedResult(gen);
473
+ return completedResult(gen, attachmentsFor(gen));
403
474
  }
404
475
  catch (err) {
405
476
  // A SUBMITTED generation is running and charged. Whether the wait timed out or a
@@ -415,7 +486,7 @@ export function registerTools(server, opts) {
415
486
  server.registerTool('generate_video', {
416
487
  title: 'Generate Video',
417
488
  annotations: WRITE,
418
- 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. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
489
+ 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.',
419
490
  inputSchema: {
420
491
  modelId: z.enum(models.video).describe(VIDEO_MODEL_GUIDANCE),
421
492
  prompt: z
@@ -500,7 +571,7 @@ export function registerTools(server, opts) {
500
571
  if (args.getCost)
501
572
  return costResult(await client.estimateCost(request));
502
573
  const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
503
- return completedResult(gen);
574
+ return completedResult(gen, attachmentsFor(gen));
504
575
  }
505
576
  catch (err) {
506
577
  // A SUBMITTED generation is running and charged. Whether the wait timed out or a
@@ -516,7 +587,7 @@ export function registerTools(server, opts) {
516
587
  server.registerTool('generate_audio', {
517
588
  title: 'Generate Audio',
518
589
  annotations: WRITE,
519
- description: 'Generate audio with ElevenLabs: speech (TTS), music, or a sound effect. Returns the audio URL directly (synchronous, no polling). Optionally pass projectId to place the generated audio onto that editor project\'s timeline in the same call, controlled by an optional placement; omit projectId to save a standalone library output. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
590
+ description: 'Generate audio with ElevenLabs: speech (TTS), music, or a sound effect. Returns the audio URL directly (synchronous, no polling). Optionally pass projectId to place the generated audio onto that editor project\'s timeline in the same call, controlled by an optional placement; 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.',
520
591
  inputSchema: {
521
592
  modelId: z.enum(models.audio).describe(AUDIO_MODEL_GUIDANCE),
522
593
  prompt: z.string().optional().describe('For music / sfx: what to generate.'),
@@ -644,7 +715,7 @@ export function registerTools(server, opts) {
644
715
  if (args.getCost)
645
716
  return costResult(await client.estimateCost(request));
646
717
  const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
647
- return completedResult(gen);
718
+ return completedResult(gen, attachmentsFor(gen));
648
719
  }
649
720
  catch (err) {
650
721
  // A SUBMITTED generation is running and charged. Whether the wait timed out or a
@@ -705,7 +776,7 @@ export function registerTools(server, opts) {
705
776
  if (args.getCost)
706
777
  return costResult(await client.estimateCost(request));
707
778
  const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
708
- return completedResult(gen);
779
+ return completedResult(gen, attachmentsFor(gen));
709
780
  }
710
781
  catch (err) {
711
782
  // A SUBMITTED generation is running and charged. Whether the wait timed out or a
@@ -720,6 +791,13 @@ export function registerTools(server, opts) {
720
791
  // -- transcribe -----------------------------------------------------------
721
792
  server.registerTool('transcribe', {
722
793
  title: 'Transcribe Audio',
794
+ /*
795
+ ⭐ ANNOTATED, NOT JUST ARGUED FOR. The comment below has said "NOT read-only" since this tool
796
+ shipped, and nobody ever wrote the annotation, so the tool went out carrying NEITHER hint. Claude
797
+ files an unannotated tool under "Other", which is how 2 of 88 ended up unclassified: a comment
798
+ describing an enforcement nobody built reads exactly like one that was built.
799
+ */
800
+ annotations: WRITE,
723
801
  // NOT read-only, despite only returning text. readOnlyHint is a host's signal that a
724
802
  // tool is safe to call without asking the user, and this one is metered per minute of
725
803
  // audio: annotated READ, an agent could transcribe a two-hour file repeatedly,
@@ -2496,19 +2574,44 @@ export function registerTools(server, opts) {
2496
2574
  inputSchema: {
2497
2575
  projectId: z.string().optional().describe('Scope to a specific project (editor/canvas). Omit for the user\'s most-recent-active surface anywhere. Required for render when no session is live.'),
2498
2576
  capture: z.boolean().optional().describe("Also return a screenshot of the user's live viewport (their SCREEN), captured at read time. Default false returns structured context only. Request it only when the task depends on seeing the live, as-shown state including unsaved UI. To see the composed OUTPUT rather than the screen, use render instead."),
2499
- render: z.boolean().optional().describe('Also return an inline render (image[s]) of your work, so you can visually verify edits. Ephemeral, stored nowhere, counts against no quota, works without a live tab. render=true alone renders the current focus point as a still. Use mode=filmstrip for several frames across a range. Use this to check your work, not export_project. To watch a RAW source clip use get_media with a video item; for a composed VIDEO of a range use create_preview.'),
2500
- mode: z.enum(['still', 'filmstrip']).optional().describe("Render tier (inferred from the params if omitted): 'still' = one composed editor frame / canvas slide; 'filmstrip' = several composed frames across an editor range (judge motion / flow / cut placement)."),
2501
- frame: z.number().int().min(0).optional().describe('still (editor): which timeline frame to render. Omit to render the current playhead frame.'),
2502
- slideId: z.string().optional().describe('still (canvas): the id of the slide to render. Omit to render the focused slide.'),
2503
- slideIndex: z.number().int().min(1).optional().describe('still (canvas): the 1-based slide index to render (alternative to slideId).'),
2504
- fromFrame: z.number().int().min(0).optional().describe('filmstrip: start timeline frame of the range. Omit to start at the beginning.'),
2505
- toFrame: z.number().int().min(0).optional().describe('filmstrip: end timeline frame of the range. Omit to run to the end.'),
2506
- count: z.number().int().min(1).optional().describe('filmstrip: how many frames to return. Omit for a proportional default.'),
2507
- width: z.number().int().min(48).max(1440).optional().describe('still: render at an explicit DISPLAY width in pixels, to judge legibility at the size the output will actually be seen (a course tile, a thumbnail, a feed card) rather than at full resolution, where small type always looks fine. Height follows the composition aspect ratio and is not settable. Clamped; the size produced is reported back on rendered.'),
2577
+ render: z.boolean().optional().describe('Also render your work so you can visually verify edits. Ephemeral, stored nowhere, counts against no quota, works without a live tab. render=true alone renders the current focus point as one image; add count with fromFrame/toFrame for several across a range; mode=video returns a short playable clip of that range. Use this to check your work, not export_project, which produces a file the user KEEPS. To watch a RAW source clip instead of your composition, use get_media with a video item.'),
2578
+ mode: z.enum(['image', 'video']).optional().describe("What MEDIUM to render (default 'image'). 'image' returns composed frames INLINE: one by default, or several across a range when you pass count with fromFrame/toFrame, to judge motion, flow and cut placement. 'video' returns the range actually playing, as a short low-res composed clip, for timing a cut or a beat that separate frames cannot show; it is a JOB, returning a renderId to poll with get_preview, because it has to be rendered."),
2579
+ frame: z.number().int().min(0).optional().describe("mode='image' (editor): which single timeline frame to render. Omit to render the current playhead frame."),
2580
+ slideId: z.string().optional().describe("mode='image' (canvas): the id of the slide to render. Omit to render the focused slide."),
2581
+ slideIndex: z.number().int().min(1).optional().describe("mode='image' (canvas): the 1-based slide index to render (alternative to slideId)."),
2582
+ fromFrame: z.number().int().min(0).optional().describe('Start timeline frame of the range, for several frames or a video. Omit to start at the beginning.'),
2583
+ toFrame: z.number().int().min(0).optional().describe('End timeline frame of the range. Omit to run to the end.'),
2584
+ count: z.number().int().min(1).optional().describe("mode='image': how many frames to return across the range. Omit for one frame at the focus point, or a proportional default when a range is given."),
2585
+ width: z.number().int().min(48).max(1440).optional().describe("mode='image': render at an explicit DISPLAY width in pixels, to judge legibility at the size the output will actually be seen (a course tile, a thumbnail, a feed card) rather than at full resolution, where small type always looks fine. Height follows the composition aspect ratio and is not settable. Clamped; the size produced is reported back on rendered."),
2508
2586
  },
2509
2587
  }, async (args, extra) => {
2510
2588
  try {
2511
2589
  const client = await getClient(extra);
2590
+ /*
2591
+ ⭐⭐⭐ **`video` IS THE THIRD RUNG OF THIS LADDER, NOT A SEPARATE TOOL.** It used to be
2592
+ `create_preview`, and the split was drawn on HOW the render is delivered (a job, not inline)
2593
+ rather than on WHAT the caller is asking for. Both answers to "let me look at my own work,
2594
+ ephemerally, without producing a deliverable" now live behind one question.
2595
+
2596
+ ⛔ THE EVIDENCE THE OLD BOUNDARY WAS WRONG WAS IN THE DESCRIPTIONS. `get_context` ended with
2597
+ "for a composed VIDEO of a range use create_preview" and `create_preview` ended with "to see a
2598
+ single frame or a few frames use get_context render". Two tools each telling the agent when to
2599
+ use the other is routing work the schema should be doing. The CLI had already reached this
2600
+ conclusion: it exposes `context preview`, a sibling of `context`, while export lives under
2601
+ `project`.
2602
+
2603
+ ⚠️ THE TRANSPORT IS UNCHANGED. This is a facade over the same client call the old tool made, so
2604
+ nothing moved server-side and the SDK needed no new field.
2605
+ */
2606
+ if (args.mode === 'video') {
2607
+ const job = await client.createPreview({
2608
+ projectId: args.projectId ?? '',
2609
+ fromFrame: args.fromFrame,
2610
+ toFrame: args.toFrame,
2611
+ });
2612
+ return text(`Preview render started (frames ${job.fromFrame}-${job.toFrame}, ~${job.durationSeconds}s).\n` +
2613
+ `Poll get_preview with renderId="${job.renderId}" and bucketName="${job.bucketName}" until status is "done", then fetch the returned url.`);
2614
+ }
2512
2615
  const result = await client.getContext({
2513
2616
  projectId: args.projectId,
2514
2617
  capture: args.capture,
@@ -2530,37 +2633,13 @@ export function registerTools(server, opts) {
2530
2633
  return errorResult(err);
2531
2634
  }
2532
2635
  });
2533
- server.registerTool('create_preview', {
2534
- title: 'Create Preview',
2535
- // NOT read-only. It does not charge the caller's credits (unlike transcribe), but it
2536
- // STARTS A RENDER JOB: it returns a renderId you then poll, which is state that did
2537
- // not exist before the call. readOnlyHint says a tool does not modify its
2538
- // environment, and dispatching a Lambda render does. get_preview, which only reads
2539
- // that job, stays READ.
2540
- description: "Create an async PREVIEW of your work (ephemeral, never stored, not a deliverable). Currently a short low-res COMPOSED VIDEO of an editor range, so you can assess motion, cuts, transitions, and pacing that a still cannot show. This is a JOB: it returns a renderId + bucketName; poll get_preview with those until it is done, then fetch the returned url. To see a single frame or a few frames instead (cheaper, instant), use get_context render. Requires the context:read scope.",
2541
- inputSchema: {
2542
- projectId: z.string().describe('The editor project to preview.'),
2543
- fromFrame: z.number().int().min(0).optional().describe('Start timeline frame of the range. Omit to start at the beginning.'),
2544
- toFrame: z.number().int().min(0).optional().describe('End timeline frame. Omit to run to the end (capped to a short preview length).'),
2545
- },
2546
- }, async (args, extra) => {
2547
- try {
2548
- const client = await getClient(extra);
2549
- const job = await client.createPreview({ projectId: args.projectId, fromFrame: args.fromFrame, toFrame: args.toFrame });
2550
- return text(`Preview render started (frames ${job.fromFrame}-${job.toFrame}, ~${job.durationSeconds}s).\n` +
2551
- `Poll get_preview with renderId="${job.renderId}" and bucketName="${job.bucketName}" until status is "done", then fetch the returned url.`);
2552
- }
2553
- catch (err) {
2554
- return errorResult(err);
2555
- }
2556
- });
2557
2636
  server.registerTool('get_preview', {
2558
2637
  title: 'Get Preview',
2559
2638
  annotations: READ,
2560
- description: 'Poll a preview started with create_preview. While rendering, returns the progress; when done, returns a short-lived url to the ephemeral preview output (plus the estimated cost). Requires the context:read scope.',
2639
+ description: 'Poll a preview started by get_context with mode="video". While rendering, returns the progress; when done, returns a short-lived url to the ephemeral preview output (plus the estimated cost). Requires the context:read scope.',
2561
2640
  inputSchema: {
2562
- renderId: z.string().describe('The renderId returned by create_preview.'),
2563
- bucketName: z.string().describe('The bucketName returned by create_preview.'),
2641
+ renderId: z.string().describe('The renderId returned by get_context with mode="video".'),
2642
+ bucketName: z.string().describe('The bucketName returned by get_context with mode="video".'),
2564
2643
  },
2565
2644
  }, async (args, extra) => {
2566
2645
  try {