@contenthero/mcp 0.4.12 → 0.4.14

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 DELETED
@@ -1,3315 +0,0 @@
1
- /**
2
- * The ContentHero MCP tool surface: intent-shaped tools over the @contenthero/sdk
3
- * kernel.
4
- *
5
- * generate_image - smart-wait, image models
6
- * generate_video - smart-wait, video models
7
- * generate_audio - synchronous (ElevenLabs), no polling
8
- * upscale - smart-wait, image/video upscalers
9
- * generate_lip_sync - smart-wait, talking-head lip-sync (portrait + audio/script)
10
- * transcribe - synchronous speech-to-text (audio URL -> transcript)
11
- * list_avatars / get_avatar - the account's avatars (base look + default voice)
12
- * list_voices / get_voice - the account's saved voices
13
- * list_brand_kits / get_brand_kit - the account's brand kits (full brand context)
14
- * list_media / get_media - the account's studio outputs (+ per-variation ids)
15
- * search_media - semantic search of the editable media library (with scene timestamps)
16
- * get_generation_status - check 1-8 outputIds; blocks until terminal by default
17
- * get_balance - credit balance + tier
18
- * ... plus the content-pipeline, brand-kit-write, inspiration, brand-account,
19
- * and connected-account tools.
20
- *
21
- * `registerTools(server, opts)` registers the whole surface against a backend
22
- * resolved PER CALL via `opts.getClient(extra)`. The stdio/npm server passes a
23
- * single env-configured client (identity is in the API key); the hosted OAuth
24
- * server passes a factory that resolves a per-user client from the validated
25
- * token's `extra.authInfo`. Tool schemas (incl. the per-tool model enums) are
26
- * fixed at registration, so the model enums are supplied via `opts.models`.
27
- *
28
- * Intent-shaped generate tools rather than one generate_media: each operation
29
- * (generate / upscale / lip-sync) gets a tool whose schema only carries its own
30
- * fields, and per-tool modelId enums prevent cross-type model misuse.
31
- */
32
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
33
- /**
34
- * ⚠️⚠️ **THE CONSTANTS ONLY, NOT THE `./server` HELPERS, AND THAT IS DELIBERATE.**
35
- *
36
- * `@modelcontextprotocol/ext-apps@2` targets the SPLIT packages (`@modelcontextprotocol/server`), while this
37
- * server is built on the monolithic `@modelcontextprotocol/sdk@1.26`. Its `registerAppResource` therefore
38
- * typechecks against a different `ResourceMetadata` than ours and rejects `description`.
39
- *
40
- * ⭐ The helper is convenience over a two-line contract: a resource whose mimeType is the app profile, and a
41
- * tool result whose `_meta` names it. Registering through OUR `server.registerResource` keeps one server
42
- * abstraction instead of two, and the STRINGS still come from the package, so the part that must match the
43
- * spec has a single source. Migrating to the split SDK is its own piece of work, not a prerequisite for this.
44
- */
45
- import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from '@modelcontextprotocol/ext-apps';
46
- import { z } from 'zod';
47
- import { GenerationTimeoutError, pendingOutputId, } from '@contenthero/sdk';
48
- import { getClient as defaultGetClient } from './client.js';
49
- /**
50
- * The widget document itself, generated by `widget/build.mjs` before `tsc` runs.
51
- *
52
- * ⚠️ STATIC, NOT LAZY. Both consumers of this package are servers whose job is to serve this widget: the
53
- * `contenthero-mcp` bin and the app's hosted `/api/mcp` route. Nobody pays for bytes they do not use, and a
54
- * static import cannot be missed by a bundler the way a dynamic one can.
55
- */
56
- import { GENERATION_WIDGET_HTML, PACKAGE_VERSION } from './widget/generation.js';
57
- export { GENERATION_WIDGET_URI } from './widget-uri.js';
58
- import { GENERATION_WIDGET_URI } from './widget-uri.js';
59
- 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';
60
- 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, completedExportResult, exportFormatsResult, trackedAccountListResult, transcriptResult, voiceListResult, voiceResult, } from './format.js';
61
- /** Platforms a card or one of its posts may target. */
62
- const POST_PLATFORMS = [
63
- 'youtube',
64
- 'instagram',
65
- 'tiktok',
66
- 'facebook',
67
- 'linkedin',
68
- 'x',
69
- 'threads',
70
- 'general',
71
- ];
72
- /**
73
- * How long the smart-wait tools (generate_image / generate_video / upscale /
74
- * generate_lip_sync) wait inline before handing back the outputId to poll.
75
- * Kept under the MCP SDK's default 60s client request timeout, so a slow render
76
- * returns the clean "still rendering, call get_generation_status" handoff rather
77
- * than tripping the client's timeout.
78
- */
79
- const SMART_WAIT_MS = 50_000;
80
- /**
81
- * What a still-running generation can already say about the shape of its own result, read from the tool's
82
- * own ARGUMENTS.
83
- *
84
- * ⚠️ **FROM `args`, NOT FROM THE BUILT REQUEST.** Every one of these sites builds its request inside a
85
- * `try`, so the request is out of scope in the `catch` where a pending outputId surfaces. `args` is the
86
- * handler's parameter and is always in scope, and it is also the more honest source: it is what the caller
87
- * asked for, which is exactly what the placeholders should depict.
88
- *
89
- * ⚠️ Read defensively because the count is spelled `numImages` on some tools and `numGenerations` on
90
- * others. A widened type here would be a third spelling; reading both is the whole reconciliation.
91
- *
92
- * ⛔ `auto` and `adaptive` are legal aspect inputs meaning "the model decides", so they are NOT ratios.
93
- * Passing one through would have the widget lay placeholders out against a string it cannot parse. Null
94
- * lets it fall back to its unshaped box, which is the honest state while nothing is known.
95
- */
96
- function pendingShapeFrom(args, contentType) {
97
- const a = (args ?? {});
98
- const ar = a.aspectRatio;
99
- const displayAspect = !ar || ar === 'auto' || ar === 'adaptive' || !ar.includes(':') ? null : ar;
100
- return {
101
- contentType,
102
- modelId: a.modelId ?? '',
103
- displayAspect,
104
- expected: a.numImages ?? a.numGenerations ?? 1,
105
- };
106
- }
107
- /**
108
- * Tool annotations drive how MCP clients group the surface. readOnlyHint=true
109
- * tools list under "Read-only"; the rest list under "Interactive". publish is
110
- * also flagged destructive (it pushes content to public social accounts).
111
- */
112
- const READ = { readOnlyHint: true };
113
- const WRITE = { readOnlyHint: false };
114
- const PUBLISH = { readOnlyHint: false, destructiveHint: true };
115
- /**
116
- * Placement intent for the generative tools' optional one-call timeline placement. Mirrors the SDK
117
- * `PlacementIntent` union. All positional fields are in SECONDS (resolved to frames server-side via the
118
- * project fps). Shared across the generative tools as they gain `projectId` placement.
119
- */
120
- const TRACK_SELECTOR_DESC = "Which track to place on: 'overlay' (a non-primary track with room, else a new track), 'primary' (the main track, media only), or a specific track id. Omit for the default track.";
121
- const TIMELINE_PLACEMENT_SCHEMA = z.discriminatedUnion('mode', [
122
- z.object({ mode: z.literal('append') }).describe('Land after the last clip on the best track of the clip\'s kind, or a new track.'),
123
- z.object({ mode: z.literal('at'), startSeconds: z.number().optional(), track: z.string().optional().describe(TRACK_SELECTOR_DESC), durationSeconds: z.number().optional().describe('For an IMAGE placed as a point, the clip length in seconds (omitted uses the default still duration). Ignored for video/audio, whose length is the asset\'s own.') }).describe('Land at an explicit time; optionally choose the track.'),
124
- z.object({ mode: z.literal('atPlayhead'), durationSeconds: z.number().optional().describe('For an IMAGE placed as a point, the clip length in seconds (omitted uses the default still duration). Ignored for video/audio.') }).describe('Land at the current playhead.'),
125
- z.object({ mode: z.literal('replace'), itemId: z.string(), duration: z.enum(['natural', 'match']).optional() }).describe('Swap in place for an existing clip; the new clip inherits its track + start.'),
126
- z.object({ mode: z.literal('range'), startSeconds: z.number().optional(), endSeconds: z.number().optional(), track: z.string().optional().describe(TRACK_SELECTOR_DESC), fit: z.enum(['cover', 'trim', 'overwrite']).optional() }).describe('Fill or cover a time span; optionally choose the track.'),
127
- ]);
128
- /**
129
- * CANVAS placement (6A A8): places the generated asset as a LAYER on a slide. Flat (no mode); the server reads it
130
- * only for a canvas-design project. All fields optional. Positions/sizes are design pixels; the response returns
131
- * the created layerId + resolvedSlideId so you can chain further ops (animate / reposition / reorder).
132
- */
133
- const CANVAS_PLACEMENT_SCHEMA = z.object({
134
- slideId: z.string().optional().describe('The target slide. Omitted, the asset is placed on the slide the user is currently focused on (the one centered in their viewport, the same focused slide get_context reports), falling back to the first slide when no view is active.'),
135
- slideIndex: z.number().int().min(1).optional().describe('1-based slide number, an alternative to slideId.'),
136
- fit: z.enum(['contain', 'cover', 'none']).optional().describe("How the asset is sized to the slide: 'contain' (default) scales to fit inside it, 'cover' fills it edge to edge, 'none' uses a default box. Overridden by explicit width/height."),
137
- anchor: z.enum(['center', 'top-left', 'top', 'top-right', 'left', 'right', 'bottom-left', 'bottom', 'bottom-right']).optional().describe('A nine-point anchor positioning the layer relative to the slide (default center).'),
138
- x: z.number().optional().describe('Fine position offset in design pixels from the anchor (from slide center when no anchor is given).'),
139
- y: z.number().optional().describe('Fine position offset in design pixels from the anchor.'),
140
- width: z.number().optional().describe('Explicit layer width in design pixels; the precise escape hatch that overrides fit.'),
141
- height: z.number().optional().describe('Explicit layer height in design pixels; overrides fit.'),
142
- asBackground: z.boolean().optional().describe("Make the generated asset the slide's BACKGROUND rather than a free layer: placed full-bleed and promoted into the background slot once it lands. Ignores anchor / x / y / width / height (a background fills the slide)."),
143
- }).describe('Canvas placement: place the asset as a layer on a slide (or as the slide background with asBackground).');
144
- const PLACEMENT_SCHEMA = z.union([TIMELINE_PLACEMENT_SCHEMA, CANVAS_PLACEMENT_SCHEMA]);
145
- /** The optional one-call placement input fields, shared across the generative tools that gain projectId. */
146
- const PLACEMENT_INPUT_FIELDS = {
147
- projectId: z.string().optional().describe('The project to place the result on. Omit for a standalone library output. The server interprets placement against the project\'s surface (video timeline or canvas design).'),
148
- placement: PLACEMENT_SCHEMA.optional().describe('Where the asset lands, interpreted against the project\'s surface. VIDEO TIMELINE: append to the end, at a time, at the playhead, replacing an existing clip, or filling a time range (omitted places it at the playhead when known, else appends). CANVAS DESIGN: a layer on a slide (slideId / slideIndex, default the focused slide; fit contain|cover|none; a nine-point anchor; and design-pixel x/y/width/height).'),
149
- playheadFrame: z.number().optional().describe('The current playhead frame, for playhead-relative timeline placement.'),
150
- };
151
- /**
152
- * Fetch a get_context snapshot signed URL and base64-encode it, so get_context can return an IMAGE content
153
- * block the calling model actually sees. Best-effort: any failure returns null and the tool still returns the
154
- * textual context. The signed URL is self-authorizing (no secret needed here).
155
- */
156
- async function fetchSnapshotBase64(url) {
157
- try {
158
- const res = await fetch(url);
159
- if (!res.ok)
160
- return null;
161
- const mimeType = res.headers.get('content-type') || 'image/webp';
162
- const data = Buffer.from(await res.arrayBuffer()).toString('base64');
163
- return { data, mimeType };
164
- }
165
- catch {
166
- return null;
167
- }
168
- }
169
- /**
170
- * The asset itself, ready to attach to a finished generation.
171
- *
172
- * ## The rule, per medium
173
- *
174
- * ⭐⭐⭐ **BYTES FOR WHAT MCP CAN CARRY, A LINK FOR WHAT IT CANNOT.** Images and audio have first-class
175
- * content blocks and modest sizes, so they are embedded: that is what makes them render in the chat, and it
176
- * is also what makes them permanent, because bytes in a transcript cannot expire. Video has no block of its
177
- * own and a ten-second 1080p clip would be megabytes of base64 in every future turn of the conversation, so
178
- * it travels as a `resource_link` pointing at a capability url.
179
- *
180
- * ⛔ **FAILING TO FETCH IS NOT AN ERROR.** A generation that succeeded must never be reported as failed
181
- * because we could not inline a preview of it. Every failure path returns no attachment and the text result
182
- * stands on its own, which is exactly what the caller got before this existed.
183
- *
184
- * ⚠️ ONLY THE FIRST OUTPUT IS EMBEDDED WHEN THERE ARE MANY. A four-image batch as four base64 payloads is a
185
- * large multiple of the same conversation cost, and the urls for the rest are already in the text. The cap
186
- * is stated here rather than left implicit, because a silent truncation reads as "that is all there was".
187
- */
188
- /**
189
- * ⭐⭐⭐ **EVERY OUTPUT, AS A LINK, NOT THE FIRST ONE AS BYTES.**
190
- *
191
- * The first version embedded base64 for images and audio and capped at one attachment. Both halves were
192
- * wrong, and they were wrong together:
193
- *
194
- * - **The cap made a four-variation batch show one variation.** Generating four and seeing one is not a
195
- * smaller version of the feature, it is a broken one: the whole point of a batch is to compare them.
196
- * - **Base64 is charged to the user's context on every subsequent turn.** A four-image batch embedded as
197
- * bytes is a large multiple of the same cost, repeated for the rest of the conversation.
198
- *
199
- * ⭐ A `resource_link` costs a URL and renders in the host's UI, so ALL of them can come back. Verified
200
- * against a working implementation: the Higgsfield MCP returns `resource_link` for its media and it renders.
201
- *
202
- * ## ⛔ THE THING THIS DELIBERATELY GIVES UP, AND WHERE IT WENT INSTEAD
203
- *
204
- * An `image` block feeds the MODEL's vision; a `resource_link` gives the HOST something to render for the
205
- * human. Measured in this very session: when a link came back from another MCP, the model received text and
206
- * could not see the picture.
207
- *
208
- * So the model can no longer critique a generation it just made from this result alone. That is the correct
209
- * trade, because **`get_media` already exists to embed bytes for exactly that purpose** and an agent calls
210
- * it when it actually needs to look. Deciding on every generation that the model probably wants to look was
211
- * the wrong default: it spent the user's context to answer a question nobody asked.
212
- *
213
- * ⚠️ NO SSRF FETCH HAPPENS HERE ANY MORE. Nothing is downloaded, so the allowlist that guards
214
- * `fetchSnapshotBase64` is not on this path; the url is handed to the host to fetch under its own rules.
215
- */
216
- const LINK_MIME = {
217
- image: 'image/png',
218
- video: 'video/mp4',
219
- audio: 'audio/mpeg',
220
- };
221
- /**
222
- * ⛔⛔⛔ **A `resource_link` DOES NOT RENDER. MEASURED IN BOTH HOSTS, IN PRODUCTION, 2026-09-19.**
223
- *
224
- * This returned `kind: 'link'` for every output, including images, and the result was the feature not
225
- * working at all:
226
- *
227
- * - **ChatGPT** showed the output id and a "View the generated image" hyperlink. Clicking it opened the
228
- * asset in a NEW TAB, which is the opposite of inline.
229
- * - **Claude** showed NOTHING. No image, no link, no output id.
230
- *
231
- * ⚠️ **THE FORMATTER COULD ALWAYS DO THIS AND NOTHING EVER ASKED IT TO.** `completedResult` has handled a
232
- * `kind: 'bytes'` attachment since it was written, and its own docblock claims images get first-class
233
- * blocks. This function never produced one, so the branch had no caller. Same shape as the capability-url
234
- * no-op: a path that exists, typechecks, passes tests, and is unreachable.
235
- *
236
- * ⭐ **AN `image` BLOCK IS THE ONLY THING A HOST ACTUALLY RENDERS**, and it feeds the model's vision as
237
- * well, so the earlier reasoning that `get_media` covers the looking case was answering a different
238
- * question than the one the user asked: they wanted to SEE it.
239
- *
240
- * ## Both, not either
241
- *
242
- * Images get a block AND a link. The block is the small `.preview.webp` sibling, so inline display costs a
243
- * few hundred tokens rather than the megabytes a 2736x1536 original would. The link is the capability url:
244
- * permanent, full resolution, and the thing to click when the preview is not enough.
245
- *
246
- * ⚠️ VIDEO STAYS LINK-ONLY. MCP has no video content block, and base64 video in a transcript is not a
247
- * trade worth making. Audio likewise has no small derivative to send, so it stays a link too.
248
- */
249
- /**
250
- * ⛔⛔ **THERE IS NO HOST DETECTION HERE, AND THAT IS NOT AN OVERSIGHT.**
251
- *
252
- * The obvious optimization is to skip the bytes when the host will mount the widget, since the widget loads
253
- * media from a URL and the blocks are only a fallback. I wrote it, and it could never work: MCP Apps
254
- * declares its support under `capabilities.extensions["io.modelcontextprotocol/ui"]`, and
255
- * **`@modelcontextprotocol/sdk@1.26` does not know the word `extensions`** (measured: zero occurrences in
256
- * its types). The schema strips it, so `getClientCapabilities()` returns the same answer for a host that
257
- * mounts widgets and one that cannot, and the check silently reduced to a constant.
258
- *
259
- * ⭐ A CHECK THAT ALWAYS ANSWERS THE SAME WAY IS WORSE THAN NO CHECK: it reads as a decision being made.
260
- * Deleted, and the budget below is what keeps every result under the host's ceiling on its own.
261
- *
262
- * ⏭️ The split packages (`@modelcontextprotocol/server@2`) carry the field. Migrating to them is what
263
- * unlocks this, and it is its own piece of work rather than a prerequisite for rendering.
264
- */
265
- export async function attachmentsFor(gen) {
266
- /**
267
- * ⛔⛔⛔ **ONE BUDGET FOR THE WHOLE RESULT, BECAUSE THE HOST'S CEILING IS PER RESULT.**
268
- *
269
- * This was a PER-ITEM cap, which is the same defect one level up from the one it replaced. Four images at
270
- * 600 KB each pass individually (822 KB encoded, under the budget) and total 3.3 MB, so the host rejects
271
- * the call and the person pays for four generations they cannot reach. Today's assets are ~3.6 MB apiece
272
- * and fail the per-item check anyway, so the batch case was safe BY ACCIDENT rather than by design.
273
- *
274
- * ⭐ Spending a single budget makes the envelope bounded no matter the count or the resolution: four 4K
275
- * images, ten variations, a 60 second video. Whatever does not fit degrades to a link, and the widget
276
- * renders it from a URL regardless, so nothing is lost but the fallback for hosts that cannot mount apps.
277
- */
278
- let budget = MAX_INLINE_BASE64_CHARS;
279
- const urls = (gen.outputUrls ?? []).filter((u) => typeof u === 'string' && u.length > 0);
280
- const mimeType = LINK_MIME[gen.contentType];
281
- if (!mimeType)
282
- return [];
283
- const ext = mimeType.split('/')[1];
284
- const out = [];
285
- for (const [i, uri] of urls.entries()) {
286
- /**
287
- * ⭐ AUDIO HAS A FIRST-CLASS BLOCK TOO, and it plays inline exactly as an image draws. It is fetched the
288
- * same best-effort way: a miss degrades this one output to a link rather than failing a generation the
289
- * person already paid for.
290
- *
291
- * ⚠️ NO PREVIEW DERIVATIVE EXISTS FOR AUDIO, so this is the real file and the size cap is what stops a
292
- * long track going into the transcript. A voiceover is small; an hour of music is not, and that one
293
- * degrades to a link, which the widget renders anyway.
294
- */
295
- if (gen.contentType === 'audio') {
296
- const bytes = await fetchAudioBytes(uri, budget);
297
- if (bytes) {
298
- budget -= bytes.data.length;
299
- out.push({ kind: 'bytes', type: 'audio', data: bytes.data, mimeType: bytes.mimeType });
300
- }
301
- }
302
- if (gen.contentType === 'image') {
303
- // Best-effort: a miss (host not allowlisted, over the budget, network hiccup) degrades this one output
304
- // to a link instead of failing a generation the user already paid for.
305
- const bytes = await fetchMediaImageBase64(uri, budget);
306
- if (bytes) {
307
- budget -= bytes.data.length;
308
- out.push({ kind: 'bytes', type: 'image', data: bytes.data, mimeType: bytes.mimeType });
309
- }
310
- }
311
- /**
312
- * ⛔ NO `resource_link` PER OUTPUT ANY MORE. It was a THIRD representation of a url the text list and the
313
- * widget's `structuredContent` both already carry, and hosts render a run of them as `name: uri` with no
314
- * separator, producing tokens that read as corrupted. The link added nothing the text did not, and cost
315
- * a per-output block to say it.
316
- */
317
- void ext;
318
- void mimeType;
319
- }
320
- return out;
321
- }
322
- /**
323
- * True when an image URL is safe to fetch into an image block. SSRF allowlist:
324
- * our storage hosts plus the finite set of generation-provider CDNs that our
325
- * finalize pipeline stores as video posters (fal, cloudinary). The values fed
326
- * here are server-produced (a resolved variation url or a DB-stored thumbnail),
327
- * not raw caller input (the API already allowlists raw caller urls more strictly).
328
- */
329
- function isAllowedImageHost(url) {
330
- try {
331
- const u = new URL(url);
332
- if (u.protocol !== 'https:')
333
- return false;
334
- if (u.username || u.password)
335
- return false;
336
- return (u.host === 'cloud.contenthero.ai' ||
337
- // ⭐ THE MEDIA GATEWAY. Generated assets now address through it with a capability token rather than a
338
- // presigned R2 url, so without this every inline attachment would be silently dropped by the SSRF
339
- // allowlist and the agent would be back to a bare link.
340
- u.host === 'media.contenthero.ai' ||
341
- u.host === 'cdn.contenthero.ai' ||
342
- u.host.endsWith('.supabase.co') ||
343
- u.host.endsWith('.fal.media') ||
344
- u.host.endsWith('.cloudinary.com'));
345
- }
346
- catch {
347
- return false;
348
- }
349
- }
350
- /**
351
- * The optimized `.preview.webp` sibling of a studio-outputs image object, or the
352
- * url unchanged. Mirrors the app's previewImageSrc convention so we can prefer
353
- * the light derivative when it exists (and fall back to the raw when it does not,
354
- * e.g. an uploaded image or a not-yet-optimized video thumbnail). Pure string rule.
355
- */
356
- function optimizedImageSibling(url) {
357
- if (!url.includes('/object/public/studio-outputs/'))
358
- return url;
359
- if (url.includes('.preview.webp'))
360
- return url;
361
- const qIdx = url.indexOf('?');
362
- const path = qIdx < 0 ? url : url.slice(0, qIdx);
363
- const query = qIdx < 0 ? '' : url.slice(qIdx + 1);
364
- const rewritten = path.replace(/\.(png|jpe?g|webp|gif|avif|tiff?)$/i, '.preview.webp');
365
- if (rewritten === path)
366
- return url;
367
- return query ? `${rewritten}?${query}` : rewritten;
368
- }
369
- /**
370
- * ⛔⛔⛔ **A HOST ENFORCES A 1 MB CEILING ON A WHOLE TOOL RESULT, AND THIS IS SIZED AGAINST THAT.**
371
- *
372
- * Claude Desktop rejects an oversized result outright with "Tool result is too large. Maximum size is 1MB",
373
- * which fails the CALL rather than degrading the picture. Measured 2026-09-19 on a real `generate_image`:
374
- * the generation succeeded and was charged, and the person could not retrieve it.
375
- *
376
- * ⚠️ I SET THIS CAP WRONG TWICE BEFORE GETTING HERE. 1.5 MB was too low and would have silently degraded
377
- * real outputs back to links; 8 MB was too high and broke the host. Both were reasoned from what the BYTES
378
- * cost us. The number that actually governs belongs to the host, and it bounds the ENTIRE result: text,
379
- * structured content, every block. So the budget is expressed in BASE64 LENGTH, which is what travels, and
380
- * leaves room for everything else in the envelope.
381
- *
382
- * ⭐ Over budget, the item degrades to a link and the widget still renders it, because the widget loads from
383
- * a url rather than from bytes.
384
- *
385
- * ⚠️ **DERIVED, NOT PICKED.** The ceiling is 1,000,000 bytes for the ENTIRE serialized result. Measured on a
386
- * real one: text, `structuredContent` and the links together weigh about 2 KB. 900,000 base64 characters
387
- * leaves roughly 100 KB of headroom, and admits a 657 KB source asset. A 613 KB PNG measured in production
388
- * encodes to about 840 KB, so it fits with room to spare, where the 700,000 I first wrote would have thrown
389
- * it away. That was the third time I set this number from reasoning instead of from a measurement.
390
- */
391
- const MAX_INLINE_BASE64_CHARS = 900_000;
392
- /**
393
- * The audio equivalent of `fetchImageBytes`, sharing its host allowlist, its size cap and its timeout.
394
- *
395
- * ⚠️ SEPARATE RATHER THAN A `kind` PARAMETER because the content-type CHECK is the difference, and a single
396
- * function taking "which prefix do I accept" is the shape that eventually accepts the wrong one.
397
- */
398
- async function fetchAudioBytes(url, budget) {
399
- if (!isAllowedImageHost(url))
400
- return null;
401
- try {
402
- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
403
- if (!res.ok)
404
- return null;
405
- const mimeType = res.headers.get('content-type') || 'audio/mpeg';
406
- if (!mimeType.startsWith('audio/'))
407
- return null;
408
- // Base64 inflates by about a third, so the declared byte length is checked against the budget it will
409
- // BECOME rather than against itself.
410
- const declared = Number(res.headers.get('content-length') ?? '');
411
- if (Number.isFinite(declared) && declared * 1.37 > budget)
412
- return null;
413
- const data = Buffer.from(await res.arrayBuffer()).toString('base64');
414
- if (data.length > budget)
415
- return null;
416
- return { data, mimeType };
417
- }
418
- catch {
419
- return null;
420
- }
421
- }
422
- async function fetchImageBytes(url, budget) {
423
- if (!isAllowedImageHost(url))
424
- return null;
425
- try {
426
- /**
427
- * ⚠️⚠️ **A BARE `fetch` HAS NO TIMEOUT, AND THIS ONE IS ON THE PATH OF EVERY GENERATION RESULT.**
428
- *
429
- * One unresponsive asset would hang the whole tool call rather than degrading that item to a link, and
430
- * the caller would see a dead generation they had already paid for. Found by `verify:inline` hanging on
431
- * its first run after the status path started attaching.
432
- *
433
- * ⭐ Failing is CHEAP here and the fallback is good: no block, keep the link. Waiting is what is
434
- * expensive, so the budget is deliberately short.
435
- */
436
- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
437
- if (!res.ok)
438
- return null;
439
- const mimeType = res.headers.get('content-type') || 'image/jpeg';
440
- if (!mimeType.startsWith('image/'))
441
- return null;
442
- // Checked BEFORE reading the body where the server declares it, and again after, because
443
- // `content-length` is absent on a chunked response and a header is not a measurement.
444
- const declared = Number(res.headers.get('content-length') ?? '');
445
- if (Number.isFinite(declared) && declared * 1.37 > budget)
446
- return null;
447
- const data = Buffer.from(await res.arrayBuffer()).toString('base64');
448
- if (data.length > budget)
449
- return null;
450
- return { data, mimeType };
451
- }
452
- catch {
453
- return null;
454
- }
455
- }
456
- /**
457
- * Fetch a still image URL for an image content block, preferring the optimized
458
- * `.preview.webp` sibling and falling back to the raw url if that is missing. This
459
- * auto-upgrades as the optimization pipeline backfills derivatives, with no code
460
- * change here. Best-effort: any failure returns null and the item stays text-only.
461
- */
462
- async function fetchMediaImageBase64(url, budget) {
463
- const optimized = optimizedImageSibling(url);
464
- if (optimized !== url) {
465
- const hit = await fetchImageBytes(optimized, budget);
466
- if (hit)
467
- return hit;
468
- }
469
- return fetchImageBytes(url, budget);
470
- }
471
- /**
472
- * ⭐⭐⭐ **THE ONE PLACE THAT DECIDES HOW MUCH OF A RESULT MAY BE BYTES.**
473
- *
474
- * Two call sites needed this and each had its own answer, which is how the same defect appeared twice at
475
- * different levels: `attachmentsFor` capped PER ITEM while the host's ceiling is per RESULT, and `get_media`
476
- * fetched up to TEN images in parallel with no cap at all (10 x 840 KB is 8 MB against a 1 MB limit).
477
- *
478
- * ⛔ SEQUENTIAL, DELIBERATELY. The budget is shared state, so a parallel fetch cannot know what the others
479
- * already spent and every one of them would pass a check the set as a whole fails. Most calls carry one to
480
- * three items, so the latency is small and the alternative is a ceiling that holds only by luck.
481
- *
482
- * Returns one entry per input, `null` where the asset did not fit or could not be read, so callers keep
483
- * positional alignment with what they asked for.
484
- */
485
- async function inlineImagesWithinBudget(urls) {
486
- let budget = MAX_INLINE_BASE64_CHARS;
487
- const out = [];
488
- for (const url of urls) {
489
- if (!url) {
490
- out.push(null);
491
- continue;
492
- }
493
- const hit = await fetchMediaImageBase64(url, budget);
494
- if (hit)
495
- budget -= hit.data.length;
496
- out.push(hit);
497
- }
498
- return out;
499
- }
500
- /**
501
- * ⛔⛔⛔ **THE WIDGET IS DECLARED BY THE TOOL, NOT BY THE RESULT. I HAD IT ON THE RESULT.**
502
- *
503
- * A host reads `tool._meta` at `tools/list` time to learn that a tool renders a widget. Putting the binding
504
- * only on the CallToolResult means the host never knows to mount anything, so the result arrives as plain
505
- * blocks and the widget silently never appears. Measured in Claude Desktop 2026-09-19: four URLs as text,
506
- * no viewer, no error anywhere.
507
- *
508
- * ⚠️ BOTH SPELLINGS, DELIBERATELY. `_meta.ui.resourceUri` is the current format and `ui/resourceUri` the
509
- * legacy one, and the spec's own guidance is that hosts must accept either. Emitting both costs nothing and
510
- * removes a whole class of "works in one client" from the table.
511
- *
512
- * ⭐ Spread into the tools whose results are MEDIA. Not onto all 87: a tool that returns a card or a folder
513
- * has nothing for this widget to show, and claiming otherwise would put an empty frame under every call.
514
- */
515
- const RENDERS_GENERATION = {
516
- _meta: {
517
- ui: { resourceUri: GENERATION_WIDGET_URI },
518
- [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI,
519
- },
520
- };
521
- /**
522
- * ⛔⛔⛔ **WITHOUT THIS THE FRAME LOADS NOTHING, AND THE SPEC SAYS SO PLAINLY:**
523
- * "Empty or omitted → no network resources (secure default)."
524
- *
525
- * Measured in Claude Desktop 2026-09-19: the widget mounted, the chrome rendered, the variation strip and
526
- * the buttons worked, and every image was a broken icon showing its own filename. The frame was doing
527
- * exactly what it was told, which was to permit nothing.
528
- *
529
- * `resourceDomains` maps to `img-src`, `media-src`, `script-src`, `style-src` and `font-src`, so it is the
530
- * one field that decides whether an `<img>` or a `<video>` in this widget can reach our storage.
531
- *
532
- * ⚠️ NO `connectDomains`. The widget never calls `fetch`: it points element sources at urls and lets the
533
- * browser load them. Granting network access it does not use would widen the sandbox for nothing.
534
- *
535
- * ⚠️ These are the hosts that actually serve generated media, which is a SMALLER set than the server's SSRF
536
- * allowlist. That list governs what the SERVER may fetch and inline; this governs what the FRAME may load.
537
- * Two different questions, deliberately not one constant.
538
- */
539
- const WIDGET_CSP = {
540
- _meta: {
541
- ui: {
542
- csp: {
543
- resourceDomains: [
544
- // Capability urls for generated assets: the token rides in the query string, so an element src
545
- // loads one directly with no header to set.
546
- 'https://media.contenthero.ai',
547
- // Public-class objects (posters, gallery, stock).
548
- 'https://cdn.contenthero.ai',
549
- ],
550
- /**
551
- * ⛔⛔ **A SEPARATE FIELD, AND OMITTING IT BLOCKS `fetch` ENTIRELY.**
552
- *
553
- * `resourceDomains` maps to `img-src`, `media-src` and friends, which is why the pictures render.
554
- * `connectDomains` maps to `connect-src`, and the spec's default for an omitted list is "no network
555
- * connections (secure default)". So the frame could DISPLAY our media and could not READ it, which
556
- * is exactly the shape needed to save a file: downloading means holding the bytes.
557
- *
558
- * ⚠️ Same origins, deliberately repeated rather than shared with a constant. They answer different
559
- * questions (may the frame paint this, may the frame read this) and a future answer to one is not
560
- * automatically the answer to the other.
561
- */
562
- connectDomains: ['https://media.contenthero.ai', 'https://cdn.contenthero.ai'],
563
- },
564
- },
565
- },
566
- };
567
- /** Drop undefined values so the request payload stays minimal. */
568
- function compact(obj) {
569
- return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
570
- }
571
- function buildReferences(parts) {
572
- const refs = compact(parts);
573
- return Object.keys(refs).length > 0 ? refs : undefined;
574
- }
575
- /**
576
- * Register the full ContentHero tool surface on `server`. Synchronous: the model
577
- * enums are supplied pre-resolved, and the backend client is resolved per call.
578
- */
579
- export function registerTools(server, opts) {
580
- const { getClient, models } = opts;
581
- /**
582
- * 🚨🚨 **EVERY TOOL'S INPUT IS STRICT. AN UNDECLARED PARAMETER IS A 400, NEVER A SILENT DROP.**
583
- *
584
- * Zod object schemas STRIP unknown keys by default, and the SDK builds one from each `inputSchema` shape.
585
- * So before this wrapper, all 85 tools accepted any parameter they did not declare, discarded it, and ran
586
- * on whatever survived. The failure is invisible by construction: the caller gets a success.
587
- *
588
- * ⚠️ **THE MEASURED CASE, 2026-09-08.** `update_card` does not declare `ops`. A call passing `ops` had it
589
- * stripped, leaving only `cardId`, and returned **"Updated: 01.3 Design Your Character"** having written
590
- * nothing. A second call passing the nonsense op `__probe__` did the same. An agent running a batch of
591
- * partial edits would collect a full set of success messages and zero writes.
592
- *
593
- * ⭐⭐ **WRAPPED HERE RATHER THAN AT 85 CALL SITES, AND THAT IS THE POINT.** A rule every registration must
594
- * remember is a rule that holds until someone adds the 86th tool. Same move as `withSpineRegistration`
595
- * wrapping the Supabase client instead of asking 73 upload sites to declare an owner.
596
- *
597
- * ⚠️ **NESTED `.passthrough()` SURVIVES, DELIBERATELY.** `update_timeline` and `update_canvas` declare
598
- * `ops: z.array(z.object({ op: z.string() }).passthrough())` because a timeline op carries a different
599
- * shape per op type. Strictness here applies to the TOP-LEVEL argument object only, so those keep taking
600
- * varied op payloads while still rejecting an undeclared top-level parameter.
601
- */
602
- /**
603
- * ⭐⭐⭐ **THE GENERATION WIDGET: THE ONLY THING THAT PUTS A PLAYING VIDEO IN A CONVERSATION.**
604
- *
605
- * MCP's content blocks are `text | image | audio | resource | resource_link`. **There is no video block**,
606
- * so no arrangement of them can render video, and a `resource_link` renders as a hyperlink in ChatGPT and
607
- * as NOTHING in Claude. Measured in production 2026-09-19.
608
- *
609
- * MCP Apps is the mechanism that works. The server publishes an HTML resource under `ui://`, the host
610
- * mounts it, and the widget reads the tool's `structuredContent`. It is an open standard with an official
611
- * SDK (`@modelcontextprotocol/ext-apps`), verified against a working implementation before adoption.
612
- *
613
- * ⚠️ **THE HTML IS READ FROM THE PACKAGE, NOT FETCHED.** It ships inside the published tarball, so a local
614
- * install renders the same thing the hosted server does. Fetching it from our app would make the widget
615
- * depend on a deploy and break every offline or self-hosted install.
616
- *
617
- * ⛔ **REGISTERING THIS COSTS NOTHING FOR HOSTS THAT DO NOT SUPPORT IT.** A client that ignores `ui://`
618
- * resources simply never reads it, and the image and audio BLOCKS remain the fallback. That is why the
619
- * blocks stay rather than being replaced: two mechanisms, and the widget is the better one where it exists.
620
- */
621
- /**
622
- * ⚠️⚠️ **REGISTERED UNCONDITIONALLY, AND READ LAZILY.**
623
- *
624
- * This used to read the bundle at startup and register the resource only if it was found. Two problems.
625
- * A missing bundle produced a server that silently had no widget, which is the failure mode hardest to
626
- * notice: every tool still worked and nothing rendered. And the resource then did not exist when running
627
- * from `src/`, so the guard that checks tools point at a real resource could not run at all.
628
- *
629
- * ⭐ The resource is part of this server's contract. Advertising it always and throwing a NAMED error at
630
- * read time turns "no widget, no reason" into one line that says exactly what is missing.
631
- */
632
- server.registerResource('generation', GENERATION_WIDGET_URI, {
633
- description: 'Shows what a generation produced: every variation, playable and downloadable.',
634
- mimeType: RESOURCE_MIME_TYPE,
635
- ...WIDGET_CSP,
636
- }, async () => {
637
- /**
638
- * ⭐⭐⭐ **AN IMPORT, NOT A FILE READ.**
639
- *
640
- * This used to be `readFileSync(join(MODULE_DIR, 'widget', 'generation.html'))`, which works from
641
- * disk and fails wherever a bundler is involved. Measured on Vercel: Next.js inlines this package
642
- * into the route's bundle, so `import.meta.url` pointed at the bundled file and the HTML was never
643
- * traced at all. The hosted MCP advertised a `ui://` resource it could not read, so nothing rendered
644
- * and nothing said why.
645
- *
646
- * An import is the one thing every bundler, tracer and runtime already understands, so no consumer
647
- * needs tracing configuration or an externals list to serve the widget it was handed.
648
- */
649
- const text = GENERATION_WIDGET_HTML;
650
- // ⚠️ REPEATED ON THE READ RESULT, not just the listing. The spec reads csp from the `resources/read`
651
- // content item and treats the `resources/list` entry as a FALLBACK, so a host that only consults the
652
- // read path would otherwise see no policy and apply the secure default of blocking everything.
653
- return {
654
- contents: [{ uri: GENERATION_WIDGET_URI, mimeType: RESOURCE_MIME_TYPE, text, ...WIDGET_CSP }],
655
- };
656
- });
657
- const rawRegisterTool = server.registerTool.bind(server);
658
- server.registerTool = ((name, config, cb) => {
659
- const shape = config.inputSchema;
660
- // A tool with no inputs, or one that already passed a built schema, is left exactly as it was.
661
- const strict = shape && typeof shape === 'object' && !(shape instanceof z.ZodType)
662
- ? z.object(shape).strict()
663
- : shape;
664
- return rawRegisterTool(name, { ...config, inputSchema: strict }, cb);
665
- });
666
- /**
667
- * ⚠️ THESE SHAPES ARE DECLARED, NOT LEFT AS `z.unknown()`. An array of unknown serialises to
668
- * `{"type":"array","items":{}}`, which tells a client NOTHING about what may go inside it. The server
669
- * accepted every shape when called directly, and Claude Desktop rejected all of them before they left,
670
- * because a validator cannot check a value against an empty schema and a model cannot pattern an argument
671
- * on one either. Measured 2026-08-23: 12 fields across 4 tools were advertised that way.
672
- *
673
- * Every entry below stays permissive at the EDGES (optional fields, free-form payload objects) because the
674
- * server does the real validation. The point is to describe the shape, not to duplicate the rules.
675
- */
676
- const logoEntrySchema = z.object({
677
- url: z.string().optional().describe('A url the kit already has.'),
678
- outputId: z.string().optional().describe('A generation to copy in: "<id>", or "<id>-2" for variation 2.'),
679
- name: z.string().optional(),
680
- is_primary: z.boolean().optional().describe("Make this the kit's cover. Exactly one logo ends up primary."),
681
- layout: z.enum(['horizontal', 'stacked', 'icon', 'wordmark']).optional(),
682
- colorMode: z.enum(['full_color', 'light', 'dark', 'grayscale']).optional(),
683
- });
684
- const assetEntrySchema = z.object({
685
- url: z.string().optional().describe('A url the kit already has.'),
686
- outputId: z.string().optional().describe('A generation to copy in.'),
687
- name: z.string().optional(),
688
- });
689
- const sectionEntrySchema = z.object({
690
- tab: z.string().describe('The tab this section belongs to, e.g. "voice", "overview". Part of the key.'),
691
- sectionName: z.string().describe('The section title. Part of the key.'),
692
- sortOrder: z.number().int().optional(),
693
- fields: z.array(z.record(z.string(), z.unknown())).optional().describe('Field objects: { key, label, type, value }.'),
694
- });
695
- /** An existing tracked-account id, OR a profile to add by handle/url. */
696
- const accountEntrySchema = z.union([
697
- z.string().describe('A tracked-account id, or a full profile url.'),
698
- z.object({
699
- platform: z
700
- .enum(['youtube', 'instagram', 'facebook', 'tiktok', 'x', 'threads', 'linkedin'])
701
- .optional()
702
- .describe('Only needed for a bare handle; a full profile url carries its own platform.'),
703
- handleOrUrl: z.string().describe('A profile url, or a handle when platform is given.'),
704
- }),
705
- ]);
706
- const cardPostSchema = z.object({
707
- platform: z.enum(POST_PLATFORMS).describe('The platform. This is the KEY: one post per platform.'),
708
- format: z.string().optional(),
709
- connectedAccountId: z.string().nullable().optional(),
710
- scheduledAt: z.string().nullable().optional().describe("Per-post override of the card's schedule."),
711
- platformSpecificData: z.record(z.string(), z.unknown()).optional().describe('The publish payload for this platform.'),
712
- status: z.string().optional(),
713
- });
714
- const postAssetSchema = z.object({
715
- id: z.string().optional().describe('Keep an existing asset, at this position in the order.'),
716
- assetUrl: z.string().optional().describe('A public url for a NEW asset.'),
717
- outputId: z.string().optional().describe('A generation for a NEW asset.'),
718
- assetType: z.string().optional().describe('Required with assetUrl; inferred from outputId.'),
719
- displayName: z.string().optional(),
720
- metadata: z.record(z.string(), z.unknown()).nullable().optional(),
721
- });
722
- // -- generate_image -------------------------------------------------------
723
- server.registerTool('generate_image', {
724
- ...RENDERS_GENERATION,
725
- title: 'Generate Image',
726
- annotations: WRITE,
727
- 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.',
728
- inputSchema: {
729
- modelId: z.enum(models.image).describe(IMAGE_MODEL_GUIDANCE),
730
- prompt: z
731
- .string()
732
- .optional()
733
- .describe('Describe the image to generate. Required for most models; optional for a few that can run from references alone.'),
734
- aspectRatio: z.string().optional().describe('e.g. 16:9, 1:1, 9:16. Validated per model.'),
735
- resolution: z.string().optional().describe('e.g. 1K, 2K, 4K. Model-dependent (e.g. gpt-image-2, nano-banana-2/pro, flux-2-pro, seedream).'),
736
- mode: z
737
- .string()
738
- .optional()
739
- .describe('Variant mode for models that expose one: flux-2-pro takes "pro" or "flex"; flux-1-kontext takes "pro" or "max". Affects both the variant and the price. Ignored by models without a mode.'),
740
- numImages: z.number().int().min(1).max(4).optional().describe('Number of variations (1-4).'),
741
- seed: z.number().int().optional().describe('Seed for reproducibility.'),
742
- referenceImages: z
743
- .array(z.string())
744
- .optional()
745
- .describe('References for image-to-image / editing. Each may be a URL or a previous output id (e.g. "<id>" or "<id>-2") to chain from an earlier generation.'),
746
- avatarId: z
747
- .string()
748
- .optional()
749
- .describe('Optional avatar id from list_avatars. File the result onto that avatar as a new LOOK (one appearance of a reusable character: same person, different outfit, setting or framing) instead of saving a standalone library output. Combine with a referenceImage of the avatar to keep the subject on-model.'),
750
- ...PLACEMENT_INPUT_FIELDS,
751
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
752
- },
753
- }, async (args, extra) => {
754
- try {
755
- const client = await getClient(extra);
756
- const request = compact({
757
- contentType: 'image',
758
- modelId: args.modelId,
759
- prompt: args.prompt,
760
- aspectRatio: args.aspectRatio,
761
- resolution: args.resolution,
762
- numImages: args.numImages,
763
- seed: args.seed,
764
- references: buildReferences({ images: args.referenceImages }),
765
- parameters: args.mode ? { mode: args.mode } : undefined,
766
- avatarId: args.avatarId,
767
- projectId: args.projectId,
768
- placement: args.placement,
769
- playheadFrame: args.playheadFrame,
770
- });
771
- if (args.getCost)
772
- return costResult(await client.estimateCost(request));
773
- const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
774
- return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
775
- }
776
- catch (err) {
777
- // A SUBMITTED generation is running and charged. Whether the wait timed out or a
778
- // poll hit a transient error, returning the outputId lets the caller resume;
779
- // dropping it invites a retry that generates and charges a second time.
780
- const pending = pendingOutputId(err);
781
- if (pending)
782
- return pendingResult(pending, pollAfterSecondsFor('image'), pendingShapeFrom(args, 'image'));
783
- return errorResult(err);
784
- }
785
- });
786
- // -- generate_board -------------------------------------------------------
787
- server.registerTool('generate_board', {
788
- ...RENDERS_GENERATION,
789
- title: 'Generate Reference Board',
790
- annotations: WRITE,
791
- 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.',
792
- inputSchema: {
793
- boardType: z.enum(BOARD_TYPES).describe(BOARD_TYPE_GUIDANCE),
794
- prompt: z
795
- .string()
796
- .optional()
797
- .describe('Describe the subject or how you will use the board. Required when no referenceImages are given (text-only boards); otherwise optional context (the source image leads).'),
798
- referenceImages: z
799
- .array(z.string())
800
- .optional()
801
- .describe('Source images the board is built from: each a URL or a previous output id (e.g. "<id>" or "<id>-2") to chain from an earlier generation.'),
802
- numImages: z
803
- .number()
804
- .int()
805
- .min(1)
806
- .max(4)
807
- .optional()
808
- .describe('Number of board variations (1-4). Defaults to 1.'),
809
- boardName: z.string().optional().describe('Optional name for the board.'),
810
- avatarId: z
811
- .string()
812
- .optional()
813
- .describe('Optional avatar id from list_avatars. Associate the board with that avatar, so a character sheet built for an avatar stays filed against it.'),
814
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
815
- },
816
- }, async (args, extra) => {
817
- try {
818
- const client = await getClient(extra);
819
- const request = compact({
820
- boardType: args.boardType,
821
- prompt: args.prompt,
822
- referenceImages: args.referenceImages,
823
- numImages: args.numImages,
824
- boardName: args.boardName,
825
- avatarId: args.avatarId,
826
- });
827
- if (args.getCost)
828
- return costResult(await client.estimateBoardCost(request));
829
- const gen = await client.generateBoardAndWait(request, { timeoutMs: SMART_WAIT_MS });
830
- return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
831
- }
832
- catch (err) {
833
- // A SUBMITTED generation is running and charged. Whether the wait timed out or a
834
- // poll hit a transient error, returning the outputId lets the caller resume;
835
- // dropping it invites a retry that generates and charges a second time.
836
- const pending = pendingOutputId(err);
837
- if (pending)
838
- return pendingResult(pending, pollAfterSecondsFor('image'), pendingShapeFrom(args, 'image'));
839
- return errorResult(err);
840
- }
841
- });
842
- // -- generate_video -------------------------------------------------------
843
- server.registerTool('generate_video', {
844
- ...RENDERS_GENERATION,
845
- title: 'Generate Video',
846
- annotations: WRITE,
847
- 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.',
848
- inputSchema: {
849
- modelId: z.enum(models.video).describe(VIDEO_MODEL_GUIDANCE),
850
- prompt: z
851
- .string()
852
- .optional()
853
- .describe('Describe the video to generate. Required for most models; optional for some (e.g. motion-control), where it is an auxiliary motion hint.'),
854
- aspectRatio: z.string().optional().describe('e.g. 16:9, 9:16. Validated per model.'),
855
- resolution: z.string().optional().describe('e.g. 720p, 1080p, 4K. Model-dependent.'),
856
- duration: z
857
- .number()
858
- .optional()
859
- .describe('Clip length in seconds. Model-dependent; some models lock it.'),
860
- audioEnabled: z
861
- .boolean()
862
- .optional()
863
- .describe('Generate audio (only for models that support it).'),
864
- numGenerations: z.number().int().min(1).max(4).optional().describe('Number of variations (1-4).'),
865
- negativePrompt: z.string().optional().describe('What to avoid (models that support it).'),
866
- seed: z.number().int().optional().describe('Seed for reproducibility.'),
867
- startFrame: z.string().optional().describe('First frame: an image URL or a previous output id (e.g. "<id>-2") to chain (e.g. animate an image you just generated).'),
868
- endFrame: z.string().optional().describe('Last frame: an image URL or a previous output id.'),
869
- referenceImages: z.array(z.string()).optional().describe('Reference images: each a URL or a previous output id to chain.'),
870
- referenceVideos: z.array(z.string()).optional().describe('Reference videos: each a URL or a previous output id to chain.'),
871
- referenceAudio: z
872
- .array(z.string())
873
- .optional()
874
- .describe('Reference audio (e.g. Seedance references mode, audio-driven video): each a URL or a previous output id. Only used by models that accept audio references.'),
875
- elements: z
876
- .array(z.object({
877
- elementId: z.string().optional().describe('Reference a saved element by id (from list_elements / create_element). Resolves to its name + images.'),
878
- name: z.string().optional().describe('Inline element: reference it in the prompt as @name.'),
879
- description: z.string().optional().describe('Inline element: what it represents.'),
880
- images: z.array(z.string()).optional().describe('Inline element: image URLs or previous output ids.'),
881
- }))
882
- .optional()
883
- .describe('Named reference elements (Kling 3.0): each is a saved element ({ elementId }) or an inline group ({ name, description, images }), addressable in the prompt as @name. Requires a startFrame. See get_model promptReferences (named_tag scheme).'),
884
- multiShot: z
885
- .boolean()
886
- .optional()
887
- .describe('WAN 2.6: enable multi-shot mode (a single longer sequence with multiple shots) instead of single-shot. For Kling 3.0, pass per-shot prompts via `shots` instead, which turns on multi-shot automatically.'),
888
- shots: z
889
- .array(z.object({ prompt: z.string(), duration: z.number() }))
890
- .optional()
891
- .describe('Kling 3.0 multi-shot mode: an ordered list of shots, each with its own prompt and duration in seconds (1-12 each, total <=15). When provided, the video runs in multi-shot mode; only startFrame attaches as an image (it becomes the first frame of shot 1), all other shots are text-only. Audio is always on in multi-shot.'),
892
- ...PLACEMENT_INPUT_FIELDS,
893
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
894
- },
895
- }, async (args, extra) => {
896
- try {
897
- const client = await getClient(extra);
898
- const klingMultiShot = Array.isArray(args.shots) && args.shots.length > 0;
899
- const wantMultiShot = klingMultiShot || args.multiShot === true;
900
- const parameters = {};
901
- if (wantMultiShot)
902
- parameters.multiShot = true;
903
- if (klingMultiShot)
904
- parameters.shots = args.shots;
905
- const request = compact({
906
- contentType: 'video',
907
- modelId: args.modelId,
908
- prompt: klingMultiShot ? args.prompt ?? args.shots.map((s) => s.prompt).join(' ') : args.prompt,
909
- aspectRatio: args.aspectRatio,
910
- resolution: args.resolution,
911
- duration: klingMultiShot ? args.shots.reduce((sum, s) => sum + s.duration, 0) : args.duration,
912
- audioEnabled: args.audioEnabled,
913
- numGenerations: args.numGenerations,
914
- negativePrompt: args.negativePrompt,
915
- seed: args.seed,
916
- ...(Object.keys(parameters).length > 0 ? { parameters } : {}),
917
- references: buildReferences({
918
- startFrame: args.startFrame,
919
- endFrame: args.endFrame,
920
- images: args.referenceImages,
921
- videos: args.referenceVideos,
922
- audio: args.referenceAudio,
923
- elements: args.elements,
924
- }),
925
- projectId: args.projectId,
926
- placement: args.placement,
927
- playheadFrame: args.playheadFrame,
928
- });
929
- if (args.getCost)
930
- return costResult(await client.estimateCost(request));
931
- const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
932
- return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
933
- }
934
- catch (err) {
935
- // A SUBMITTED generation is running and charged. Whether the wait timed out or a
936
- // poll hit a transient error, returning the outputId lets the caller resume;
937
- // dropping it invites a retry that generates and charges a second time.
938
- const pending = pendingOutputId(err);
939
- if (pending)
940
- return pendingResult(pending, pollAfterSecondsFor('video'), pendingShapeFrom(args, 'video'));
941
- return errorResult(err);
942
- }
943
- });
944
- // -- generate_audio (synchronous) -----------------------------------------
945
- server.registerTool('generate_audio', {
946
- title: 'Generate Audio',
947
- ...RENDERS_GENERATION,
948
- annotations: WRITE,
949
- 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.',
950
- inputSchema: {
951
- modelId: z.enum(models.audio).describe(AUDIO_MODEL_GUIDANCE),
952
- prompt: z.string().optional().describe('For music / sfx: what to generate.'),
953
- text: z.string().optional().describe('For TTS (elevenlabs-tts): the words to speak.'),
954
- voiceId: z.string().optional().describe('For TTS: the ElevenLabs voice id.'),
955
- voiceName: z.string().optional().describe('For TTS: human-readable voice name (display only).'),
956
- durationSeconds: z.number().optional().describe('For music / sfx: length in seconds.'),
957
- promptInfluence: z
958
- .number()
959
- .min(0)
960
- .max(1)
961
- .optional()
962
- .describe('For sfx: how literally to follow the prompt (0 to 1).'),
963
- ...PLACEMENT_INPUT_FIELDS,
964
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
965
- },
966
- }, async (args, extra) => {
967
- try {
968
- const client = await getClient(extra);
969
- const request = compact({
970
- contentType: 'audio',
971
- modelId: args.modelId,
972
- prompt: args.prompt,
973
- text: args.text,
974
- voiceId: args.voiceId,
975
- voiceName: args.voiceName,
976
- durationSeconds: args.durationSeconds,
977
- promptInfluence: args.promptInfluence,
978
- projectId: args.projectId,
979
- placement: args.placement,
980
- playheadFrame: args.playheadFrame,
981
- });
982
- if (args.getCost)
983
- return costResult(await client.estimateCost(request));
984
- const result = await client.generate(request);
985
- return audioResult(result, client.baseUrl);
986
- }
987
- catch (err) {
988
- return errorResult(err);
989
- }
990
- });
991
- // -- edit_audio (existing audio -> audio) ---------------------------------
992
- server.registerTool('edit_audio', {
993
- title: 'Edit Audio',
994
- ...RENDERS_GENERATION,
995
- annotations: WRITE,
996
- description: 'Transform existing audio with an audio-processing model, in one of TWO shapes. FILE mode: pass sourceUrl to process a standalone file into a new library asset. Voice isolation removes background noise and music and returns the processed URL directly; audio enhancement levels loudness and cleans up background noise, is asynchronous, and returns an outputId to poll with get_generation_status. Optionally pass projectId to place the result onto that editor project\'s timeline in the same call, controlled by an optional placement. IN-PLACE mode: pass projectId with clipIds (or enhanceClips for the whole timeline) to enhance the audio OF EXISTING CLIPS instead of producing a new asset, which is how you clean up a recording already on a timeline. In-place returns a LIST on outputs, one job per SOURCE, because the vendor estimates a noise profile per production: one recording\'s clips are concatenated and enhanced together so the level and noise floor stay consistent across cuts, while separate recordings stay separate jobs. Poll every outputId. The enhanced audio is applied to the clips automatically when each job lands: an audio clip has its source swapped, and a video clip is muted with the enhanced audio placed on its own clip. Silenced clips are skipped. In-place mode is enhancement only and needs no sourceUrl. SPENDS CREDITS: pass getCost to preview the price first, which runs nothing and charges nothing.',
997
- inputSchema: {
998
- modelId: z.enum(models.editAudio).describe(EDIT_AUDIO_MODEL_GUIDANCE),
999
- sourceUrl: z
1000
- .string()
1001
- .optional()
1002
- .describe('FILE mode: the audio file to process, as a URL or a previous output id. Omit in in-place mode, where the sources come from the clips.'),
1003
- durationSeconds: z
1004
- .number()
1005
- .optional()
1006
- .describe('Source audio length in seconds. Required for getCost, and for enhancement pricing when the source is not a stored ContentHero asset.'),
1007
- projectId: z.string().optional().describe('The editor project: where to PLACE the result in file mode, or which timeline to enhance in in-place mode. Omit for a standalone library output.'),
1008
- clipIds: z
1009
- .array(z.string())
1010
- .optional()
1011
- .describe('IN-PLACE mode: enhance the audio of these clips on projectId. Omit with enhanceClips:true to enhance every audible clip on the timeline.'),
1012
- enhanceClips: z
1013
- .boolean()
1014
- .optional()
1015
- .describe('IN-PLACE mode for the whole timeline, without naming clips. Implied when clipIds is given.'),
1016
- placement: PLACEMENT_SCHEMA.optional().describe('Where the clip lands: append to the end, at a time, at the playhead, replacing an existing clip, or filling a time range. Omitted places it at the playhead when known, else appends.'),
1017
- playheadFrame: z.number().optional().describe('The current playhead frame, for playhead-relative placement.'),
1018
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of running (nothing runs, nothing is charged).'),
1019
- },
1020
- }, async (args, extra) => {
1021
- try {
1022
- const client = await getClient(extra);
1023
- const request = compact({
1024
- modelId: args.modelId,
1025
- sourceUrl: args.sourceUrl,
1026
- durationSeconds: args.durationSeconds,
1027
- projectId: args.projectId,
1028
- placement: args.placement,
1029
- playheadFrame: args.playheadFrame,
1030
- clipIds: args.clipIds,
1031
- enhanceClips: args.enhanceClips,
1032
- });
1033
- if (args.getCost)
1034
- return costResult(await client.estimateEditAudioCost(request));
1035
- const result = await client.editAudio(request);
1036
- // IN-PLACE mode returns one job per SOURCE, so the agent is handed every outputId rather than just the
1037
- // first: polling only `outputId` would report the whole edit as done when one recording had finished.
1038
- if (result.outputs)
1039
- return enhanceClipsResult(result);
1040
- // Enhancement is async (status 'processing'); isolation returns URLs inline.
1041
- if (result.status === 'processing')
1042
- return pendingResult(result.outputId);
1043
- return audioResult(result, client.baseUrl);
1044
- }
1045
- catch (err) {
1046
- return errorResult(err);
1047
- }
1048
- });
1049
- // -- upscale --------------------------------------------------------------
1050
- server.registerTool('upscale', {
1051
- ...RENDERS_GENERATION,
1052
- title: 'Upscale',
1053
- annotations: WRITE,
1054
- 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.',
1055
- inputSchema: {
1056
- modelId: z.enum(models.upscale).describe(UPSCALE_MODEL_GUIDANCE),
1057
- sourceUrl: z.string().describe('The source image (image upscalers) or video (video upscalers): a URL or a previous output id (e.g. "<id>-1") to upscale an earlier generation.'),
1058
- factor: z.string().describe('Upscale factor, e.g. 2x, 4x. Model-dependent; validated per model.'),
1059
- durationSeconds: z
1060
- .number()
1061
- .optional()
1062
- .describe('Required for video upscalers: the source video length in seconds (used for pricing).'),
1063
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of upscaling (nothing runs, nothing is charged).'),
1064
- },
1065
- }, async (args, extra) => {
1066
- try {
1067
- const client = await getClient(extra);
1068
- const isVideo = models.upscaleContentType[args.modelId] === 'video';
1069
- const request = compact({
1070
- contentType: isVideo ? 'video' : 'image',
1071
- modelId: args.modelId,
1072
- upscaleFactor: args.factor,
1073
- duration: isVideo ? args.durationSeconds : undefined,
1074
- references: isVideo ? { videos: [args.sourceUrl] } : { images: [args.sourceUrl] },
1075
- });
1076
- if (args.getCost)
1077
- return costResult(await client.estimateCost(request));
1078
- const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
1079
- return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
1080
- }
1081
- catch (err) {
1082
- // A SUBMITTED generation is running and charged. Whether the wait timed out or a
1083
- // poll hit a transient error, returning the outputId lets the caller resume;
1084
- // dropping it invites a retry that generates and charges a second time.
1085
- const pending = pendingOutputId(err);
1086
- if (pending)
1087
- return pendingResult(pending, pollAfterSecondsFor('image'), pendingShapeFrom(args, 'image'));
1088
- return errorResult(err);
1089
- }
1090
- });
1091
- // -- generate_lip_sync ----------------------------------------------------
1092
- server.registerTool('generate_lip_sync', {
1093
- ...RENDERS_GENERATION,
1094
- title: 'Generate Lip Sync',
1095
- annotations: WRITE,
1096
- 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.',
1097
- inputSchema: {
1098
- modelId: z.enum(models.lipSync).describe(LIP_SYNC_MODEL_GUIDANCE),
1099
- imageUrl: z.string().describe('The portrait to animate (the speaking subject): an image URL or a previous output id (e.g. "<id>-1") to chain.'),
1100
- audioUrl: z
1101
- .string()
1102
- .optional()
1103
- .describe('An existing speech clip: an audio URL or a previous output id. Use this OR script + voiceId.'),
1104
- script: z
1105
- .string()
1106
- .optional()
1107
- .describe('Text for the subject to speak. Requires voiceId; synthesized to speech. Use this OR audioUrl.'),
1108
- voiceId: z.string().optional().describe('ElevenLabs voice id to speak the script (required with script).'),
1109
- voiceName: z.string().optional().describe('Human-readable voice name (display only).'),
1110
- motionPrompt: z
1111
- .string()
1112
- .optional()
1113
- .describe('Optional motion / expression hint for the animation.'),
1114
- resolution: z.string().optional().describe('e.g. 480p, 720p, 1080p. Model-dependent.'),
1115
- audioDurationSeconds: z
1116
- .number()
1117
- .optional()
1118
- .describe('Length of audioUrl in seconds (audio mode only; improves cost accuracy).'),
1119
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
1120
- },
1121
- }, async (args, extra) => {
1122
- try {
1123
- const client = await getClient(extra);
1124
- const request = compact({
1125
- contentType: 'video',
1126
- modelId: args.modelId,
1127
- prompt: args.motionPrompt,
1128
- text: args.script,
1129
- voiceId: args.voiceId,
1130
- voiceName: args.voiceName,
1131
- resolution: args.resolution,
1132
- durationSeconds: args.audioDurationSeconds,
1133
- references: buildReferences({
1134
- images: [args.imageUrl],
1135
- audio: args.audioUrl ? [args.audioUrl] : undefined,
1136
- }),
1137
- });
1138
- if (args.getCost)
1139
- return costResult(await client.estimateCost(request));
1140
- const gen = await client.generateAndWait(request, { timeoutMs: SMART_WAIT_MS });
1141
- return completedResult(gen, await attachmentsFor(gen), [], client.baseUrl);
1142
- }
1143
- catch (err) {
1144
- // A SUBMITTED generation is running and charged. Whether the wait timed out or a
1145
- // poll hit a transient error, returning the outputId lets the caller resume;
1146
- // dropping it invites a retry that generates and charges a second time.
1147
- const pending = pendingOutputId(err);
1148
- if (pending)
1149
- return pendingResult(pending, pollAfterSecondsFor('video'), pendingShapeFrom(args, 'video'));
1150
- return errorResult(err);
1151
- }
1152
- });
1153
- // -- transcribe -----------------------------------------------------------
1154
- server.registerTool('transcribe', {
1155
- title: 'Transcribe Audio',
1156
- /*
1157
- ⭐ ANNOTATED, NOT JUST ARGUED FOR. The comment below has said "NOT read-only" since this tool
1158
- shipped, and nobody ever wrote the annotation, so the tool went out carrying NEITHER hint. Claude
1159
- files an unannotated tool under "Other", which is how 2 of 88 ended up unclassified: a comment
1160
- describing an enforcement nobody built reads exactly like one that was built.
1161
- */
1162
- annotations: WRITE,
1163
- // NOT read-only, despite only returning text. readOnlyHint is a host's signal that a
1164
- // tool is safe to call without asking the user, and this one is metered per minute of
1165
- // audio: annotated READ, an agent could transcribe a two-hour file repeatedly,
1166
- // unattended, spending real credits. Every other metered tool here is a write with a
1167
- // getCost preflight, and a test now holds that line.
1168
- //
1169
- // ⚠️ This tool has NO getCost, and that is a server limitation, not an oversight:
1170
- // POST /api/v1/studio/transcribe does not accept the flag, and pricing the call
1171
- // means knowing the audio's duration before transcribing it. Until the route can
1172
- // price it, the cost is only knowable after the fact, from creditsUsed on the
1173
- // result. It is the single documented entry in METERED_WITHOUT_PREFLIGHT.
1174
- description: 'Transcribe an audio URL to text (speech-to-text). Returns the transcript directly (synchronous, no polling). SPENDS CREDITS, metered per minute of audio, and the cost cannot be previewed: the result reports the credits it cost after the fact. Zero only when the account runs on its own ElevenLabs key.',
1175
- inputSchema: {
1176
- audioUrl: z.string().describe('Public URL of the audio file to transcribe.'),
1177
- languageCode: z
1178
- .string()
1179
- .optional()
1180
- .describe('ISO language hint, e.g. "en". Auto-detected when omitted.'),
1181
- diarize: z.boolean().optional().describe('Label each speaker (diarization).'),
1182
- },
1183
- }, async (args, extra) => {
1184
- try {
1185
- const client = await getClient(extra);
1186
- const t = await client.transcribe({
1187
- audioUrl: args.audioUrl,
1188
- languageCode: args.languageCode,
1189
- diarize: args.diarize,
1190
- });
1191
- return transcriptResult(t);
1192
- }
1193
- catch (err) {
1194
- return errorResult(err);
1195
- }
1196
- });
1197
- // -- list_avatars ---------------------------------------------------------
1198
- server.registerTool('list_avatars', {
1199
- title: 'List Avatars',
1200
- annotations: READ,
1201
- description: "List the account's avatars. Each avatar has an imageUrl (its base look) and a defaultVoiceId, which feed generate_lip_sync. Call get_avatar for full detail and the avatar's looks.",
1202
- }, async (extra) => {
1203
- try {
1204
- const client = await getClient(extra);
1205
- return avatarListResult(await client.listAvatars());
1206
- }
1207
- catch (err) {
1208
- return errorResult(err);
1209
- }
1210
- });
1211
- // -- get_avatar -----------------------------------------------------------
1212
- server.registerTool('get_avatar', {
1213
- title: 'Get Avatar',
1214
- annotations: READ,
1215
- description: 'Get one avatar by id: its base image (use as generate_lip_sync imageUrl), default voice, traits, and its looks (outfit variations).',
1216
- inputSchema: {
1217
- avatarId: z.string().describe('The avatar id from list_avatars.'),
1218
- },
1219
- }, async (args, extra) => {
1220
- try {
1221
- const client = await getClient(extra);
1222
- return avatarResult(await client.getAvatar(args.avatarId));
1223
- }
1224
- catch (err) {
1225
- return errorResult(err);
1226
- }
1227
- });
1228
- // -- create_avatar --------------------------------------------------------
1229
- server.registerTool('create_avatar', {
1230
- title: 'Create Avatar',
1231
- annotations: WRITE,
1232
- description: "Create a reusable character and start generating its first look. SPENDS CREDITS (pass getCost to preview the price without creating anything). Returns as soon as the record exists: the avatar is NOT usable yet, it sits at status 'processing' with no image until its first look finishes, so poll get_avatar until status is 'completed'. Supply referenceImageUrls to make the avatar a likeness of a real person from their photos; omit them to invent a character from the description and traits.",
1233
- inputSchema: {
1234
- name: z.string().describe('Avatar name, at least 3 characters.'),
1235
- age: z.string().describe("Apparent age, e.g. '20s', '35', 'middle-aged'. Required: the prompt writer describes the character from these traits."),
1236
- gender: z.string().describe('Gender presentation. Required, same reason as age.'),
1237
- ethnicity: z.string().optional().describe('Optional ethnicity, for a more specific likeness.'),
1238
- niche: z.array(z.string()).optional().describe('Content niches this character is for, e.g. ["fitness","nutrition"].'),
1239
- style: z.string().optional().describe('Visual style hint for the portrait, e.g. "editorial", "cinematic". Not stored on the avatar.'),
1240
- description: z
1241
- .string()
1242
- .optional()
1243
- .describe('Free-text description of the character. The strongest single input when no reference photos are given.'),
1244
- defaultVoiceId: z.string().optional().describe("A voiceId from list_voices, used as this avatar's default voice."),
1245
- referenceImageUrls: z
1246
- .array(z.string())
1247
- .optional()
1248
- .describe('Photos of a REAL PERSON to anchor identity to: each a URL or a previous output id. Only use photos of someone who has agreed to being cloned.'),
1249
- getCost: z.boolean().optional().describe('Return the credit cost estimate instead of creating (nothing runs, nothing is charged).'),
1250
- },
1251
- }, async (args, extra) => {
1252
- try {
1253
- const client = await getClient(extra);
1254
- if (args.getCost) {
1255
- const creditsEstimate = await client.estimateAvatarCost();
1256
- // `modelId` is the first thing `costResult` names in its sentence, and `contentType` only
1257
- // admits image/video/audio. An avatar is none of those: the price is the fixed
1258
- // avatar-creation fee, not the cost of the portrait model, so say that rather than pick a
1259
- // media kind that would misdescribe it.
1260
- return costResult({ getCost: true, creditsEstimate, modelId: 'avatar creation' });
1261
- }
1262
- const created = await client.createAvatar({
1263
- name: args.name,
1264
- age: args.age,
1265
- gender: args.gender,
1266
- ethnicity: args.ethnicity,
1267
- niche: args.niche,
1268
- style: args.style,
1269
- description: args.description,
1270
- defaultVoiceId: args.defaultVoiceId,
1271
- referenceImageUrls: args.referenceImageUrls,
1272
- });
1273
- return avatarPendingResult(created);
1274
- }
1275
- catch (err) {
1276
- return errorResult(err);
1277
- }
1278
- });
1279
- // -- update_avatar --------------------------------------------------------
1280
- server.registerTool('update_avatar', {
1281
- title: 'Update Avatar',
1282
- annotations: WRITE,
1283
- description: "Update an avatar and/or change its looks. Fields: name, defaultLookId (also becomes the avatar's profile photo), defaultVoiceId. Looks are changed through ops, the same shape update_timeline and update_canvas use: add_look files images the account ALREADY OWNS onto the avatar, remove_look trashes one (recoverable for 30 days). Ops run before the fields, so one call can add a look and make it the default. To GENERATE a new look instead of filing an existing image, call generate_image with avatarId.",
1284
- inputSchema: {
1285
- avatarId: z.string().describe('The avatar id from list_avatars.'),
1286
- name: z.string().optional().describe('New name, at least 3 characters.'),
1287
- defaultLookId: z
1288
- .string()
1289
- .optional()
1290
- .describe("A look id from get_avatar. Becomes the avatar's default look AND its profile photo."),
1291
- defaultVoiceId: z.string().nullable().optional().describe('A voiceId from list_voices, or null to clear it.'),
1292
- ops: z
1293
- .array(z.union([
1294
- z.object({
1295
- op: z.literal('add_look'),
1296
- imageUrls: z
1297
- .array(z.string())
1298
- .describe('Images the account already owns, as URLs: an upload, a creation, an editor export, or another avatar look. Anything not owned by this account is skipped rather than failing the call.'),
1299
- name: z
1300
- .string()
1301
- .optional()
1302
- .describe("What to call the look. Applied to every image in this op. Omit and it stays unnamed, displaying by its source label ('from_media'), which is rarely what you want for a look you will pick from a list later."),
1303
- }),
1304
- z.object({
1305
- op: z.literal('remove_look'),
1306
- lookId: z.string().describe('A look id from get_avatar.'),
1307
- }),
1308
- ]))
1309
- .optional()
1310
- .describe('Look changes, applied in order before the field updates. NOT a transaction: a failure part-way leaves earlier ops applied.'),
1311
- },
1312
- }, async (args, extra) => {
1313
- try {
1314
- const client = await getClient(extra);
1315
- const { avatarId, ...request } = args;
1316
- const updated = await client.updateAvatar(avatarId, request);
1317
- return avatarResult(updated.avatar);
1318
- }
1319
- catch (err) {
1320
- return errorResult(err);
1321
- }
1322
- });
1323
- // -- delete_avatar --------------------------------------------------------
1324
- server.registerTool('delete_avatar', {
1325
- title: 'Delete Avatar',
1326
- annotations: WRITE,
1327
- description: "Delete an avatar. Soft: the avatar stops appearing, but ITS LOOKS SURVIVE as library images and can be filed onto another avatar with update_avatar's add_look. Use this to retire a duplicate or an abandoned character, after moving any looks worth keeping.",
1328
- inputSchema: {
1329
- avatarId: z.string().describe('The avatar id from list_avatars.'),
1330
- },
1331
- }, async (args, extra) => {
1332
- try {
1333
- const client = await getClient(extra);
1334
- await client.deleteAvatar(args.avatarId);
1335
- return text(`Avatar ${args.avatarId} deleted. Its looks are retained and can be filed onto another avatar with update_avatar add_look.`);
1336
- }
1337
- catch (err) {
1338
- return errorResult(err);
1339
- }
1340
- });
1341
- // -- list_voices ----------------------------------------------------------
1342
- server.registerTool('list_voices', {
1343
- title: 'List Voices',
1344
- annotations: READ,
1345
- description: "List the account's saved voices (favorites first). Set favorited=true to show only favorites. Each has a voiceId for generate_lip_sync / generate_audio (TTS). Call get_voice for full detail.",
1346
- inputSchema: {
1347
- favorited: z.boolean().optional().describe('Only favorited voices.'),
1348
- },
1349
- }, async (args, extra) => {
1350
- try {
1351
- const client = await getClient(extra);
1352
- return voiceListResult(await client.listVoices({ favorited: args.favorited }));
1353
- }
1354
- catch (err) {
1355
- return errorResult(err);
1356
- }
1357
- });
1358
- // -- get_voice ------------------------------------------------------------
1359
- server.registerTool('get_voice', {
1360
- title: 'Get Voice',
1361
- annotations: READ,
1362
- description: 'Get one voice by its voiceId: provider, traits (accent/language/gender/age), description, and a preview URL.',
1363
- inputSchema: {
1364
- voiceId: z.string().describe('The voice id from list_voices.'),
1365
- },
1366
- }, async (args, extra) => {
1367
- try {
1368
- const client = await getClient(extra);
1369
- return voiceResult(await client.getVoice(args.voiceId));
1370
- }
1371
- catch (err) {
1372
- return errorResult(err);
1373
- }
1374
- });
1375
- // -- list_brand_kits ------------------------------------------------------
1376
- server.registerTool('list_brand_kits', {
1377
- title: 'List Brand Kits',
1378
- annotations: READ,
1379
- description: "List the account's brand kits (default first). Excludes archived kits unless archived=true; set favorited=true for only favorites. Call get_brand_kit for one kit's full brand context (voice, visual identity, audience, sections, accounts, knowledge) to write on-brand content.",
1380
- inputSchema: {
1381
- favorited: z.boolean().optional().describe('Only favorited brand kits.'),
1382
- archived: z.boolean().optional().describe('Only archived brand kits (default excludes archived).'),
1383
- },
1384
- }, async (args, extra) => {
1385
- try {
1386
- const client = await getClient(extra);
1387
- return brandKitListResult(await client.listBrandKits({ favorited: args.favorited, archived: args.archived }));
1388
- }
1389
- catch (err) {
1390
- return errorResult(err);
1391
- }
1392
- });
1393
- // -- get_brand_kit --------------------------------------------------------
1394
- server.registerTool('get_brand_kit', {
1395
- title: 'Get Brand Kit',
1396
- annotations: READ,
1397
- description: 'Get one brand kit in full: business overview, positioning, audience, voice profile, visual identity (logos/colors/typography), curated sections, linked brand + inspiration accounts, and a knowledge-base summary. Use it to ground on-brand generation.',
1398
- inputSchema: {
1399
- brandKitId: z.string().describe('The brand kit id from list_brand_kits.'),
1400
- },
1401
- }, async (args, extra) => {
1402
- try {
1403
- const client = await getClient(extra);
1404
- return brandKitResult(await client.getBrandKit(args.brandKitId));
1405
- }
1406
- catch (err) {
1407
- return errorResult(err);
1408
- }
1409
- });
1410
- // -- create_brand_kit -----------------------------------------------------
1411
- server.registerTool('create_brand_kit', {
1412
- title: 'Create Brand Kit',
1413
- annotations: WRITE,
1414
- description: "Create a brand kit. THREE SOURCES, chosen by what you pass: (1) EMPTY, just a name, then fill it in with update_brand_kit; or FROM A SOCIAL PROFILE, pass its url in brandAccounts (your own) or inspirationAccounts (a creator you watch) with no name at all, and the kit is named after the handle and starts ingesting that account's posts if it is YouTube or Instagram; (2) FROM A WEBSITE, pass websiteUrl + extract:true and ContentHero scrapes that site and fills in business name, positioning, voice, colors, typography, logos and assets by itself, which is by far the fastest way to get a real kit; (3) A COPY, pass duplicateFrom with an existing kit id, which copies its sections and brand media (assets re-link rather than duplicate, so a copy costs no storage). A brand with NO WEBSITE (so nothing to extract) is built by passing its fields directly, including logos, whose entries may name outputId to bring in a generation you just made rather than a url. With extract it RETURNS IMMEDIATELY, before the kit has any content: that empty kit is the handle, and the fields fill in over the next minute or two, so poll extractionStatus with get_brand_kit rather than assuming it failed. name is OPTIONAL when websiteUrl or a social profile url is given: it defaults to the site's hostname or the @handle, a placeholder extraction or you overwrite later. Brand kits are capped by plan, so this fails with a limit error near the cap, and a duplicate counts against it like any other kit. Requires the brandkit:write scope.",
1415
- inputSchema: {
1416
- name: z.string().optional().describe("The kit's name. Optional when websiteUrl is given."),
1417
- websiteUrl: z.string().optional().describe('The business website. Required to use extract.'),
1418
- extract: z
1419
- .boolean()
1420
- .optional()
1421
- .describe('Scrape websiteUrl and fill the kit in automatically. Returns at once; poll extractionStatus.'),
1422
- duplicateFrom: z.string().optional().describe('Copy an existing brand kit id instead of starting empty.'),
1423
- businessName: z.string().optional().describe('The business or creator name this kit represents.'),
1424
- primaryOffer: z.string().optional().describe('What this business sells, in one line. Grounds copy in what is actually being promoted.'),
1425
- nicheDefinition: z.string().optional().describe('The niche this brand operates in. Keeps generated angles on-topic.'),
1426
- positioning: z.record(z.string(), z.unknown()).optional().describe('Positioning object (free-form).'),
1427
- audience: z.record(z.string(), z.unknown()).optional().describe('Audience object (free-form).'),
1428
- voiceProfile: z.record(z.string(), z.unknown()).optional().describe('Voice profile object (tone, style, ...).'),
1429
- visualStyle: z.string().optional().describe("The look in words, e.g. 'warm film grain, muted earth tones'. Grounds image and video prompts."),
1430
- designPrinciples: z.array(z.string()).optional().describe('Design rules to hold to, one per entry. REPLACES the list; [] clears it.'),
1431
- contentStrategy: z.record(z.string(), z.unknown()).optional().describe('Content strategy object (free-form).'),
1432
- logos: z.array(logoEntrySchema).optional().describe("The kit's logos, each { url | outputId, name?, is_primary?, layout?, colorMode? }. Use outputId to bring in a generation."),
1433
- assets: z.array(assetEntrySchema).optional().describe("The kit's brand assets, each { url | outputId, name? }."),
1434
- sections: z
1435
- .array(sectionEntrySchema)
1436
- .optional()
1437
- .describe("The kit's curated sections, each { tab, sectionName, sortOrder?, fields? }. Array position is the default order."),
1438
- brandAccounts: z
1439
- .array(accountEntrySchema)
1440
- .optional()
1441
- .describe("The account owner's OWN profiles. A tracked-account id, or { platform?, handleOrUrl } to ADD one and start ingesting it."),
1442
- inspirationAccounts: z
1443
- .array(accountEntrySchema)
1444
- .optional()
1445
- .describe('Competitor/creator profiles they watch. Same entry shape as brandAccounts.'),
1446
- },
1447
- }, async (args, extra) => {
1448
- try {
1449
- const client = await getClient(extra);
1450
- const seedsFromAccount = [...(args.brandAccounts ?? []), ...(args.inspirationAccounts ?? [])].length > 0;
1451
- if (!args.name && !args.websiteUrl && !args.duplicateFrom && !seedsFromAccount) {
1452
- return errorResult(new Error('create_brand_kit needs a name, a websiteUrl, a social profile url, or duplicateFrom.'));
1453
- }
1454
- if (args.extract && !args.websiteUrl) {
1455
- return errorResult(new Error('create_brand_kit: extract requires a websiteUrl to scrape.'));
1456
- }
1457
- const { logos, assets, sections, brandAccounts, inspirationAccounts, ...rest } = args;
1458
- const { brandKit, extraction } = await client.createBrandKit({
1459
- ...rest,
1460
- ...(logos !== undefined ? { logos } : {}),
1461
- ...(assets !== undefined ? { assets } : {}),
1462
- ...(sections !== undefined ? { sections: sections } : {}),
1463
- ...(brandAccounts !== undefined ? { brandAccounts: brandAccounts } : {}),
1464
- ...(inspirationAccounts !== undefined ? { inspirationAccounts: inspirationAccounts } : {}),
1465
- });
1466
- return brandKitResult(brandKit, extraction);
1467
- }
1468
- catch (err) {
1469
- return errorResult(err);
1470
- }
1471
- });
1472
- // -- update_brand_kit -----------------------------------------------------
1473
- server.registerTool('update_brand_kit', {
1474
- title: 'Update Brand Kit',
1475
- annotations: WRITE,
1476
- description: "Update a brand kit: identity fields (business name, positioning, audience, voice profile, visual style, content strategy), its brand media, which kit is the DEFAULT, and which tracked accounts it is LINKED to. Only the fields you pass change. Get the current kit first with get_brand_kit. Requires the brandkit:write scope. THREE MODES, chosen by what you pass: (1) pass brandKitId to patch one kit; (2) pass orderedIds ALONE to reorder the whole set, which is collection-level because ordering is a property of the set and a per-kit position would let two kits claim one slot, so pass every id in the order you want; (3) pass brandKitId + extract:true to RE-RUN website extraction, which returns immediately and fills the kit in the background from its websiteUrl (poll extractionStatus via get_brand_kit). logos/assets/sections/brandAccounts/inspirationAccounts are DECLARATIVE: a patch REPLACES the whole list, so pass the full set and use [] to clear. THIS IS ALSO HOW YOU ADD NEW MEDIA TO A KIT: a logo or asset entry names either a url it already has, or outputId to bring in a generation that is not in the kit yet ('<id>', or '<id>-2' for variation 2 of a batch), whose bytes get COPIED into the kit so trashing that generation later cannot empty it. To add a logo, read the kit, append one entry, and send the whole list back; sending an outputId twice adds it twice. brandAccounts are the account owner's OWN profiles (performance), inspirationAccounts are competitors and creators they watch; they are separate lists because they mean opposite things. AN ENTRY IS EITHER a tracked-account id you already have, OR { platform?, handleOrUrl } to ADD a profile that is not tracked yet, which is what STARTS ingesting its posts (a full profile url carries its own platform, so platform is only needed for a bare handle). isDefault only accepts true (passing false would leave the account with no default at all, so to move the default, name the kit that should hold it).",
1477
- inputSchema: {
1478
- brandKitId: z.string().optional().describe('The brand kit id. Omit ONLY when reordering with orderedIds.'),
1479
- orderedIds: z
1480
- .array(z.string())
1481
- .optional()
1482
- .describe('Reorder mode: every brand kit id, in the order you want them. Pass this alone.'),
1483
- extract: z
1484
- .boolean()
1485
- .optional()
1486
- .describe('Re-run website extraction for this kit. Returns immediately; poll extractionStatus.'),
1487
- logos: z.array(logoEntrySchema).optional().describe('The kit\'s logos, each { url | outputId, name?, is_primary?, layout?: horizontal|stacked|icon|wordmark, colorMode?: full_color|light|dark|grayscale }. REPLACES the list; [] clears it. Exactly one ends up primary (the kit\'s cover); name none and the first wins.'),
1488
- assets: z.array(assetEntrySchema).optional().describe('The kit\'s brand assets, each { url | outputId, name? }. REPLACES the list; [] clears it.'),
1489
- sections: z
1490
- .array(sectionEntrySchema)
1491
- .optional()
1492
- .describe("The kit's curated sections, each { tab, sectionName, sortOrder?, fields? }. REPLACES the set, keyed by (tab, sectionName); a section left out is ARCHIVED, never deleted. Array position is the default order."),
1493
- isDefault: z.literal(true).optional().describe('Make this the default kit, un-defaulting every other.'),
1494
- brandAccounts: z
1495
- .array(accountEntrySchema)
1496
- .optional()
1497
- .describe("The account owner's OWN profiles. Each entry is a tracked-account id, or { platform?, handleOrUrl } to ADD one and start ingesting it. REPLACES the list; [] clears it."),
1498
- inspirationAccounts: z
1499
- .array(accountEntrySchema)
1500
- .optional()
1501
- .describe('Competitor/creator profiles they watch. Same entry shape as brandAccounts. REPLACES the list; [] clears it.'),
1502
- name: z.string().optional().describe('Rename the kit. This is the label in the UI, not the business name.'),
1503
- businessName: z.string().optional().describe('The business or creator name this kit represents.'),
1504
- websiteUrl: z.string().optional().describe('The business website. Stored as a reference; it does not re-extract on its own.'),
1505
- primaryOffer: z.string().optional().describe('What this business sells, in one line. Grounds copy in what is actually being promoted.'),
1506
- nicheDefinition: z.string().optional().describe('The niche this brand operates in. Keeps generated angles on-topic.'),
1507
- positioning: z.record(z.string(), z.unknown()).optional().describe('Positioning object (free-form).'),
1508
- audience: z.record(z.string(), z.unknown()).optional().describe('Audience object (free-form).'),
1509
- voiceProfile: z.record(z.string(), z.unknown()).optional().describe('Voice profile object (tone, style, ...).'),
1510
- visualStyle: z.string().optional().describe("The look in words, e.g. 'warm film grain, muted earth tones'. Grounds image and video prompts."),
1511
- designPrinciples: z.array(z.string()).optional().describe('Design rules to hold to, one per entry. REPLACES the list; [] clears it.'),
1512
- contentStrategy: z.record(z.string(), z.unknown()).optional().describe('Content strategy object (free-form).'),
1513
- },
1514
- }, async (args, extra) => {
1515
- try {
1516
- const client = await getClient(extra);
1517
- const { brandKitId, orderedIds, extract, logos, assets, sections, brandAccounts, inspirationAccounts, ...rest } = args;
1518
- // The declarative arrays are `unknown[]` in the schema (their entries are free-form objects the
1519
- // server validates), so they are cast at this one boundary rather than restating the shape in zod.
1520
- const input = {
1521
- ...rest,
1522
- ...(logos !== undefined ? { logos } : {}),
1523
- ...(assets !== undefined ? { assets } : {}),
1524
- ...(sections !== undefined ? { sections: sections } : {}),
1525
- // ⚠️ THESE TWO WERE DESTRUCTURED OUT OF `args` AND NEVER PUT BACK, so a patch that named only
1526
- // accounts arrived here empty and the handler answered "nothing to change". Anything pulled out of
1527
- // `args` by name has to be re-added by name; `...rest` cannot cover it.
1528
- ...(brandAccounts !== undefined ? { brandAccounts: brandAccounts } : {}),
1529
- ...(inspirationAccounts !== undefined ? { inspirationAccounts: inspirationAccounts } : {}),
1530
- };
1531
- // Reorder is the collection-level mode and takes no kit id at all.
1532
- if (orderedIds && !brandKitId) {
1533
- return brandKitListResult(await client.reorderBrandKits(orderedIds));
1534
- }
1535
- if (!brandKitId) {
1536
- return errorResult(new Error('update_brand_kit needs either brandKitId, or orderedIds to reorder.'));
1537
- }
1538
- // A patch and an extract compose: correct the url and re-extract in one call. The patch lands first so
1539
- // the extraction reads the url the caller just set, not the one it replaced.
1540
- const patched = Object.keys(input).length > 0
1541
- ? await client.updateBrandKit(brandKitId, input)
1542
- : null;
1543
- if (extract) {
1544
- const extraction = await client.extractBrandKit(brandKitId);
1545
- return brandKitResult(patched ?? (await client.getBrandKit(brandKitId)), extraction);
1546
- }
1547
- if (!patched) {
1548
- return errorResult(new Error('update_brand_kit: nothing to change. Pass a field, extract, or orderedIds.'));
1549
- }
1550
- return brandKitResult(patched);
1551
- }
1552
- catch (err) {
1553
- return errorResult(err);
1554
- }
1555
- });
1556
- // -- add_brand_kit_section ------------------------------------------------
1557
- // -- update_brand_kit_section ---------------------------------------------
1558
- // -- search_brand_knowledge -----------------------------------------------
1559
- server.registerTool('search_brand_knowledge', {
1560
- title: 'Search Brand Knowledge',
1561
- annotations: READ,
1562
- description: "Semantic search over a brand kit's knowledge base (everything the owner has uploaded: notes, docs, articles, video transcripts). Returns the most relevant passages, ranked. This is the deep-grounding read: use it to pull what the brand has said about a topic before drafting or deciding. Requires the brandkit:read scope.",
1563
- inputSchema: {
1564
- brandKitId: z.string().describe('The brand kit id (from list_brand_kits / get_brand_kit).'),
1565
- query: z.string().describe('What to search for, in natural language.'),
1566
- limit: z.number().int().min(1).max(50).optional().describe('Max matches (default 8).'),
1567
- threshold: z.number().min(0).max(1).optional().describe('Minimum similarity 0-1 (default 0.45).'),
1568
- },
1569
- }, async (args, extra) => {
1570
- try {
1571
- const client = await getClient(extra);
1572
- return brandKnowledgeSearchResult(await client.searchBrandKnowledge(args.brandKitId, args.query, {
1573
- limit: args.limit,
1574
- threshold: args.threshold,
1575
- }));
1576
- }
1577
- catch (err) {
1578
- return errorResult(err);
1579
- }
1580
- });
1581
- // -- list_brand_knowledge -------------------------------------------------
1582
- server.registerTool('list_brand_knowledge', {
1583
- title: 'List Brand Knowledge',
1584
- annotations: READ,
1585
- description: "The complete, paginated index of a brand kit's knowledge items (titles and metadata, no bodies). Use it to browse what exists, or to find an item's id before get_brand_knowledge or remove_brand_knowledge. For relevance retrieval, use search_brand_knowledge instead. Requires the brandkit:read scope.",
1586
- inputSchema: {
1587
- brandKitId: z.string().describe('The brand kit id.'),
1588
- limit: z.number().int().min(1).max(200).optional().describe('How many to return (default 50).'),
1589
- offset: z.number().int().min(0).optional().describe('Pagination offset.'),
1590
- },
1591
- }, async (args, extra) => {
1592
- try {
1593
- const client = await getClient(extra);
1594
- return brandKnowledgeListResult(await client.listBrandKnowledge(args.brandKitId, { limit: args.limit, offset: args.offset }));
1595
- }
1596
- catch (err) {
1597
- return errorResult(err);
1598
- }
1599
- });
1600
- // -- get_brand_knowledge --------------------------------------------------
1601
- server.registerTool('get_brand_knowledge', {
1602
- title: 'Get Brand Knowledge',
1603
- annotations: READ,
1604
- description: "Get one knowledge item's stored body by id (the capped anchor text; the full document is embedded for search, not stored verbatim). Use search_brand_knowledge for the deep content. Requires the brandkit:read scope.",
1605
- inputSchema: {
1606
- brandKitId: z.string().describe('The brand kit id.'),
1607
- knowledgeId: z.string().describe('The knowledge item id (from list_brand_knowledge or search_brand_knowledge).'),
1608
- },
1609
- }, async (args, extra) => {
1610
- try {
1611
- const client = await getClient(extra);
1612
- return brandKnowledgeDetailResult(await client.getBrandKnowledge(args.brandKitId, args.knowledgeId));
1613
- }
1614
- catch (err) {
1615
- return errorResult(err);
1616
- }
1617
- });
1618
- // -- add_brand_knowledge --------------------------------------------------
1619
- server.registerTool('add_brand_knowledge', {
1620
- title: 'Add Brand Knowledge',
1621
- annotations: WRITE,
1622
- description: "Add an item to a brand kit's knowledge base so it can be searched later. This is how the brand's knowledge grows over time: capture a lesson learned, a brand decision, an asset description, an article, or a video. Source can be text (a note), url (a page to scrape), youtube (a video transcript), or file. For a file, pass either fileData (base64, best for small documents and images) or fileUrl (a hosted URL the server fetches, needed for large files and video/audio). Requires the brandkit:write scope.",
1623
- inputSchema: {
1624
- brandKitId: z.string().describe('The brand kit id.'),
1625
- sourceType: z.enum(['text', 'url', 'youtube', 'file']).describe('How the content is provided.'),
1626
- text: z.string().optional().describe('For sourceType "text": the note body.'),
1627
- url: z.string().optional().describe('For sourceType "url" or "youtube": the link.'),
1628
- fileData: z.string().optional().describe('For sourceType "file": base64-encoded file bytes (small documents and images).'),
1629
- fileUrl: z.string().optional().describe('For sourceType "file": a hosted URL the server fetches (large files, video, audio).'),
1630
- fileExt: z.string().optional().describe('For sourceType "file": the extension without a dot, e.g. "pdf". Inferred from fileUrl when omitted.'),
1631
- title: z.string().optional().describe('Optional title (otherwise derived from the content).'),
1632
- },
1633
- }, async (args, extra) => {
1634
- try {
1635
- const client = await getClient(extra);
1636
- return brandKnowledgeItemResult(await client.addBrandKnowledge(args.brandKitId, {
1637
- sourceType: args.sourceType,
1638
- text: args.text,
1639
- url: args.url,
1640
- fileData: args.fileData,
1641
- fileUrl: args.fileUrl,
1642
- fileExt: args.fileExt,
1643
- title: args.title,
1644
- }));
1645
- }
1646
- catch (err) {
1647
- return errorResult(err);
1648
- }
1649
- });
1650
- // -- remove_brand_knowledge -----------------------------------------------
1651
- server.registerTool('remove_brand_knowledge', {
1652
- title: 'Remove Brand Knowledge',
1653
- annotations: WRITE,
1654
- description: "Remove a knowledge item and its embedding chunks from a brand kit's knowledge base. Requires the brandkit:write scope.",
1655
- inputSchema: {
1656
- brandKitId: z.string().describe('The brand kit id.'),
1657
- knowledgeId: z.string().describe('The knowledge item id to remove.'),
1658
- },
1659
- }, async (args, extra) => {
1660
- try {
1661
- const client = await getClient(extra);
1662
- const res = await client.removeBrandKnowledge(args.brandKitId, args.knowledgeId);
1663
- return brandKnowledgeItemResult({ id: res.id, title: null, sourceType: null, sourceUrl: null, createdAt: null, updatedAt: null }, 'Removed');
1664
- }
1665
- catch (err) {
1666
- return errorResult(err);
1667
- }
1668
- });
1669
- // -- list_media -----------------------------------------------------------
1670
- server.registerTool('list_media', {
1671
- title: 'List Media',
1672
- annotations: READ,
1673
- description: "List the account's media, newest first. `source` selects which library: 'creations' (default) is studio generations, each an output with one or more variations (images, video, or audio); 'uploads' is the editor Uploads tab, the raw video, image, and audio files the user uploaded to edit with; 'stock' is stock media the user has already used in a project (cached and reusable); 'all' returns every library merged across sources, each item self-describing via its `source`. Filter with contentType and page with limit/offset. For creations you can also filter by kind ('board'/'creation'/'look') or favorited/archived. Each item shows its id and, for a single-file item like an upload, its file name, duration, and resolved URL inline, so you can reference it directly (for example, add an upload to a timeline with update_timeline). Call get_media to SEE an item (image blocks / video keyframes).",
1674
- inputSchema: {
1675
- source: z
1676
- .enum(['creations', 'uploads', 'stock', 'all'])
1677
- .optional()
1678
- .describe("Which library to read: 'creations' (default, studio generations), 'uploads' (the editor Uploads tab), 'stock' (used stock media), or 'all' (every library merged newest-first)."),
1679
- contentType: z
1680
- .enum(['image', 'video', 'audio', 'transcript'])
1681
- .optional()
1682
- .describe('Filter to one media type.'),
1683
- kind: z
1684
- .enum(['creation', 'board', 'look'])
1685
- .optional()
1686
- .describe("Creations only. Filter by asset class: 'creation' (normal generations), 'board' (reference boards), or 'look'. Omit to list all."),
1687
- status: z.string().optional().describe("Status filter; defaults to 'completed'."),
1688
- favorited: z.boolean().optional().describe('Creations only. Only outputs that have a favorited variation.'),
1689
- archived: z.boolean().optional().describe('Creations only. Only outputs that have an archived variation.'),
1690
- limit: z.number().int().min(1).max(100).optional().describe('How many to return (default 20).'),
1691
- },
1692
- }, async (args, extra) => {
1693
- try {
1694
- const client = await getClient(extra);
1695
- return mediaListResult(await client.listMedia({
1696
- source: args.source,
1697
- contentType: args.contentType,
1698
- kind: args.kind,
1699
- status: args.status,
1700
- favorited: args.favorited,
1701
- archived: args.archived,
1702
- limit: args.limit,
1703
- }));
1704
- }
1705
- catch (err) {
1706
- return errorResult(err);
1707
- }
1708
- });
1709
- // -- search_media ---------------------------------------------------------
1710
- server.registerTool('search_media', {
1711
- title: 'Search Media',
1712
- annotations: READ,
1713
- description: "Semantically search the account's editable media library (their generated creations, uploads, licensed stock, and brand assets) by describing the content in natural language. Returns matching assets ranked by relevance, each with its media kind, a description, tags, and, for videos, the timestamps of the specific scenes that matched, so a precise moment can be located. Use this to find existing material to place, reference, or build with, rather than generating new media, whenever the user refers to footage, images, audio, or clips they already have. Optionally restrict results to specific media kinds. This searches only the account's own usable library, never inspiration, published posts, or knowledge. Call get_media to SEE a match (image blocks / video keyframes).",
1714
- inputSchema: {
1715
- query: z
1716
- .string()
1717
- .describe('A natural-language description of the media to find, describing its visible or audible content.'),
1718
- kinds: z
1719
- .array(z.enum(['image', 'video', 'audio']))
1720
- .optional()
1721
- .describe('Restrict results to these media kinds. Omit to search all kinds.'),
1722
- limit: z.number().int().min(1).max(50).optional().describe('Maximum number of assets to return (default 12, max 50).'),
1723
- },
1724
- }, async (args, extra) => {
1725
- try {
1726
- const client = await getClient(extra);
1727
- return mediaSearchResult(await client.searchMedia(args.query, { kinds: args.kinds, limit: args.limit }));
1728
- }
1729
- catch (err) {
1730
- return errorResult(err);
1731
- }
1732
- });
1733
- // -- library folder tools (Phase D) ---------------------------------------
1734
- const smartQuerySchema = z
1735
- .object({
1736
- text: z.string().optional().describe('Natural-language description to match semantically.'),
1737
- kinds: z.array(z.enum(['image', 'video', 'audio'])).optional(),
1738
- sources: z.array(z.enum(['creations', 'uploads', 'stock'])).optional(),
1739
- tags: z.array(z.string()).optional().describe('Require all of these tags.'),
1740
- favoritedOnly: z.boolean().optional(),
1741
- sort: z.enum(['relevance', 'recent', 'name']).optional(),
1742
- })
1743
- .optional()
1744
- .describe('For a smart folder: the live query that defines its membership (the same filters as the library search bar).');
1745
- server.registerTool('list_folders', {
1746
- title: 'List Folders',
1747
- annotations: READ,
1748
- description: "List the account's library folders (their own manual and smart folders, as a flat list with parent links for nesting) together with the built-in derived folders (recents, favorites, edits, canvas, cards). Use this to see how the library is organized before browsing or filing items.",
1749
- inputSchema: {},
1750
- }, async (_args, extra) => {
1751
- try {
1752
- return folderListResult(await (await getClient(extra)).listFolders());
1753
- }
1754
- catch (err) {
1755
- return errorResult(err);
1756
- }
1757
- });
1758
- server.registerTool('get_folder', {
1759
- title: 'Get Folder',
1760
- annotations: READ,
1761
- description: "Return the contents of one folder. The folder id is either one of the account's own folder ids or a built-in derived-folder key. A manual folder returns exactly the items filed in it; a smart folder computes its members live from its saved query; a derived folder returns its built-in set. Items are media (with kind and a description) and, in manual folders, entities such as projects or posts.",
1762
- inputSchema: {
1763
- folderId: z.string().describe('A folder id, or a derived-folder key (recents, favorites, edits, canvas, cards).'),
1764
- },
1765
- }, async (args, extra) => {
1766
- try {
1767
- const r = await (await getClient(extra)).getFolder(args.folderId);
1768
- return folderContentsResult(r.folder, r.items);
1769
- }
1770
- catch (err) {
1771
- return errorResult(err);
1772
- }
1773
- });
1774
- server.registerTool('create_folder', {
1775
- title: 'Create Folder',
1776
- annotations: WRITE,
1777
- description: 'Create a new library folder. A manual folder is an initially-empty collection you then file items into. A smart folder saves a query and stays live, always showing whatever currently matches. Optionally nest it under a parent folder. Create a folder only when the user wants to organize or save a view, not to hold a single transient result.',
1778
- inputSchema: {
1779
- name: z.string().describe('The folder name.'),
1780
- type: z.enum(['manual', 'smart']).optional().describe("'manual' (a collection you file items into) or 'smart' (a saved live query). Defaults to manual."),
1781
- query: smartQuerySchema,
1782
- parentId: z.string().optional().describe('Nest the new folder under this parent folder id.'),
1783
- },
1784
- }, async (args, extra) => {
1785
- try {
1786
- const f = await (await getClient(extra)).createFolder({ name: args.name, type: args.type, query: args.query, parentId: args.parentId });
1787
- return text(`Created ${f.type} folder "${f.name}" (id ${f.id}).`);
1788
- }
1789
- catch (err) {
1790
- return errorResult(err);
1791
- }
1792
- });
1793
- /**
1794
- * One item's universal identity. NO folderId: the folder is named by the tool's own folderId /
1795
- * folderIds now, which is what lets one call file many items into many folders.
1796
- */
1797
- const itemRefBodySchema = z.object({
1798
- sourceTable: z.string().describe("The item's source table (e.g. as returned by list_media / get_media)."),
1799
- sourceRecordId: z.string().describe("The item's source record id."),
1800
- variant: z.number().int().optional().describe('The variation index (default 0 for single-asset items).'),
1801
- });
1802
- server.registerTool('update_folder', {
1803
- title: 'Update Folder',
1804
- annotations: WRITE,
1805
- description: "Update the account's own folders: rename one, MOVE folders under a different parent (or to the top level with a null parent), change a smart folder's saved query, and FILE or UNFILE items. addItems/removeItems are DELTAS of { sourceTable, sourceRecordId, variant? }, not a list to replace, because an item can sit in several folders at once and a replace would silently unfile it from the others. Filing never moves or copies anything: it adds a pointer, and only manual folders accept items (a smart folder computes its own membership). Pass folderIds to patch several folders at once, which crossed with addItems files the same items into all of them; renaming and re-querying still need exactly one folder. NOTE the asymmetry: nesting a FOLDER via parentId is a MOVE (a folder has one parent), while filing an ITEM is a pointer that leaves its other folders alone.",
1806
- inputSchema: {
1807
- folderId: z.string().describe('The folder id to update.'),
1808
- folderIds: z
1809
- .array(z.string())
1810
- .optional()
1811
- .describe('Patch several folders at once. Attribute fields (name, query) still need exactly one.'),
1812
- name: z.string().optional().describe('A new name.'),
1813
- parentId: z.string().nullable().optional().describe('A new parent folder id, or null to move to the top level. MOVES the folder.'),
1814
- query: smartQuerySchema,
1815
- addItems: z
1816
- .array(itemRefBodySchema)
1817
- .optional()
1818
- .describe('File these items into the folder(s). A delta: their other folders are untouched.'),
1819
- removeItems: z
1820
- .array(itemRefBodySchema)
1821
- .optional()
1822
- .describe('Unfile these items. Only the pointer goes; the asset is never deleted.'),
1823
- },
1824
- }, async (args, extra) => {
1825
- try {
1826
- const client = await getClient(extra);
1827
- const patch = {
1828
- name: args.name,
1829
- parentId: args.parentId,
1830
- query: args.query,
1831
- addItems: args.addItems,
1832
- removeItems: args.removeItems,
1833
- };
1834
- const targets = args.folderIds?.length ? args.folderIds : [args.folderId];
1835
- const folders = targets.length > 1
1836
- ? await client.updateFolders(targets, patch)
1837
- : [await client.updateFolder(targets[0], patch)];
1838
- const filed = args.addItems?.length ?? 0;
1839
- const unfiled = args.removeItems?.length ?? 0;
1840
- const what = [
1841
- filed ? `filed ${filed} item(s)` : null,
1842
- unfiled ? `unfiled ${unfiled} item(s)` : null,
1843
- ].filter(Boolean).join(', ');
1844
- const names = folders.map((f) => `"${f.name}" (id ${f.id})`).join(', ');
1845
- return text(`Updated ${folders.length === 1 ? 'folder' : `${folders.length} folders`} ${names}${what ? `: ${what}` : '.'}`);
1846
- }
1847
- catch (err) {
1848
- return errorResult(err);
1849
- }
1850
- });
1851
- server.registerTool('delete_folder', {
1852
- title: 'Delete Folder',
1853
- annotations: WRITE,
1854
- description: "Delete one of the account's own folders and everything nested under it. This removes the folder structure only; the media and entities inside are pointers, so the underlying assets are never deleted. Confirm intent before deleting a folder that contains items.",
1855
- inputSchema: { folderId: z.string().describe('The folder id to delete.') },
1856
- }, async (args, extra) => {
1857
- try {
1858
- await (await getClient(extra)).deleteFolder(args.folderId);
1859
- return text(`Deleted folder ${args.folderId}.`);
1860
- }
1861
- catch (err) {
1862
- return errorResult(err);
1863
- }
1864
- });
1865
- // -- get_media ------------------------------------------------------------
1866
- server.registerTool('get_media', {
1867
- title: 'Get Media',
1868
- /**
1869
- * ⭐ get_media BOTH SEES AND SHOWS, which is why it is one tool and not two.
1870
- *
1871
- * The image blocks are the agent's vision and cost context, so they run through a shared byte budget.
1872
- * The widget renders from URLS, which cost nothing. A call therefore attaches as many pixels as the
1873
- * budget allows and displays EVERY resolved item, and the two limits never fight: more items means
1874
- * fewer inlined images, never a card showing less than was asked for.
1875
- */
1876
- ...RENDERS_GENERATION,
1877
- annotations: READ,
1878
- description: 'SEE specific media. Pass a batch of items (up to 25) to view them at once, rendered together for the user AND returned as image blocks for you: each item is either a { url } (e.g. a URL threaded from get_context, a layer/asset URL from get_project / get_card, or an upload URL from list_media source=uploads) or an { mediaId, variation? } (a studio output id, full or first-8; omit variation to get the primary one). Returns light metadata per item plus an IMAGE block for each image so you can actually see it. For a VIDEO, set frames (and optionally fromSec/toSec) on the item to get low-res KEYFRAMES across that source-time window, so you can watch the raw footage (judge B-roll relevance, take quality) without editing it; audio still returns metadata + the url. An mediaId without a variation returns ONLY the primary variation and lists the others; request a specific variation to see it. Use this to inspect the actual pixels, not just URLs.',
1879
- inputSchema: {
1880
- items: z
1881
- .array(z.union([
1882
- z.object({
1883
- url: z.string().describe('A media URL on our storage (from get_context / get_project / get_card).'),
1884
- fromSec: z.number().min(0).optional().describe('Video keyframes: start of the source-time window (seconds). Omit for the whole clip.'),
1885
- toSec: z.number().min(0).optional().describe('Video keyframes: end of the source-time window (seconds).'),
1886
- frames: z.number().int().min(1).optional().describe('Video keyframes: how many to return across the window. Set this (or fromSec/toSec) to watch the raw footage.'),
1887
- }),
1888
- z.object({
1889
- mediaId: z.string().describe('A studio output id (full or first-8 characters).'),
1890
- variation: z
1891
- .number()
1892
- .int()
1893
- .positive()
1894
- .optional()
1895
- .describe('1-based variation to view; omit for the primary variation only.'),
1896
- fromSec: z.number().min(0).optional().describe('Video keyframes: start of the source-time window (seconds). Omit for the whole clip.'),
1897
- toSec: z.number().min(0).optional().describe('Video keyframes: end of the source-time window (seconds).'),
1898
- frames: z.number().int().min(1).optional().describe('Video keyframes: how many to return across the window. Set this (or fromSec/toSec) to watch the raw footage.'),
1899
- }),
1900
- ]))
1901
- .min(1)
1902
- .max(25)
1903
- .describe('The media to view, up to 25 items per call. Paginate with another call for more.'),
1904
- },
1905
- }, async (args, extra) => {
1906
- try {
1907
- const client = await getClient(extra);
1908
- const result = await client.getMediaBatch(args.items);
1909
- // Image blocks are an MCP-layer concern: fetch the resolver-chosen still
1910
- // (imageUrl) for each item that has one (images + video posters). audio /
1911
- // transcript / posterless items stay text-only. See get-context §9.5.
1912
- // ⚠️ Ten items at 840 KB each is 8 MB against a 1 MB ceiling, and this used to fetch them all in
1913
- // parallel with no bound. One shared budget, spent in order.
1914
- const images = await inlineImagesWithinBudget(result.items.map((it) => (it.ok ? it.imageUrl : null)));
1915
- return mediaBatchResult(result, images, client.baseUrl);
1916
- }
1917
- catch (err) {
1918
- return errorResult(err);
1919
- }
1920
- });
1921
- // -- create_media_upload --------------------------------------------------
1922
- server.registerTool('create_media_upload', {
1923
- title: 'Create Media Upload',
1924
- annotations: WRITE,
1925
- description: 'Upload a local file as first-class media (phase 1 of 2). Returns a signed uploadUrl and the exact headers to send; PUT the file bytes to that URL with those headers unchanged, then call complete_media_upload with the returned outputId. The finished media is referenceable by outputId in generate_* and as an asset on a card via update_card. For a file already on a public URL, use import_media instead. Requires the assets:write scope.',
1926
- inputSchema: {
1927
- fileName: z.string().describe('The file name (used for its extension), e.g. "cover.png".'),
1928
- contentType: z.string().describe('The file MIME type, e.g. "image/png" or "video/mp4".'),
1929
- sizeBytes: z.number().optional().describe('Optional file size in bytes.'),
1930
- },
1931
- }, async (args, extra) => {
1932
- try {
1933
- const client = await getClient(extra);
1934
- return mediaUploadResult(await client.createMediaUpload({
1935
- fileName: args.fileName,
1936
- contentType: args.contentType,
1937
- sizeBytes: args.sizeBytes,
1938
- }));
1939
- }
1940
- catch (err) {
1941
- return errorResult(err);
1942
- }
1943
- });
1944
- // -- complete_media_upload ------------------------------------------------
1945
- server.registerTool('complete_media_upload', {
1946
- title: 'Complete Media Upload',
1947
- ...RENDERS_GENERATION,
1948
- annotations: WRITE,
1949
- description: 'Finalize a media upload (phase 2 of 2) after the file bytes were PUT to the signed uploadUrl from create_media_upload. Publishes the media and returns its outputId + public URL. Requires the assets:write scope.',
1950
- inputSchema: {
1951
- outputId: z.string().describe('The outputId returned by create_media_upload.'),
1952
- },
1953
- }, async (args, extra) => {
1954
- try {
1955
- const client = await getClient(extra);
1956
- return uploadedMediaResult(await client.completeMediaUpload(args.outputId), client.baseUrl);
1957
- }
1958
- catch (err) {
1959
- return errorResult(err);
1960
- }
1961
- });
1962
- // -- import_media ---------------------------------------------------------
1963
- server.registerTool('import_media', {
1964
- title: 'Import Media',
1965
- ...RENDERS_GENERATION,
1966
- annotations: WRITE,
1967
- description: 'Import a remote URL as first-class media: the server fetches and re-hosts it, returning its outputId + public URL (referenceable by outputId in generate_* and as an asset on a card via update_card). Use this for a file already on a public URL, or from a hosted client that cannot read local files. Requires the assets:write scope.',
1968
- inputSchema: {
1969
- url: z.string().describe('A public http(s) URL to fetch and re-host.'),
1970
- contentType: z.string().optional().describe('Optional MIME override (else taken from the response).'),
1971
- fileName: z.string().optional().describe('Optional file name (used for its extension).'),
1972
- },
1973
- }, async (args, extra) => {
1974
- try {
1975
- const client = await getClient(extra);
1976
- return importedMediaResult(await client.importMedia({
1977
- url: args.url,
1978
- contentType: args.contentType,
1979
- fileName: args.fileName,
1980
- }), client.baseUrl);
1981
- }
1982
- catch (err) {
1983
- return errorResult(err);
1984
- }
1985
- });
1986
- // -- list_models ----------------------------------------------------------
1987
- server.registerTool('list_models', {
1988
- title: 'List Models',
1989
- annotations: READ,
1990
- description: "List the generation models available to this account (the discovery catalog): which models exist, their content type and operation, and a compact capability summary. Use this to pick a model, then call get_model for its full request shape before generating. Source of truth for valid model ids; do not hardcode them.",
1991
- inputSchema: {
1992
- contentType: z
1993
- .enum(['image', 'video', 'audio'])
1994
- .optional()
1995
- .describe('Filter to one content type.'),
1996
- },
1997
- }, async (args, extra) => {
1998
- try {
1999
- const client = await getClient(extra);
2000
- return modelListResult(await client.listModels({ contentType: args.contentType }));
2001
- }
2002
- catch (err) {
2003
- return errorResult(err);
2004
- }
2005
- });
2006
- // -- get_model ------------------------------------------------------------
2007
- server.registerTool('get_model', {
2008
- title: 'Get Model',
2009
- annotations: READ,
2010
- description: "Get one model's full request shape by id: the exact parameters it accepts (input types, prompt mode and char cap, duration range, resolutions, aspect ratios, max references, generation count, audio, features). Ground a generation against this instead of guessing the parameters, then preview cost with the matching generate tool's getCost option before running it.",
2011
- inputSchema: {
2012
- modelId: z.string().describe('The model id, e.g. from list_models (such as "veo-3.1-fast").'),
2013
- },
2014
- }, async (args, extra) => {
2015
- try {
2016
- const client = await getClient(extra);
2017
- return modelResult(await client.getModel(args.modelId));
2018
- }
2019
- catch (err) {
2020
- return errorResult(err);
2021
- }
2022
- });
2023
- // -- list_platforms -------------------------------------------------------
2024
- server.registerTool('list_platforms', {
2025
- title: 'List Platforms',
2026
- annotations: READ,
2027
- description: "List the platforms this account can publish to (the discovery catalog): each platform's formats and whether a connected account exists for it. Use this to pick a platform and format, then call get_platform for the exact fields a post requires. Source of truth for valid platforms/formats; do not hardcode them.",
2028
- inputSchema: {},
2029
- }, async (extra) => {
2030
- try {
2031
- const client = await getClient(extra);
2032
- return platformListResult(await client.listPlatforms());
2033
- }
2034
- catch (err) {
2035
- return errorResult(err);
2036
- }
2037
- });
2038
- // -- get_platform ---------------------------------------------------------
2039
- server.registerTool('get_platform', {
2040
- title: 'Get Platform',
2041
- annotations: READ,
2042
- description: "Get one platform's full publishing shape: the fields, options (enums), and character limits a post requires per format (post, reel, short, story, thread). Ground a post's platformSettings against this instead of guessing the fields. Optionally pass a format to narrow the result.",
2043
- inputSchema: {
2044
- platform: z
2045
- .enum(POST_PLATFORMS)
2046
- .describe('The platform id, e.g. from list_platforms (such as "instagram").'),
2047
- format: z
2048
- .string()
2049
- .optional()
2050
- .describe('Optional format to narrow to (e.g. "reel", "short", "story", "thread").'),
2051
- },
2052
- }, async (args, extra) => {
2053
- try {
2054
- const client = await getClient(extra);
2055
- return platformResult(await client.getPlatform(args.platform, { format: args.format }));
2056
- }
2057
- catch (err) {
2058
- return errorResult(err);
2059
- }
2060
- });
2061
- // -- list_elements --------------------------------------------------------
2062
- server.registerTool('list_elements', {
2063
- title: 'List Elements',
2064
- annotations: READ,
2065
- description: "List the account's saved reference elements: reusable named groups of images (a character, prop, location) addressable in a Kling prompt as @name. Reference one in a generation by elementId.",
2066
- inputSchema: {},
2067
- }, async (_args, extra) => {
2068
- try {
2069
- const client = await getClient(extra);
2070
- return elementListResult(await client.listElements());
2071
- }
2072
- catch (err) {
2073
- return errorResult(err);
2074
- }
2075
- });
2076
- // -- get_element ----------------------------------------------------------
2077
- server.registerTool('get_element', {
2078
- title: 'Get Element',
2079
- annotations: READ,
2080
- description: "Get one saved reference element by id: its name, category, description, and images.",
2081
- inputSchema: { elementId: z.string().describe('The element id.') },
2082
- }, async (args, extra) => {
2083
- try {
2084
- const client = await getClient(extra);
2085
- return elementResult(await client.getElement(args.elementId));
2086
- }
2087
- catch (err) {
2088
- return errorResult(err);
2089
- }
2090
- });
2091
- // -- create_element -------------------------------------------------------
2092
- server.registerTool('create_element', {
2093
- title: 'Create Element',
2094
- annotations: WRITE,
2095
- description: "Create a reusable reference element from 2-4 images (or 1 video) of one entity (a character, prop, location). Images may be URLs or output-id tokens, so you can generate the angle shots first and assemble an element from them. Reference it later in a Kling 3.0 generation via references.elements [{ elementId }] and @name in the prompt.",
2096
- inputSchema: {
2097
- name: z.string().describe('Referenced in the prompt as @name.'),
2098
- description: z.string().describe('What the element represents (required).'),
2099
- category: z.enum(['auto', 'character', 'location', 'prop']).optional().describe("Kind of entity (default 'auto')."),
2100
- images: z.array(z.string()).optional().describe('2-4 image URLs or output-id tokens.'),
2101
- video: z.string().optional().describe('A single video URL or output-id token (alternative to images).'),
2102
- },
2103
- }, async (args, extra) => {
2104
- try {
2105
- const client = await getClient(extra);
2106
- return elementResult(await client.createElement({
2107
- name: args.name,
2108
- description: args.description,
2109
- category: args.category,
2110
- images: args.images,
2111
- video: args.video,
2112
- }), 'Created');
2113
- }
2114
- catch (err) {
2115
- return errorResult(err);
2116
- }
2117
- });
2118
- // -- update_element -------------------------------------------------------
2119
- server.registerTool('update_element', {
2120
- title: 'Update Element',
2121
- annotations: WRITE,
2122
- description: "Update a saved element's name, description, or category.",
2123
- inputSchema: {
2124
- elementId: z.string().describe('The element id.'),
2125
- name: z.string().optional().describe('Rename the element.'),
2126
- description: z.string().optional().describe('What this element is, in words. This is what makes it findable later.'),
2127
- category: z.enum(['auto', 'character', 'location', 'prop']).optional().describe("What kind of element this is. 'auto' lets the server classify it from the image."),
2128
- },
2129
- }, async (args, extra) => {
2130
- try {
2131
- const client = await getClient(extra);
2132
- return elementResult(await client.updateElement(args.elementId, { name: args.name, description: args.description, category: args.category }), 'Updated');
2133
- }
2134
- catch (err) {
2135
- return errorResult(err);
2136
- }
2137
- });
2138
- // -- delete_element -------------------------------------------------------
2139
- server.registerTool('delete_element', {
2140
- title: 'Delete Element',
2141
- annotations: WRITE,
2142
- description: 'Delete a saved reference element.',
2143
- inputSchema: { elementId: z.string().describe('The element id.') },
2144
- }, async (args, extra) => {
2145
- try {
2146
- const client = await getClient(extra);
2147
- await client.deleteElement(args.elementId);
2148
- return elementDeletedResult(args.elementId);
2149
- }
2150
- catch (err) {
2151
- return errorResult(err);
2152
- }
2153
- });
2154
- // -- get_generation_status ------------------------------------------------
2155
- server.registerTool('get_generation_status', {
2156
- /**
2157
- * ⛔⛔⛔ **THIS TOOL DOES NOT DECLARE THE WIDGET, AND THAT IS THE WHOLE POINT.**
2158
- *
2159
- * `generate_image` returns a widget that draws placeholders and then POLLS ITSELF to completion. Its
2160
- * text also tells the agent to poll, because a host without MCP Apps support has nothing else and an
2161
- * agent that stopped polling would leave a charged generation unclaimed. So the agent polls, this
2162
- * tool answers, and if it declared the widget the host would mount a SECOND card showing the same
2163
- * finished generation beside the one that just filled in.
2164
- *
2165
- * ⭐ Deterministic rather than instructional: the duplicate cannot happen, instead of relying on an
2166
- * agent reading prose that asks it not to poll. Create and look are DISPLAY; query is REPORT.
2167
- *
2168
- * ⏭️ `get_media` is the tool named "SEE specific media" and is where the look-at-an-existing-thing
2169
- * widget belongs. Until it binds one, inspecting an old generation is text only.
2170
- */
2171
- title: 'Get Generation Status',
2172
- annotations: READ,
2173
- 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.",
2174
- inputSchema: {
2175
- outputIds: z
2176
- .array(z.string())
2177
- .min(1)
2178
- .max(8)
2179
- .describe('1-8 outputIds to check (each from a prior generate_* call).'),
2180
- wait: z
2181
- .boolean()
2182
- .optional()
2183
- .describe('Block until terminal (up to ~50s), the default. false = an instant snapshot.'),
2184
- },
2185
- }, async (args, extra) => {
2186
- try {
2187
- const client = await getClient(extra);
2188
- const blocking = args.wait !== false;
2189
- const gens = await Promise.all(args.outputIds.map(async (id) => {
2190
- if (!blocking)
2191
- return client.getGeneration(id);
2192
- try {
2193
- return await client.waitForGeneration(id, { timeoutMs: SMART_WAIT_MS });
2194
- }
2195
- catch (err) {
2196
- // Timeout is expected for a slow render. A transient poll error is not, but
2197
- // it must not fail the whole BATCH either: fall back to a status snapshot so
2198
- // the other ids still report, and only surface the error if even that fails.
2199
- if (err instanceof GenerationTimeoutError)
2200
- return client.getGeneration(id);
2201
- try {
2202
- return await client.getGeneration(id);
2203
- }
2204
- catch {
2205
- throw err;
2206
- }
2207
- }
2208
- }));
2209
- // ⭐ Only the single-generation case is attached: a batch of ten would embed ten sets of bytes into
2210
- // one result. Polling ONE generation is the case a person is watching, and the one worth rendering.
2211
- const attachments = gens.length === 1 && gens[0]
2212
- ? { [gens[0].outputId]: await attachmentsFor(gens[0]) }
2213
- : {};
2214
- return generationBatchResult(gens, attachments, client.baseUrl);
2215
- }
2216
- catch (err) {
2217
- return errorResult(err);
2218
- }
2219
- });
2220
- // -- list_cards -----------------------------------------------------------
2221
- server.registerTool('list_cards', {
2222
- title: 'List Cards',
2223
- annotations: READ,
2224
- description: "List one SPACE's cards (newest-updated first). ⚠️ SCOPED, NOT COMPLETE: without spaceId this lists the account's DEFAULT space only, and cards on any other board are absent with nothing in the response saying so (search misses them too). Call list_spaces FIRST and pass spaceId unless you specifically mean the default board. Filter by status, platform, stage (id/slug/name), favorite, or a title search. Call get_card for one card's full detail (posts + assets).",
2225
- inputSchema: {
2226
- spaceId: z
2227
- .string()
2228
- .optional()
2229
- .describe("Which space's board to list, from list_spaces. Omit ONLY when you mean the account's default space; omitting it does not search every board."),
2230
- archived: z
2231
- .boolean()
2232
- .optional()
2233
- .describe('Only ARCHIVED cards. Archived cards are excluded by default, matching the board.'),
2234
- platform: z.enum(POST_PLATFORMS).optional().describe('Filter by the post platform.'),
2235
- stage: z.string().optional().describe('Filter by a stage id, slug, or name. Resolved within the chosen space.'),
2236
- search: z.string().optional().describe('Case-insensitive title search, scoped to the chosen space.'),
2237
- limit: z.number().int().min(1).max(100).optional().describe('How many to return (default 50).'),
2238
- offset: z.number().int().min(0).optional().describe('Pagination offset.'),
2239
- },
2240
- }, async (args, extra) => {
2241
- try {
2242
- const client = await getClient(extra);
2243
- return cardListResult(await client.listCards({
2244
- spaceId: args.spaceId,
2245
- archived: args.archived,
2246
- platform: args.platform,
2247
- stage: args.stage,
2248
- search: args.search,
2249
- limit: args.limit,
2250
- offset: args.offset,
2251
- }));
2252
- }
2253
- catch (err) {
2254
- return errorResult(err);
2255
- }
2256
- });
2257
- // -- get_card -------------------------------------------------------------
2258
- server.registerTool('get_card', {
2259
- title: 'Get Card',
2260
- annotations: READ,
2261
- description: 'Get one CARD in full: its fields (title, description, script, notes, status, stage, schedule), plus its posts (one per platform, which is how it publishes) and its attached assets in carousel order.',
2262
- inputSchema: {
2263
- cardId: z.string().describe('The card id from list_cards.'),
2264
- },
2265
- }, async (args, extra) => {
2266
- try {
2267
- const client = await getClient(extra);
2268
- return cardResult(await client.getCard(args.cardId));
2269
- }
2270
- catch (err) {
2271
- return errorResult(err);
2272
- }
2273
- });
2274
- // -- list_spaces ----------------------------------------------------------
2275
- server.registerTool('list_spaces', {
2276
- title: 'List Spaces',
2277
- annotations: READ,
2278
- description: "List the account's SPACES. A space is the planner's top-level container: Space > Stage > Card > Post. Each space has its own stages, so two spaces can both hold a stage called 'Published'. Call this FIRST to discover which board to work in, then pass a space id to list_stages, list_cards or create_card. Archived spaces are excluded unless includeArchived is set.",
2279
- inputSchema: {
2280
- includeArchived: z
2281
- .boolean()
2282
- .optional()
2283
- .describe('Include archived spaces. Default false, matching the grid in the app.'),
2284
- },
2285
- }, async (args, extra) => {
2286
- try {
2287
- const client = await getClient(extra);
2288
- return spaceListResult(await client.listSpaces({ includeArchived: args.includeArchived }));
2289
- }
2290
- catch (err) {
2291
- return errorResult(err);
2292
- }
2293
- });
2294
- // -- get_space ------------------------------------------------------------
2295
- server.registerTool('get_space', {
2296
- title: 'Get Space',
2297
- annotations: READ,
2298
- description: 'Return one space with its live card count. Use list_spaces to discover ids. The count excludes archived cards, and is the same number list_spaces reports for that space.',
2299
- inputSchema: { spaceId: z.string().describe('The space id.') },
2300
- }, async (args, extra) => {
2301
- try {
2302
- const client = await getClient(extra);
2303
- return spaceResult(await client.getSpace(args.spaceId));
2304
- }
2305
- catch (err) {
2306
- return errorResult(err);
2307
- }
2308
- });
2309
- // -- create_space ---------------------------------------------------------
2310
- server.registerTool('create_space', {
2311
- title: 'Create Space',
2312
- annotations: WRITE,
2313
- description: "Create a space: a new planner board with its own stages. duplicateFrom copies another space's STAGES, never its cards, so the new board arrives with the columns and none of the work. Requires the planner:write scope.",
2314
- inputSchema: {
2315
- name: z.string().describe('The space name.'),
2316
- coverUrl: z.string().optional().describe('A cover image URL for the space tile.'),
2317
- duplicateFrom: z
2318
- .string()
2319
- .optional()
2320
- .describe("Copy this space's stages into the new one. Cards are never copied."),
2321
- },
2322
- }, async (args, extra) => {
2323
- try {
2324
- const client = await getClient(extra);
2325
- return spaceResult(await client.createSpace({
2326
- name: args.name,
2327
- coverUrl: args.coverUrl,
2328
- duplicateFrom: args.duplicateFrom,
2329
- }));
2330
- }
2331
- catch (err) {
2332
- return errorResult(err);
2333
- }
2334
- });
2335
- // -- update_space ---------------------------------------------------------
2336
- server.registerTool('update_space', {
2337
- title: 'Update Space',
2338
- annotations: WRITE,
2339
- description: "Rename a space or change its cover. This is a PATCH: a field you omit is left alone, so renaming does not disturb the cover. Pass coverUrl as an empty string to REMOVE the cover. To favorite or archive a space, use the `favorite` and `archive` tools with assetType 'space' instead. Requires the planner:write scope.",
2340
- inputSchema: {
2341
- spaceId: z.string().describe('The space id to update.'),
2342
- name: z.string().optional().describe('A new name.'),
2343
- coverUrl: z
2344
- .string()
2345
- .optional()
2346
- .describe('A new cover image URL. Pass an empty string to remove the cover entirely.'),
2347
- },
2348
- }, async (args, extra) => {
2349
- try {
2350
- const client = await getClient(extra);
2351
- // An empty string is how a tool caller says "remove it": JSON Schema has no way to
2352
- // distinguish an omitted string from an explicit null in a plain string field.
2353
- const coverUrl = args.coverUrl === undefined ? undefined : args.coverUrl === '' ? null : args.coverUrl;
2354
- return spaceResult(await client.updateSpace(args.spaceId, { name: args.name, coverUrl }));
2355
- }
2356
- catch (err) {
2357
- return errorResult(err);
2358
- }
2359
- });
2360
- // -- delete_space ---------------------------------------------------------
2361
- server.registerTool('delete_space', {
2362
- title: 'Delete Space',
2363
- annotations: WRITE,
2364
- description: 'Delete a space. The server REFUSES a space that still holds cards and names the count, because the delete cascades to every card in it along with their covers, captions, posts and schedules. Archive the space instead if you want it out of the way. Requires the planner:write scope.',
2365
- inputSchema: { spaceId: z.string().describe('The space id to delete.') },
2366
- }, async (args, extra) => {
2367
- try {
2368
- const client = await getClient(extra);
2369
- await client.deleteSpace(args.spaceId);
2370
- return spaceDeletedResult(args.spaceId);
2371
- }
2372
- catch (err) {
2373
- return errorResult(err);
2374
- }
2375
- });
2376
- // -- list_stages -------------------------------------------------
2377
- server.registerTool('list_stages', {
2378
- title: 'List Pipeline Stages',
2379
- annotations: READ,
2380
- description: "List one SPACE's stages, in order. ⚠️ STAGES ARE PER-SPACE: without spaceId this is the account's DEFAULT space, and two spaces can each hold a stage named 'Published' with different ids, so a stage name resolved against the wrong space is a different column. Stages are user-customizable (renamed, reordered, added, removed), so call this to discover the real stages before placing a card; pass a stage's id (most stable), slug, or name to create_card / update_card.",
2381
- inputSchema: {
2382
- spaceId: z
2383
- .string()
2384
- .optional()
2385
- .describe("Which space's stages, from list_spaces. Omit only when you mean the account's default space."),
2386
- },
2387
- }, async (args, extra) => {
2388
- try {
2389
- const client = await getClient(extra);
2390
- return stageListResult(await client.listStages({ spaceId: args.spaceId }));
2391
- }
2392
- catch (err) {
2393
- return errorResult(err);
2394
- }
2395
- });
2396
- // -- create_stage ---------------------------------------------------------
2397
- server.registerTool('create_stage', {
2398
- title: 'Create Stage',
2399
- annotations: WRITE,
2400
- description: "Create a stage (a column on one board). ⚠️ STAGES ARE PER-SPACE: without spaceId this creates on the account's DEFAULT board, which is rarely what you want once more than one space exists, so call list_spaces first. The slug is DERIVED from the name and is not settable; a board cannot hold two columns whose names produce the same slug and the server refuses the second rather than renaming it for you. Place the column with afterId/beforeId, or omit both to put it at the end. Requires the planner:write scope.",
2401
- inputSchema: {
2402
- name: z.string().describe('The column name, for example "In Review". Must contain a letter or number.'),
2403
- spaceId: z
2404
- .string()
2405
- .optional()
2406
- .describe("Which board, from list_spaces. Omit only when you mean the account's default space."),
2407
- color: z.string().optional().describe('A hex color such as "#3B82F6".'),
2408
- afterId: z.string().optional().describe('Put the new column immediately after this stage id.'),
2409
- beforeId: z.string().optional().describe('Put the new column immediately before this stage id.'),
2410
- },
2411
- }, async (args, extra) => {
2412
- try {
2413
- const client = await getClient(extra);
2414
- return stageResult(await client.createStage({
2415
- name: args.name,
2416
- spaceId: args.spaceId,
2417
- color: args.color,
2418
- afterId: args.afterId,
2419
- beforeId: args.beforeId,
2420
- }));
2421
- }
2422
- catch (err) {
2423
- return errorResult(err);
2424
- }
2425
- });
2426
- // -- update_stage ---------------------------------------------------------
2427
- server.registerTool('update_stage', {
2428
- title: 'Update Stage',
2429
- annotations: WRITE,
2430
- description: "Rename, recolor or move one stage. This is a PATCH: a field you omit is left alone. ⚠️ spaceId is REQUIRED, because a stage id alone does not tell the server which board you mean and guessing the wrong one silently changes nothing. Renaming re-derives the slug, so renaming a column away from 'Published' also stops publishing auto-moving cards into it; a rename that collides with another column on the same board is refused. Moving names NEIGHBORS, not a position: pass afterId or beforeId. Requires the planner:write scope.",
2431
- inputSchema: {
2432
- stageId: z.string().describe('The stage id to update, from list_stages.'),
2433
- spaceId: z.string().describe('The board this stage is on, from list_stages or list_spaces.'),
2434
- name: z.string().optional().describe('A new name. The slug follows it automatically.'),
2435
- color: z.string().optional().describe('A new hex color such as "#3B82F6".'),
2436
- afterId: z
2437
- .string()
2438
- .optional()
2439
- .describe('Move it immediately after this stage id. Use the empty string to move it to the far left.'),
2440
- beforeId: z
2441
- .string()
2442
- .optional()
2443
- .describe('Move it immediately before this stage id. Use the empty string to move it to the far right.'),
2444
- },
2445
- }, async (args, extra) => {
2446
- try {
2447
- const client = await getClient(extra);
2448
- /*
2449
- An empty string is how a tool caller says "the edge": JSON Schema cannot distinguish an
2450
- omitted string from an explicit null in a plain string field, and the two mean opposite
2451
- things here. Omitted means "do not move it"; null means "move it to the end of the board".
2452
- `update_space` resolves the same ambiguity the same way for coverUrl.
2453
- */
2454
- const edge = (v) => (v === undefined ? undefined : v === '' ? null : v);
2455
- const { stage, respaced } = await client.updateStage(args.stageId, {
2456
- spaceId: args.spaceId,
2457
- name: args.name,
2458
- color: args.color,
2459
- afterId: edge(args.afterId),
2460
- beforeId: edge(args.beforeId),
2461
- });
2462
- return stageResult(stage, respaced);
2463
- }
2464
- catch (err) {
2465
- return errorResult(err);
2466
- }
2467
- });
2468
- // -- delete_stage ---------------------------------------------------------
2469
- server.registerTool('delete_stage', {
2470
- title: 'Delete Stage',
2471
- annotations: WRITE,
2472
- description: 'Delete a stage and move its cards to another column. The server REFUSES a column that still holds cards when you name no targetStageId, and tells you how many there are: cards are never destroyed by deleting a column, and the delete and the reassignment happen in one transaction. Returns the board that is left. Requires the planner:write scope.',
2473
- inputSchema: {
2474
- stageId: z.string().describe('The stage id to delete, from list_stages.'),
2475
- spaceId: z.string().describe('The board this stage is on.'),
2476
- targetStageId: z
2477
- .string()
2478
- .optional()
2479
- .describe('Where this column\'s cards should go. Required unless the column is empty.'),
2480
- },
2481
- }, async (args, extra) => {
2482
- try {
2483
- const client = await getClient(extra);
2484
- const result = await client.deleteStage(args.stageId, {
2485
- spaceId: args.spaceId,
2486
- targetStageId: args.targetStageId ?? null,
2487
- });
2488
- return stageDeletedResult(result.id, result.movedCards, result.stages);
2489
- }
2490
- catch (err) {
2491
- return errorResult(err);
2492
- }
2493
- });
2494
- // -- create_card ----------------------------------------------------------
2495
- server.registerTool('create_card', {
2496
- title: 'Create Card',
2497
- annotations: WRITE,
2498
- description: "Create a card, the container in the content pipeline. A card holds the work (title, notes, cover) and the posts that publish it. Attach posts and media by passing `posts` and `assets` to update_card, then publish with publish_post. ⚠️ WITHOUT spaceId THIS LANDS ON THE ACCOUNT'S DEFAULT BOARD, which is rarely what you want once more than one space exists, so call list_spaces first. `stage` accepts a stage id/slug/name and DECIDES the space when it is an id; a spaceId that disagrees with it is rejected rather than guessed. Requires a key with the planner:write scope.",
2499
- inputSchema: {
2500
- title: z.string().describe('Post title (required).'),
2501
- platform: z.enum(POST_PLATFORMS).describe('Primary platform for the post.'),
2502
- spaceId: z
2503
- .string()
2504
- .optional()
2505
- .describe("Which board to create the card on, from list_spaces. Omit only when you mean the account's default space."),
2506
- stage: z.string().optional().describe('Pipeline stage id, slug, or name. Defaults to the first stage.'),
2507
- coverUrl: z.string().optional().describe('Public URL for the post cover (the card thumbnail).'),
2508
- coverOutputId: z
2509
- .string()
2510
- .optional()
2511
- .describe('A media token (output id, first-8, or "-N") for the cover, resolved to its URL. Use this or coverUrl.'),
2512
- tags: z
2513
- .array(z.string())
2514
- .optional()
2515
- .describe('Tag names to set on the post (must already exist; see list_tags / create_tag). Replaces the set.'),
2516
- },
2517
- }, async (args, extra) => {
2518
- try {
2519
- const client = await getClient(extra);
2520
- return postSummaryResult(await client.createCard({
2521
- title: args.title,
2522
- platform: args.platform,
2523
- spaceId: args.spaceId,
2524
- stage: args.stage,
2525
- coverUrl: args.coverUrl,
2526
- coverOutputId: args.coverOutputId,
2527
- tags: args.tags,
2528
- }), 'Created');
2529
- }
2530
- catch (err) {
2531
- return errorResult(err);
2532
- }
2533
- });
2534
- // -- update_card ----------------------------------------------------------
2535
- server.registerTool('update_card', {
2536
- title: 'Update Card',
2537
- annotations: WRITE,
2538
- description: "Update a card: its fields (title, notes, platform, cover, stage), its POSTS (one per platform, which is how it publishes), its ASSETS (the media on it, in order), and its SCHEDULE. posts and assets are DECLARATIVE: pass the WHOLE set, because anything you leave out is removed. Posts key on platform. Assets key on id, and THE ARRAY ORDER IS THE carousel ORDER, so reordering is just sending the same ids in a different order; keep an existing asset by id, add a new one by assetUrl or outputId. scheduledAt sets the time on the card AND every post (pass null to clear); give a post its own scheduledAt to override it for that platform. To publish NOW, use publish_post. Pass spaceId to MOVE the card to another space; without a stage it lands in the target space's stage whose slug matches its current one, or that space's first stage. Pass cardIds to update several cards at once, which crossed with spaceId is how a selection moves in one call; fields that describe ONE card (title, notes, cover) still need exactly one. WRITING notes REQUIRES expectedRevision: read the card first and pass the revision it reported, or the call is refused. Requires the planner:write scope.",
2539
- inputSchema: {
2540
- cardId: z.string().describe('The card id.'),
2541
- title: z.string().optional().describe('Rename the card. Keep it short: a long title wraps and makes the column unreadable.'),
2542
- platform: z.enum(POST_PLATFORMS).optional().describe("The card's primary platform. This is a label on the card; what actually publishes is its posts."),
2543
- stage: z.string().optional().describe('Move the card to this stage (id, slug, or name).'),
2544
- spaceId: z
2545
- .string()
2546
- .optional()
2547
- .describe("Move the card to a different space (id or slug). Without a stage, it lands in that space's stage whose slug matches its current one, or that space's first stage."),
2548
- cardIds: z
2549
- .array(z.string())
2550
- .optional()
2551
- .describe('Update several cards at once. Fields that describe ONE card (title, notes, cover) still require exactly one.'),
2552
- expectedRevision: z
2553
- .number()
2554
- .int()
2555
- .optional()
2556
- .describe('REQUIRED when writing notes: the revision get_card reported for this card. A card\'s notes have four '
2557
- + 'independent writers, each reading the whole document and writing it back, so without this the last '
2558
- + 'writer silently erases the others and still gets a success. If the card changed since you read it, '
2559
- + 'the call fails with a conflict carrying the current notes and revision: merge onto those and retry '
2560
- + 'with the revision they came with. Never guess it or add one to it, because it only advances when '
2561
- + 'notes actually change.'),
2562
- notes: z.string().optional().describe('Working notes on the card. Plain text or markdown; tables render here. No emojis.'),
2563
- coverUrl: z.string().optional().describe('Public URL for the post cover.'),
2564
- coverOutputId: z
2565
- .string()
2566
- .optional()
2567
- .describe('A media token (output id, first-8, or "-N") for the cover, resolved to its URL.'),
2568
- tags: z
2569
- .array(z.string())
2570
- .optional()
2571
- .describe('Tag names to set on the post (must already exist; replaces the set). Omit to leave tags unchanged.'),
2572
- scheduledAt: z
2573
- .string()
2574
- .nullable()
2575
- .optional()
2576
- .describe('ISO time to publish. Sets the card AND every post. null clears the schedule.'),
2577
- posts: z
2578
- .array(cardPostSchema)
2579
- .optional()
2580
- .describe("The card's posts, each { platform, format?, connectedAccountId?, platformSpecificData?, scheduledAt?, status? }. REPLACES the set, keyed by platform; [] detaches all."),
2581
- assets: z
2582
- .array(postAssetSchema)
2583
- .optional()
2584
- .describe("The post's assets IN ORDER, each { id } to keep an existing one or { assetUrl | outputId, assetType?, displayName? } to add. REPLACES the list; [] clears it."),
2585
- },
2586
- }, async (args, extra) => {
2587
- try {
2588
- const client = await getClient(extra);
2589
- const { cardId, cardIds, posts, assets, ...input } = args;
2590
- // The two declarative arrays are `unknown[]` in the schema (their entries are free-form objects the
2591
- // server validates), so they are cast at this one boundary rather than duplicating the shape in zod.
2592
- const patch = {
2593
- ...input,
2594
- ...(posts !== undefined ? { posts: posts } : {}),
2595
- ...(assets !== undefined ? { assets: assets } : {}),
2596
- };
2597
- // `cardIds` widens the path id, matching update_folder's folderId / folderIds. One card still
2598
- // goes through updateCard so the single-card response shape is unchanged for every caller.
2599
- const targets = cardIds?.length ? cardIds : [cardId];
2600
- if (targets.length > 1) {
2601
- const cards = await client.updateCards(targets, patch);
2602
- return text(`Updated ${cards.length} cards.`);
2603
- }
2604
- return postSummaryResult(await client.updateCard(targets[0], patch), 'Updated');
2605
- }
2606
- catch (err) {
2607
- return errorResult(err);
2608
- }
2609
- });
2610
- // -- list_tags ------------------------------------------------------------
2611
- server.registerTool('list_tags', {
2612
- title: 'List Tags',
2613
- annotations: READ,
2614
- description: "List the account's tags (the organizational tag library). Set a post's tags with the `tags` field on create_card / update_card. A tag is just a lowercase name.",
2615
- inputSchema: {},
2616
- }, async (extra) => {
2617
- try {
2618
- const client = await getClient(extra);
2619
- return tagListResult(await client.listTags());
2620
- }
2621
- catch (err) {
2622
- return errorResult(err);
2623
- }
2624
- });
2625
- // -- create_tag -----------------------------------------------------------
2626
- server.registerTool('create_tag', {
2627
- title: 'Create Tag',
2628
- annotations: WRITE,
2629
- description: "Create a tag in the account's tag library (the name is lowercased). Tags organize posts; apply them with the `tags` field on create_card / update_card. Requires the planner:write scope.",
2630
- inputSchema: {
2631
- name: z.string().describe('The tag name (lowercased on save).'),
2632
- },
2633
- }, async (args, extra) => {
2634
- try {
2635
- const client = await getClient(extra);
2636
- return tagResult(await client.createTag(args.name), 'Created');
2637
- }
2638
- catch (err) {
2639
- return errorResult(err);
2640
- }
2641
- });
2642
- // -- update_tag -----------------------------------------------------------
2643
- server.registerTool('update_tag', {
2644
- title: 'Update Tag',
2645
- annotations: WRITE,
2646
- description: 'Rename a tag (preserves its assignments on all posts). To detach a tag from one post, set that post\'s `tags` without it via update_card. Requires the planner:write scope.',
2647
- inputSchema: {
2648
- tagId: z.string().describe('The tag id (from list_tags).'),
2649
- name: z.string().describe('The new tag name (lowercased on save).'),
2650
- },
2651
- }, async (args, extra) => {
2652
- try {
2653
- const client = await getClient(extra);
2654
- return tagResult(await client.updateTag(args.tagId, args.name), 'Renamed');
2655
- }
2656
- catch (err) {
2657
- return errorResult(err);
2658
- }
2659
- });
2660
- // -- delete_tag -----------------------------------------------------------
2661
- server.registerTool('delete_tag', {
2662
- title: 'Delete Tag',
2663
- annotations: WRITE,
2664
- description: "Delete a tag from the account's library. This DESTROYS the tag and removes it from every post it was on. To just detach a tag from one post, set that post's `tags` without it via update_card instead. Requires the planner:write scope.",
2665
- inputSchema: {
2666
- tagId: z.string().describe('The tag id (from list_tags).'),
2667
- },
2668
- }, async (args, extra) => {
2669
- try {
2670
- const client = await getClient(extra);
2671
- return tagDeletedResult(await client.deleteTag(args.tagId));
2672
- }
2673
- catch (err) {
2674
- return errorResult(err);
2675
- }
2676
- });
2677
- // -- publish_post ---------------------------------------------------------
2678
- server.registerTool('publish_post', {
2679
- title: 'Publish Post',
2680
- annotations: PUBLISH,
2681
- description: "Publish a card's posts NOW: every post on the card, or only the named platform's post when `platform` is given. Each post must have a connected account. Requires a key with the publish:write scope; holding that scope is the account owner's consent to autonomous publishing. Returns one result per post.",
2682
- inputSchema: {
2683
- cardId: z.string().describe('The card id to publish.'),
2684
- platform: z.enum(POST_PLATFORMS).optional().describe('Publish only this platform. Omit to publish all posts.'),
2685
- },
2686
- }, async (args, extra) => {
2687
- try {
2688
- const client = await getClient(extra);
2689
- return publishResult(await client.publishPost(args.cardId, { platform: args.platform }));
2690
- }
2691
- catch (err) {
2692
- return errorResult(err);
2693
- }
2694
- });
2695
- // -- list_accounts --------------------------------------------------------
2696
- server.registerTool('list_accounts', {
2697
- title: 'List Tracked Accounts',
2698
- annotations: READ,
2699
- description: "List the social accounts this ContentHero account tracks. TWO KINDS, in one list: accountType 'inspiration' is the creators and competitors they watch for research, 'brand' is their OWN profiles (distinct from list_brand_kits, which are the brand identity documents). Every row reports its own accountType, so omit the filter to see both. Call get_account for one account's performance, or list_content for the posts. Pass brandKitId to scope to the accounts linked to a specific brand kit.",
2700
- inputSchema: {
2701
- accountType: z
2702
- .enum(['inspiration', 'brand'])
2703
- .optional()
2704
- .describe("Narrow to one kind. Omitted, both come back."),
2705
- brandKitId: z.string().optional().describe('Scope to the accounts linked to this brand kit (from get_brand_kit).'),
2706
- },
2707
- }, async (args, extra) => {
2708
- try {
2709
- const client = await getClient(extra);
2710
- return trackedAccountListResult(await client.listAccounts(args));
2711
- }
2712
- catch (err) {
2713
- return errorResult(err);
2714
- }
2715
- });
2716
- // -- get_account ----------------------------------------------------------
2717
- server.registerTool('get_account', {
2718
- title: 'Get Tracked Account',
2719
- annotations: READ,
2720
- description: "Get one tracked account with how its content actually performs: post count, total and average views/likes/comments, average engagement and outlier score, plus its top posts by outlier score and its most recent ones. Works for either kind of account: use it on one of the owner's OWN accounts to ground decisions in their real numbers, or on a creator they watch to study what works for that creator.",
2721
- inputSchema: {
2722
- accountId: z.string().describe('The account id from list_accounts.'),
2723
- },
2724
- }, async (args, extra) => {
2725
- try {
2726
- const client = await getClient(extra);
2727
- const detail = await client.getAccount(args.accountId);
2728
- return accountDetailResult(detail);
2729
- }
2730
- catch (err) {
2731
- return errorResult(err);
2732
- }
2733
- });
2734
- // -- list_content ---------------------------------------------------------
2735
- server.registerTool('list_content', {
2736
- title: 'List Tracked Content',
2737
- annotations: READ,
2738
- description: "The core research read: social posts this account tracks, ranked by OUTLIER SCORE (how far a post overperformed its own creator's baseline, so a small account's hit still surfaces). SPANS BOTH the creators they watch and their OWN posts by default; set scope to narrow, and every row carries isOwn either way. This is how you answer both \"what is working for the people I watch\" and \"how did my own posts do\" without picking a subsystem first. Filter by platform, content type, a published window (publicationDate like 'week' or 'month', or exact publishedAfter/publishedBefore), and ranges over score, views, duration and follower count. Call get_content for one post in full, including its transcript.",
2739
- inputSchema: {
2740
- scope: z
2741
- .enum(['all', 'inspiration', 'brand'])
2742
- .optional()
2743
- .describe("'inspiration' = creators they watch, 'brand' = their own accounts, 'all' = both (default)."),
2744
- platform: z.enum(['youtube', 'instagram']).optional().describe('Filter to one platform.'),
2745
- contentType: z.string().optional().describe("Filter by content type, e.g. 'video', 'short', 'reel'."),
2746
- outlierScoreMin: z.number().optional().describe('Only content at or above this outlier score.'),
2747
- outlierScoreMax: z.number().optional().describe('Only content at or below this outlier score.'),
2748
- viewsMin: z.number().optional().describe('Only content at or above this view count.'),
2749
- viewsMax: z.number().optional().describe('Only content at or below this view count.'),
2750
- durationMin: z.number().optional().describe('Minimum duration in seconds.'),
2751
- durationMax: z.number().optional().describe('Maximum duration in seconds.'),
2752
- subscribersMin: z.number().optional().describe("Minimum follower count of the post's account."),
2753
- subscribersMax: z.number().optional().describe("Maximum follower count of the post's account."),
2754
- publicationDate: z
2755
- .enum(['week', 'month', '3months', '6months', 'year', '2years'])
2756
- .optional()
2757
- .describe('Published within this window. Use publishedAfter for an exact date instead.'),
2758
- publishedAfter: z.string().optional().describe('ISO timestamp. Wins over publicationDate.'),
2759
- publishedBefore: z.string().optional().describe('ISO timestamp.'),
2760
- search: z.string().optional().describe('Text search across title, creator, handle, and description.'),
2761
- sortBy: z.enum(['score', 'date', 'views', 'engagement']).optional().describe("Sort field (default 'score')."),
2762
- sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort direction (default 'desc')."),
2763
- accountIds: z.array(z.string()).optional().describe('Limit to these tracked account ids (from list_accounts).'),
2764
- addedByYou: z.boolean().optional().describe('Only the one-off posts the owner saved by url.'),
2765
- brandKitId: z.string().optional().describe('Scope to the accounts linked to this brand kit.'),
2766
- favorited: z.boolean().optional().describe('Only content the account has favorited.'),
2767
- limit: z.number().int().min(1).max(100).optional().describe('How many to return (default 20).'),
2768
- offset: z.number().int().min(0).optional().describe('Pagination offset.'),
2769
- },
2770
- }, async (args, extra) => {
2771
- try {
2772
- const client = await getClient(extra);
2773
- return outlierListResult(await client.listContent(args));
2774
- }
2775
- catch (err) {
2776
- return errorResult(err);
2777
- }
2778
- });
2779
- // -- get_content ----------------------------------------------------------
2780
- server.registerTool('get_content', {
2781
- title: 'Get Tracked Content',
2782
- annotations: READ,
2783
- description: "Get one tracked post in full: engagement stats, outlier score, hashtags, keywords, mentions and audio info. Works for a creator's post and for the owner's own. THE TRANSCRIPT IS OPT-IN because a long video is a large document: pass transcript='text' for the whole thing, or transcript='segments' for timed slices, and then narrow with startMs/endMs or transcriptSearch to pull only the part that matters. The transcript reports a status: 'complete', 'not_applicable' (there is nothing to transcribe), 'failed' (it will be retried), 'processing', or 'absent' (never attempted), so an empty result is never ambiguous.",
2784
- inputSchema: {
2785
- contentId: z.string().describe('The content id from list_content or get_account.'),
2786
- transcript: z
2787
- .enum(['none', 'text', 'segments'])
2788
- .optional()
2789
- .describe("How much transcript to include. Default 'none'."),
2790
- startMs: z.number().optional().describe('Window start, ms from the start of the media. Implies segments.'),
2791
- endMs: z.number().optional().describe('Window end, ms from the start of the media. Implies segments.'),
2792
- transcriptSearch: z
2793
- .string()
2794
- .optional()
2795
- .describe('Return only the segments containing this phrase. Implies segments.'),
2796
- },
2797
- }, async (args, extra) => {
2798
- try {
2799
- const client = await getClient(extra);
2800
- const { contentId, ...options } = args;
2801
- return inspirationContentResult(await client.getContent(contentId, options));
2802
- }
2803
- catch (err) {
2804
- return errorResult(err);
2805
- }
2806
- });
2807
- // -- list_connected_accounts ----------------------------------------------
2808
- server.registerTool('list_connected_accounts', {
2809
- title: 'List Connected Accounts',
2810
- annotations: READ,
2811
- description: "List the social accounts the owner has connected (the publish targets), default first. Use an account's id as connectedAccountId on a post in update_card, then publish_post. Read-only: connecting an account is done in the ContentHero app.",
2812
- }, async (extra) => {
2813
- try {
2814
- const client = await getClient(extra);
2815
- return connectedAccountListResult(await client.listConnectedAccounts());
2816
- }
2817
- catch (err) {
2818
- return errorResult(err);
2819
- }
2820
- });
2821
- // -- get_connected_account ------------------------------------------------
2822
- server.registerTool('get_connected_account', {
2823
- title: 'Get Connected Account',
2824
- annotations: READ,
2825
- description: "Get one connected account's detail: platform, status, and capabilities. Use it to confirm a target can publish before attaching it to a post.",
2826
- inputSchema: {
2827
- accountId: z.string().describe('The connected account id from list_connected_accounts.'),
2828
- },
2829
- }, async (args, extra) => {
2830
- try {
2831
- const client = await getClient(extra);
2832
- return connectedAccountResult(await client.getConnectedAccount(args.accountId));
2833
- }
2834
- catch (err) {
2835
- return errorResult(err);
2836
- }
2837
- });
2838
- // -- get_balance ----------------------------------------------------------
2839
- server.registerTool('get_balance', {
2840
- title: 'Get Balance',
2841
- annotations: READ,
2842
- description: 'Get the current ContentHero credit balance, subscription tier, and auto-top-up state.',
2843
- }, async (extra) => {
2844
- try {
2845
- const client = await getClient(extra);
2846
- return balanceResult(await client.getBalance());
2847
- }
2848
- catch (err) {
2849
- return errorResult(err);
2850
- }
2851
- });
2852
- // ===========================================================================
2853
- // Favorites & archive (one universal pair each, across asset types)
2854
- // ===========================================================================
2855
- // -- favorite -------------------------------------------------------------
2856
- server.registerTool('favorite', {
2857
- title: 'Favorite',
2858
- annotations: WRITE,
2859
- description: "Favorite or UNfavorite an asset: pass favorited:false to clear it (default true). For a top-level asset, pass assetType + id (card, voice, brand_kit, project, inspiration_content, gallery, transition, space). To favorite a single studio media variation (one image/video/audio slot from list_media / get_media), pass the output id + variationIndex (1-based) and omit assetType. Requires the favorites:write scope. Idempotent in both directions.",
2860
- inputSchema: {
2861
- assetType: z
2862
- .enum(['card', 'voice', 'brand_kit', 'project', 'inspiration_content', 'gallery', 'transition', 'space'])
2863
- .optional()
2864
- .describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
2865
- id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
2866
- variationIndex: z
2867
- .number()
2868
- .int()
2869
- .min(1)
2870
- .optional()
2871
- .describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
2872
- favorited: z.boolean().optional().describe('Default true. Pass false to UNfavorite.'),
2873
- },
2874
- }, async (args, extra) => {
2875
- try {
2876
- const client = await getClient(extra);
2877
- const favorited = args.favorited ?? true;
2878
- await client.favorite({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex, favorited });
2879
- return statusActionResult(favorited ? 'Favorited' : 'Unfavorited', args);
2880
- }
2881
- catch (err) {
2882
- return errorResult(err);
2883
- }
2884
- });
2885
- // -- unfavorite -----------------------------------------------------------
2886
- // -- archive --------------------------------------------------------------
2887
- server.registerTool('archive', {
2888
- title: 'Archive',
2889
- annotations: WRITE,
2890
- description: "Archive or UNarchive an asset: pass archived:false to restore it (default true). ContentHero never hard-deletes, so this is always reversible. For a top-level asset, pass assetType + id (card, brand_kit, brand_kit_section, project, space). To archive a single studio media variation, pass the output id + variationIndex (1-based) and omit assetType. Archiving is a timestamp and nothing else is touched, so a scheduled card restores as scheduled. Requires the favorites:write scope. Idempotent in both directions.",
2891
- inputSchema: {
2892
- assetType: z
2893
- .enum(['card', 'brand_kit', 'brand_kit_section', 'project', 'space'])
2894
- .optional()
2895
- .describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
2896
- id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
2897
- variationIndex: z
2898
- .number()
2899
- .int()
2900
- .min(1)
2901
- .optional()
2902
- .describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
2903
- archived: z.boolean().optional().describe('Default true. Pass false to RESTORE (unarchive).'),
2904
- },
2905
- }, async (args, extra) => {
2906
- try {
2907
- const client = await getClient(extra);
2908
- const archived = args.archived ?? true;
2909
- await client.archive({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex, archived });
2910
- return statusActionResult(archived ? 'Archived' : 'Unarchived', args);
2911
- }
2912
- catch (err) {
2913
- return errorResult(err);
2914
- }
2915
- });
2916
- // -- unarchive ------------------------------------------------------------
2917
- server.registerTool('list_projects', {
2918
- title: 'List Projects',
2919
- annotations: READ,
2920
- description: "List the account's editor (video timeline) and canvas (slides/layers) projects. Filter by state (archived / favorited), by surface (editor / canvas), or by a title search. Returns lightweight summaries; call get_project for a single project's full composition. Requires the editor:read scope.",
2921
- inputSchema: {
2922
- filter: z.enum(['archived', 'favorited']).optional().describe('archived -> only archived; favorited -> favorited and not archived; omitted -> active (not archived).'),
2923
- surface: z.enum(['editor', 'canvas']).optional().describe('Restrict to one surface; omitted returns both.'),
2924
- kind: z.enum(['editor', 'canvas']).optional().describe('Deprecated alias for `surface`. Prefer `surface`; this is accepted for one release window.'),
2925
- search: z.string().optional().describe('Case-insensitive title search.'),
2926
- },
2927
- }, async (args, extra) => {
2928
- try {
2929
- const client = await getClient(extra);
2930
- return projectListResult(await client.listProjects(args));
2931
- }
2932
- catch (err) {
2933
- return errorResult(err);
2934
- }
2935
- });
2936
- server.registerTool('get_project', {
2937
- title: 'Get Project',
2938
- annotations: READ,
2939
- description: "Read a project's composition + revision. By DEFAULT returns a SUMMARY: metadata + revision + a lightweight per-clip view, dropping heavy payloads (media URLs, transcripts, graphic code, caption words) so a structural read stays small. For a TIMELINE the summary is each clip's { id, type, from, durationInFrames, speed, trackId, disabled, sourceId }, where `sourceId` identifies the SOURCE FILE the clip was cut from: clips sharing a sourceId came from one recording, and a clip id never tells you this (ids are minted per edit, so a shared prefix means a shared operation, not a shared file). Use it to tell a rough cut of one recording apart from a timeline of separate files, which decides how much work a source-wide job is: an audio enhancement runs one vendor production per source. Absent when the clip's media is not one of our stored objects (an external stock url); for a CANVAS it is each slide with its layers' { id, type, text }, where a text layer's `text` is truncated to 80 chars and is what lets you tell one slide (or one of two text layers) from another without a full read. Pass detail:'full' for the complete composition (every prop, for a faithful round-trip or a deep edit). Scope a timeline read to a window with fromFrame/toFrame (and optionally trackId) to get just the clips overlapping that range, the same convention as get_transcript's startMs/endMs; scope a CANVAS read to one slide with slideId, which applies to detail:'full' too, so asking for a single slide's detail does not pay for the whole deck. Pass the returned revision back as expectedRevision for a concurrency-safe edit. Editing does NOT require this call: update_timeline/update_canvas' expectedRevision is optional, and get_transcript already returns the revision. Requires the editor:read scope.",
2940
- inputSchema: {
2941
- projectId: z.string().describe('The project id to read.'),
2942
- detail: z.enum(['summary', 'full']).optional().describe("'summary' (default) returns the lightweight per-clip/per-layer structure; 'full' returns the complete composition with every property."),
2943
- fromFrame: z.number().int().min(0).optional().describe('Timeline only: start of a frame window; returns clips overlapping [fromFrame, toFrame].'),
2944
- toFrame: z.number().int().min(0).optional().describe('Timeline only: end of the frame window (see fromFrame).'),
2945
- trackId: z.string().optional().describe('Timeline only: scope the read to a single track by id.'),
2946
- slideId: z.string().optional().describe('Canvas only: scope the read to a single slide by id. Applies to detail:\'full\' as well. An id matching no slide returns every slide rather than nothing.'),
2947
- includeRenderUrl: z.boolean().optional().describe('Also return a preview still URL of the current composition (renders one only if it changed).'),
2948
- },
2949
- }, async (args, extra) => {
2950
- try {
2951
- const client = await getClient(extra);
2952
- return projectDetailResult(await client.getProject(args.projectId, {
2953
- includeRenderUrl: args.includeRenderUrl,
2954
- detail: args.detail,
2955
- fromFrame: args.fromFrame,
2956
- toFrame: args.toFrame,
2957
- trackId: args.trackId,
2958
- slideId: args.slideId,
2959
- }));
2960
- }
2961
- catch (err) {
2962
- return errorResult(err);
2963
- }
2964
- });
2965
- server.registerTool('get_context', {
2966
- title: 'Get Live Context',
2967
- annotations: READ,
2968
- description: "Read the live context of what the user is currently viewing in the open app: the active surface, the focused element, the playhead, and the current selection, so you act on what the user is looking at rather than guessing. Read this first. It is fast, structured, and does not disturb the live page, and structured context alone is enough whenever the task does not depend on the exact pixels; it returns no image by default. Acquire vision only when the task genuinely requires seeing, and pick the path by what you need to see. Set render=true to see the composed output itself: the actual rendered editor frame or canvas slide, reconstructed from saved data, so you can visually verify your own edits while iterating. Pass frame (editor) or slideId/slideIndex (canvas) to inspect a specific point, or render=true alone for the point the user is viewing. The render returns inline as an image, is ephemeral, leaves nothing in the user's storage, and does not need a live tab. Set capture=true instead only when you need the user's actual screen as shown right now, including transient interface state and unsaved edits; capturing renders the current screen on demand, so its latency and brief page interruption grow with how visually heavy that screen is. Do not use export_project to check your work: exports are permanent deliverables that count against the user's storage; use render for previews. Returns the most-recent-active session and the live participant set, or nothing when no one is viewing (render still works with an explicit projectId). Optionally scope to one project. Requires the context:read scope.",
2969
- inputSchema: {
2970
- 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.'),
2971
- 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."),
2972
- 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.'),
2973
- 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."),
2974
- frame: z.number().int().min(0).optional().describe("mode='image' (editor): which single timeline frame to render. Omit to render the current playhead frame."),
2975
- slideId: z.string().optional().describe("mode='image' (canvas): the id of the slide to render. Omit to render the focused slide."),
2976
- slideIndex: z.number().int().min(1).optional().describe("mode='image' (canvas): the 1-based slide index to render (alternative to slideId)."),
2977
- 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.'),
2978
- toFrame: z.number().int().min(0).optional().describe('End timeline frame of the range. Omit to run to the end.'),
2979
- 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."),
2980
- 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."),
2981
- },
2982
- }, async (args, extra) => {
2983
- try {
2984
- const client = await getClient(extra);
2985
- /*
2986
- ⭐⭐⭐ **`video` IS THE THIRD RUNG OF THIS LADDER, NOT A SEPARATE TOOL.** It used to be
2987
- `create_preview`, and the split was drawn on HOW the render is delivered (a job, not inline)
2988
- rather than on WHAT the caller is asking for. Both answers to "let me look at my own work,
2989
- ephemerally, without producing a deliverable" now live behind one question.
2990
-
2991
- ⛔ THE EVIDENCE THE OLD BOUNDARY WAS WRONG WAS IN THE DESCRIPTIONS. `get_context` ended with
2992
- "for a composed VIDEO of a range use create_preview" and `create_preview` ended with "to see a
2993
- single frame or a few frames use get_context render". Two tools each telling the agent when to
2994
- use the other is routing work the schema should be doing. The CLI had already reached this
2995
- conclusion: it exposes `context preview`, a sibling of `context`, while export lives under
2996
- `project`.
2997
-
2998
- ⚠️ THE TRANSPORT IS UNCHANGED. This is a facade over the same client call the old tool made, so
2999
- nothing moved server-side and the SDK needed no new field.
3000
- */
3001
- if (args.mode === 'video') {
3002
- const job = await client.createPreview({
3003
- projectId: args.projectId ?? '',
3004
- fromFrame: args.fromFrame,
3005
- toFrame: args.toFrame,
3006
- });
3007
- return text(`Preview render started (frames ${job.fromFrame}-${job.toFrame}, ~${job.durationSeconds}s).\n` +
3008
- `Poll get_preview with renderId="${job.renderId}" and bucketName="${job.bucketName}" until status is "done", then fetch the returned url.`);
3009
- }
3010
- const result = await client.getContext({
3011
- projectId: args.projectId,
3012
- capture: args.capture,
3013
- render: args.render,
3014
- mode: args.mode,
3015
- frame: args.frame,
3016
- slideId: args.slideId,
3017
- slideIndex: args.slideIndex,
3018
- fromFrame: args.fromFrame,
3019
- toFrame: args.toFrame,
3020
- count: args.count,
3021
- width: args.width,
3022
- });
3023
- const snapshotUrl = typeof result.context?.snapshotUrl === 'string' ? result.context.snapshotUrl : null;
3024
- const snapshot = snapshotUrl ? await fetchSnapshotBase64(snapshotUrl) : null;
3025
- return liveContextResult(result, snapshot);
3026
- }
3027
- catch (err) {
3028
- return errorResult(err);
3029
- }
3030
- });
3031
- server.registerTool('get_preview', {
3032
- title: 'Get Preview',
3033
- annotations: READ,
3034
- 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.',
3035
- inputSchema: {
3036
- renderId: z.string().describe('The renderId returned by get_context with mode="video".'),
3037
- bucketName: z.string().describe('The bucketName returned by get_context with mode="video".'),
3038
- },
3039
- }, async (args, extra) => {
3040
- try {
3041
- const client = await getClient(extra);
3042
- const s = await client.getPreview({ renderId: args.renderId, bucketName: args.bucketName });
3043
- if (s.status === 'done') {
3044
- return text(`Preview ready. url: ${s.url}${typeof s.estimatedCostUsd === 'number' ? ` (est. cost $${s.estimatedCostUsd.toFixed(4)})` : ''}`);
3045
- }
3046
- if (s.status === 'failed')
3047
- return text(`Preview render failed: ${s.error ?? 'unknown error'}.`, true);
3048
- return text(`Preview still rendering${typeof s.progress === 'number' ? ` (${Math.round(s.progress * 100)}%)` : ''}. Poll again in a few seconds.`);
3049
- }
3050
- catch (err) {
3051
- return errorResult(err);
3052
- }
3053
- });
3054
- server.registerTool('get_layer_types', {
3055
- title: 'Get Layer Types',
3056
- annotations: READ,
3057
- description: 'List the CANVAS layer types (image, text, solid/shape, video, graphic) and their editable props, so you know what update_canvas ops can create and set. Also returns shared prop groups (transform, decoration, adjust). Requires the editor:read scope.',
3058
- inputSchema: {},
3059
- }, async (_args, extra) => {
3060
- try {
3061
- const client = await getClient(extra);
3062
- return layerTypesResult(await client.getLayerTypes());
3063
- }
3064
- catch (err) {
3065
- return errorResult(err);
3066
- }
3067
- });
3068
- server.registerTool('get_timeline_types', {
3069
- title: 'Get Timeline Types',
3070
- annotations: READ,
3071
- description: 'List the EDITOR timeline clip types (video, image, text, solid, audio, graphic) with their editable props, plus the track types (media, audio, text) and what each holds. Each clip type also carries a copy-pasteable `example` clip skeleton, and the catalog carries a `creation` section documenting the CREATE ops (create_clip, insert_track, insert_prebuilt_track) - so this one call tells you both what you can create (and the exact clip shape to pass) and what you can set. Read this before building any clip with update_timeline. Requires the editor:read scope.',
3072
- inputSchema: {},
3073
- }, async (_args, extra) => {
3074
- try {
3075
- const client = await getClient(extra);
3076
- return timelineTypesResult(await client.getTimelineTypes());
3077
- }
3078
- catch (err) {
3079
- return errorResult(err);
3080
- }
3081
- });
3082
- server.registerTool('get_transcript', {
3083
- title: 'Get Transcript',
3084
- annotations: READ,
3085
- description: "Read an EDITOR project's transcript mapped to its timeline clips, so you can do content-aware editing. Returns one segment per transcribable clip in timeline order, each carrying the words spoken within it plus its current state ([disabled] = cut/excluded from the render, [enabled] = kept) and its exact clipId. Use this to read what is said, see which parts are already disabled, then target the exact clipId(s) or source-time ranges with update_timeline (disable_ranges to non-destructively cut, set_disabled to toggle a whole clip). Pass `granularity: 'word'` to also get, per segment: word-level timing with ABSOLUTE timeline frames (so split / range ops are exact), per-word confidence + speaker, the derived silence gaps (for dead-air removal; already inset to the cuttable region so cutting them keeps breathing room), and the non-speech audio events (e.g. \"[chuckles]\"); plus the distinct speaker set at the top level. Word mode can be large, so scope it with `search` (a phrase to find) or `startMs`/`endMs` (a source-media window, which also clips the returned words) to page a long clip. Also returns the project's current `revision` so you can edit right away without a separate get_project: pass it as update_timeline's expectedRevision for a concurrency-safe edit, or omit expectedRevision to just apply to the current state. Returns mediaTranscribed:false when the media has not been transcribed yet. Requires the editor:read scope.",
3086
- inputSchema: {
3087
- projectId: z.string().describe('The editor project id.'),
3088
- search: z.string().optional().describe('Case-insensitive substring; returns only clip segments whose text contains it.'),
3089
- startMs: z.number().int().min(0).optional().describe('Source-media start time in ms; with endMs, returns only segments overlapping this window (and, in word mode, clips the returned words to it).'),
3090
- endMs: z.number().int().min(0).optional().describe('Source-media end time in ms; companion to startMs.'),
3091
- granularity: z.enum(['clip', 'word']).optional().describe("'clip' (default) returns text per clip; 'word' adds word timing + absolute timeline frames + confidence + speaker, derived silences, and audio events."),
3092
- paceThresholdMs: z.number().int().min(0).optional().describe('Word mode: minimum pause (ms) to report as a silence / dead-air region ("Pace"). Defaults to the project\'s saved pace, else 500.'),
3093
- paddingStartMs: z.number().int().optional().describe('Word mode: breathing room (ms) kept after speech at a silence start edge (negative tightens). Defaults to the saved padding, else 200.'),
3094
- paddingEndMs: z.number().int().optional().describe('Word mode: breathing room (ms) kept before speech at a silence end edge (negative tightens). Defaults to the saved padding, else 200.'),
3095
- },
3096
- }, async (args, extra) => {
3097
- try {
3098
- const client = await getClient(extra);
3099
- const { projectId, ...options } = args;
3100
- return editorTranscriptResult(await client.getTranscript(projectId, options));
3101
- }
3102
- catch (err) {
3103
- return errorResult(err);
3104
- }
3105
- });
3106
- server.registerTool('create_project', {
3107
- title: 'Create Project',
3108
- annotations: WRITE,
3109
- description: "Create a new project. `surface` picks where it lives: 'editor' (video timeline) or 'canvas' (slides/layers). All fields are optional; defaults match the in-app new-project flow (16:9 landscape, editor surface). A new canvas starts with one empty slide already, so add content to it with update_canvas create_layer (use create_slide only to add MORE slides); a new editor starts with an empty timeline. Returns the new project id + revision. Requires the editor:write scope.",
3110
- inputSchema: {
3111
- surface: z.enum(['editor', 'canvas']).optional().describe("The surface. Defaults to 'editor'."),
3112
- kind: z.enum(['editor', 'canvas']).optional().describe("Deprecated alias for `surface`. Prefer `surface`; accepted for one release window."),
3113
- title: z.string().optional().describe("Project title. Defaults to 'Untitled'."),
3114
- orientation: z.string().optional().describe("Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to '16:9'."),
3115
- width: z.number().optional().describe('Pixel width. Defaults from the orientation.'),
3116
- height: z.number().optional().describe('Pixel height. Defaults from the orientation.'),
3117
- brandKitId: z.string().optional().describe('Optional brand kit to associate.'),
3118
- },
3119
- }, async (args, extra) => {
3120
- try {
3121
- const client = await getClient(extra);
3122
- return projectCreatedResult(await client.createProject(args));
3123
- }
3124
- catch (err) {
3125
- return errorResult(err);
3126
- }
3127
- });
3128
- server.registerTool('import_project', {
3129
- title: 'Import Project',
3130
- annotations: WRITE,
3131
- description: "Import a PowerPoint / Google Slides file (by URL) or a Canva design (by id) into a NEW canvas project with editable layers. Set sourceType to 'pptx' and pass fileUrl (a URL to a .pptx / slides file), or set sourceType to 'canva' and pass designId (uses the account's Canva connection; fails with canva_not_connected if not linked). Returns the new project id + revision. Requires the editor:write scope.",
3132
- inputSchema: {
3133
- sourceType: z.enum(['pptx', 'canva']).describe("'pptx' for a file URL, 'canva' for a Canva design id."),
3134
- fileUrl: z.string().optional().describe("Required when sourceType is 'pptx': a URL to the .pptx / slides file."),
3135
- designId: z.string().optional().describe("Required when sourceType is 'canva': the Canva design id."),
3136
- title: z.string().optional().describe("Title for the created project. Defaults to 'Imported deck'."),
3137
- },
3138
- }, async (args, extra) => {
3139
- try {
3140
- if (args.sourceType === 'pptx' && !args.fileUrl)
3141
- return errorResult(new Error("fileUrl is required when sourceType is 'pptx'."));
3142
- if (args.sourceType === 'canva' && !args.designId)
3143
- return errorResult(new Error("designId is required when sourceType is 'canva'."));
3144
- const source = args.sourceType === 'pptx'
3145
- ? { type: 'pptx', fileUrl: args.fileUrl }
3146
- : { type: 'canva', designId: args.designId };
3147
- const client = await getClient(extra);
3148
- return projectCreatedResult(await client.importProject({ source, title: args.title }));
3149
- }
3150
- catch (err) {
3151
- return errorResult(err);
3152
- }
3153
- });
3154
- server.registerTool('export_project', {
3155
- title: 'Export Project',
3156
- ...RENDERS_GENERATION,
3157
- annotations: WRITE,
3158
- description: "Export (render) a project's saved composition to a downloadable file the user KEEPS: a permanent deliverable that counts against the user's storage. To preview or verify a frame or slide while editing, do NOT export; use get_context with render (ephemeral, stored nowhere). format 'mp4' works for both editor and canvas (a video render; may take a while). 'png' / 'jpg' work for both surfaces too: a canvas project renders one image per slide (multiple slides come back as a zip), while an editor project renders a single composited frame of the timeline (pick which frame with `frame`; defaults to frame 0). Canvas projects additionally support 'pdf' and 'pptx'. Resolution and watermark apply to EVERY format, not just mp4: a free account never exports above 720p and never removes the watermark, on any format or surface. `quality` is mp4 only. Returns the download URL when the render finishes in time, otherwise an exportId to poll with get_export. Requires the editor:write scope.",
3159
- inputSchema: {
3160
- projectId: z.string().describe('The project to export.'),
3161
- format: z.enum(['mp4', 'png', 'jpg', 'pdf', 'pptx']).optional().describe("Output format. Defaults to 'mp4'. mp4/png/jpg work for both surfaces (png/jpg on an editor project render one timeline frame); pdf/pptx are canvas-only."),
3162
- resolution: z.enum(['480p', '720p', '1080p', '2k', '4k']).optional().describe("Output resolution, for EVERY format including stills. Defaults '720p' for an editor mp4 and the project's NATIVE size for a still or canvas mp4, in both cases clamped to your plan. 1080p and above are plan-gated: NAMING one above your plan is a 403, omitting one clamps instead."),
3163
- quality: z.enum(['low', 'recommended', 'high']).optional().describe('mp4 ONLY: video bitrate. Meaningless for a still, which has no duration to spread bits over. Defaults recommended.'),
3164
- watermark: z.boolean().optional().describe('Keep the watermark. Defaults true; removing it is plan-gated.'),
3165
- frame: z.number().int().min(0).optional().describe('Editor still (png/jpg) only: which timeline frame to render. Clamped to the composition length. Defaults 0. Use the playhead frame from get_context to render exactly the frame the user is viewing.'),
3166
- },
3167
- }, async (args, extra) => {
3168
- try {
3169
- const client = await getClient(extra);
3170
- const { projectId, ...input } = args;
3171
- const job = await client.exportProjectAndWait(projectId, input, { timeoutMs: SMART_WAIT_MS });
3172
- // ⚠️ The FORMAT is what makes an export renderable, and only this handler knows it: `get_export`
3173
- // polls by exportId alone, so a poll legitimately reports rather than displays.
3174
- return completedExportResult(job, input.format ?? 'mp4', client.baseUrl);
3175
- }
3176
- catch (err) {
3177
- if (err instanceof GenerationTimeoutError) {
3178
- return exportJobResult({ exportId: err.outputId, status: 'rendering' });
3179
- }
3180
- return errorResult(err);
3181
- }
3182
- });
3183
- server.registerTool('get_export', {
3184
- title: 'Get Export',
3185
- annotations: READ,
3186
- description: 'Poll an export job started by export_project. Returns its status and, when done, the download URL. Requires the editor:read scope.',
3187
- inputSchema: {
3188
- exportId: z.string().describe('The export id returned by export_project.'),
3189
- },
3190
- }, async (args, extra) => {
3191
- try {
3192
- const client = await getClient(extra);
3193
- return exportJobResult(await client.getExport(args.exportId));
3194
- }
3195
- catch (err) {
3196
- return errorResult(err);
3197
- }
3198
- });
3199
- server.registerTool('get_export_formats', {
3200
- title: 'Get Export Formats',
3201
- annotations: READ,
3202
- description: 'List the export formats (and their options) available per project surface, so you know what export_project accepts. Requires the editor:read scope.',
3203
- inputSchema: {},
3204
- }, async (_args, extra) => {
3205
- try {
3206
- const client = await getClient(extra);
3207
- return exportFormatsResult(await client.getExportFormats());
3208
- }
3209
- catch (err) {
3210
- return errorResult(err);
3211
- }
3212
- });
3213
- server.registerTool('delete_project', {
3214
- title: 'Delete Project',
3215
- annotations: WRITE,
3216
- description: "PERMANENTLY delete a project. This is irreversible: the project, its edit history, and its render exports are destroyed (uploaded media stays in the library). To reversibly hide a project instead, use archive. You must pass confirm: true to proceed. Requires the editor:write scope.",
3217
- inputSchema: {
3218
- projectId: z.string().describe('The project id to permanently delete.'),
3219
- confirm: z.literal(true).describe('Must be true to confirm the irreversible permanent delete.'),
3220
- },
3221
- }, async (args, extra) => {
3222
- try {
3223
- const client = await getClient(extra);
3224
- await client.deleteProject(args.projectId);
3225
- return projectDeletedResult(args.projectId);
3226
- }
3227
- catch (err) {
3228
- return errorResult(err);
3229
- }
3230
- });
3231
- server.registerTool('update_timeline', {
3232
- title: 'Update Timeline',
3233
- annotations: WRITE,
3234
- description: "Apply a batch of ops to an EDITOR (video timeline) project. update_timeline both CREATES and EDITS. EDIT ops act on existing clips: disable_ranges, delete_ranges, merge_clips, move_clip, trim_clip, split, delete_clip, duplicate, set_disabled, set_hidden, set_locked, group, ungroup, update_group, update_clip. CREATE ops add new clips/tracks: create_clip ({ op: 'create_clip', trackId, clip }) appends a clip to a track; insert_track ({ op: 'insert_track', referenceTrackId, position: 'above'|'below', trackType }) adds an empty track; insert_prebuilt_track ({ op: 'insert_prebuilt_track', index, track: { id, name, items: [item], trackType } }) inserts a whole track WITH its clips in one op (index 0 = top overlay) - the one-shot way to drop a graphic/text/shape onto a project without an existing empty track. TRANSITIONS: add_transition ({ op: 'add_transition', trackId, leftClipId, rightClipId, preset, durationFrames?, timing? }) adds a transition at the CUT between two TOUCHING adjacent clips (leftClipId's out-point meets rightClipId's in-point); preset is fade | crossfade | slide-left|right|up|down | wipe-left|right|up|down | flip | iris | clock-wipe; durationFrames defaults to 1s; one transition per cut (re-creating on the same pair replaces it); the result carries createdTransitionId. update_transition ({ op: 'update_transition', transitionId, patch: { preset?, durationFrames?, timing? } }) and remove_transition ({ op: 'remove_transition', transitionId }) edit or remove one (transition ids are on each track.transitions[] in get_project). ANIMATIONS: add_animation ({ op: 'add_animation', clipId, edge: 'in'|'out', preset, durationFrames?, timing? }) gives a single clip an ENTRANCE (edge 'in') or EXIT (edge 'out') animation, the clip animating against emptiness on that edge; preset is fade | slide-left|right|up|down | wipe-left|right|up|down | flip | iris | clock-wipe (single-clip presets, NOT crossfade which blends two clips); durationFrames defaults to 15 (0.5s at 30fps), timing 'linear' (default) or 'spring'; idempotent, a per-clip field keyed by clipId + edge so it creates or updates that edge's animation. remove_animation ({ op: 'remove_animation', clipId, edge: 'in'|'out' }) clears it. Choose add_animation when the motion belongs to ONE clip against emptiness; choose add_transition for a blend BETWEEN two adjacent clips. BACKGROUND REMOVAL: remove_background ({ op: 'remove_background', clipId }) cuts out the background of an IMAGE or VIDEO clip, replacing it with transparency, as an ASYNC job: the result carries a generatingOutputId to poll with get_generation_status, and the clip's media swaps to the transparent cutout when it completes (the original is kept, so it stays restorable). Image removal is FREE; video removal is a PREMIUM metered feature (Champion+, charged per second, 60s cap) and returns an error if the plan or credits are insufficient. Only image/video clips have a background; other clip types return an error. MASKS: to add a CapCut-style shape mask to an IMAGE or VIDEO clip, set its `masks` array via update_clip (or update_clips) - each entry is a ClipMask cutout (shape, normalized position/size, rotation, feather, invert) that keeps only the pixels inside its shape, multiple masks union, and invert:true subtracts; masks are a clip PROPERTY, not an op, so patch them like any other field (a patch REPLACES the whole array; set [] to clear), and read get_timeline_types for the exact ClipMask shape. CAPTIONS: add_captions ({ op: 'add_captions', style?, clipIds? }) generates word-timed captions from the project's transcript, one block per spoken clip on a dedicated caption track - whole-timeline by default, or pass clipIds to scope; `style` is a caption template key (omit for the default); clips without a ready transcript are skipped and reported in the result warnings (media is normally transcribed on ingest); re-running refreshes + restyles existing caption blocks. update_captions ({ op: 'update_captions', style?, patch?, clipIds? }) restyles EXISTING captions - `style` re-resolves a template, `patch` sets caption overlay props directly (e.g. { textColor: '#FFDD00', fontSize: 32 }); pass one or both; it never creates captions where none exist (that is add_captions). remove_captions ({ op: 'remove_captions', clipIds? }) removes captions (all, or a clipIds subset) and drops the caption track if empty. Build the `clip` from get_timeline_types, which returns a copy-pasteable `example` skeleton per clip type plus the `creation` op shapes; mint your own string ids and put overlays on a NON-primary media track so they do not ripple the primary. Each op is an object with an `op` name plus its fields (e.g. { op: 'delete_clip', clipIds: ['clip-id'] } or { op: 'move_clip', clipId: 'clip-id', toFrame: 90, toTrackIndex: 0 }). CONTENT-AWARE EDITING: to cut sections you found in get_transcript, DEFAULT to disable_ranges: { op: 'disable_ranges', clipId, ranges: [{ startMs, endMs }], note? } - it takes SOURCE-media time ranges, splits the clip and marks those ranges disabled (non-destructively excluded from the render but still on the timeline, so the user can review via skip-disabled playback and toggle any back on). Prefer the SILENCE edges get_transcript reports as your cut boundaries (they already include breathing room, so cuts do not clip words or feel abrupt) rather than exact word starts. Pass all of a clip's ranges in ONE disable_ranges op. The optional `note` is shown to the USER, so keep it concise and human and use mm:ss for any times (never raw ms). delete_ranges has the same shape but HARD-deletes (ripple-closes the gap, irreversible) - use it ONLY after the user explicitly approves a permanent delete; otherwise always prefer disable_ranges. set_disabled toggles a WHOLE clip by id. RE-TIMING: a time-based clip's timeline length is DERIVED from its source media and its playback speed, so you never set its `durationInFrames` directly. To retime a clip, set the duration-affecting property in the `update_clip` (or `update_clips`) patch (today that property is `speed`) and the reducer recomputes the clip's length for you: the clip keeps covering the same span of its source, so its timeline length scales inversely with the speed change (2x speed halves its length, 0.5x doubles it). Re-timing then honors each track's positioning in the SAME op: on the magnetic primary track the following clips ripple so that no gap opens and none is left behind, while clips on other (free) tracks, and every clip while the magnetic track is off, keep their absolute positions. So a speed change, whether on one clip or a bulk `update_clips`, lands gap-free in a single call with no per-clip length math on your side. Any `durationInFrames` you pass for such a clip is ignored in favor of the derived value. update_clips ({ op: 'update_clips', clipIds?, groupId?, patch }) applies one patch to a SET of clips at once, the bulk form of update_clip; target an explicit clipIds array OR a whole group via groupId (resolves to its members; clipIds wins if both). Every listed clip takes the same patch, but a duration-affecting property re-times each clip from ITS OWN values (a `speed` change recomputes each clip's length from its own source and speed, per the RE-TIMING rule above), and the magnetic primary track ripples so the whole batch lands gap-free in one op. Use it for a bulk property change (speed, volume, opacity, and so on) instead of many update_clip ops. GROUPS: group ({ op: 'group', clipIds, name? }) links 2+ clips under one shared groupId, stamping a stable 'Group N' ordinal that never renumbers (the result carries groupId + groupOrdinal); optional name labels it. ungroup ({ op: 'ungroup', clipIds }) clears the group. update_group ({ op: 'update_group', groupId, patch: { name } }) renames a group. List groups with their ids / ordinals / names / member clips via get_project's top-level `groups`, then target a whole group with update_group or update_clips { groupId }. merge_clips ({ op: 'merge_clips', clipIds }) rejoins adjacent, same-source, contiguous clips into one (the inverse of split; use it to clean up fragments a range edit leaves behind, or to reverse a cut after re-enabling the disabled pieces). ONE-SHOT CLEANUPS (prefer these over hand-rolling ranges for the common cases): remove_silence ({ op: 'remove_silence', paceThresholdMs?, paddingStartMs?, paddingEndMs? }) detects and disables dead-air pauses across the WHOLE timeline (paceThresholdMs = min pause length to cut, default 500; paddingStartMs/paddingEndMs = breathing room, default 200) - idempotent + re-adjustable, so re-running re-cuts at the new settings; remove_filler_words ({ op: 'remove_filler_words' }) disables high-confidence disfluencies (um/uh/er) across the whole timeline; extract_audio ({ op: 'extract_audio', clipIds? }) splits each video clip's audio onto its own track (whole-timeline, or a clipIds subset). remove_silence + remove_filler_words need a ready transcript; each reports a warning + changes nothing when there is nothing to do (no transcript / no gaps / no fillers / no video). They expand to the same disable_ranges / create_clip primitives, so reach for get_transcript + disable_ranges only for CONTEXTUAL or selective cuts the macros cannot express. expectedRevision is OPTIONAL: omit it to apply to the project's current revision (last-write-wins, fine for single-editor and id-targeted ops), or pass the revision from a prior get_project/get_transcript to fail loudly on a concurrent change instead of clobbering it. You do NOT need to fetch the project just to get the revision. Each successful edit returns the new revision for chaining further edits. Requires the editor:write scope.",
3235
- inputSchema: {
3236
- projectId: z.string().describe('The editor project id.'),
3237
- ops: z.array(z.object({ op: z.string() }).passthrough()).describe('The timeline ops to apply, in order.'),
3238
- userIntent: z.string().describe('A short description of what this edit does (for attribution).'),
3239
- expectedRevision: z
3240
- .number()
3241
- .int()
3242
- .optional()
3243
- .describe('The revision from get_project; rejects with a conflict if a concurrent edit landed.'),
3244
- includeRenderUrl: z.boolean().optional().describe('Also return a preview still URL of the resulting composition.'),
3245
- },
3246
- }, async (args, extra) => {
3247
- try {
3248
- const client = await getClient(extra);
3249
- return editorOpsResult(await client.applyEditorOps({
3250
- projectId: args.projectId,
3251
- ops: args.ops,
3252
- userIntent: args.userIntent,
3253
- expectedRevision: args.expectedRevision,
3254
- includeRenderUrl: args.includeRenderUrl,
3255
- }));
3256
- }
3257
- catch (err) {
3258
- return errorResult(err);
3259
- }
3260
- });
3261
- server.registerTool('update_canvas', {
3262
- title: 'Update Canvas',
3263
- annotations: WRITE,
3264
- description: "Apply a batch of ops to a CANVAS (slides/layers) project. Ops act on layers + slides: create_layer, update_layer, delete_layer, reorder_layer, duplicate_layers, set_layer_hidden, set_layer_locked, group_layers, ungroup_layers, set_layer_as_background, create_slide, update_slide, delete_slide, duplicate_slides, reorder_slides, set_background, and more. BACKGROUND REMOVAL: remove_background ({ op: 'remove_background', layerId }) cuts out the background of an IMAGE or VIDEO layer, replacing it with transparency, as an ASYNC job (the result carries a generatingOutputId to poll with get_generation_status; the layer's media swaps to the transparent cutout when done, the original kept). Image removal is FREE; video removal is PREMIUM + metered (Champion+, per second, 60s cap). Other layer types return an error. Each op is an object with an `op` name plus its fields. expectedRevision is OPTIONAL: omit it to apply to the project's current revision (last-write-wins, fine for a single editor), or pass the revision from a prior get_project to fail loudly on a concurrent change instead of clobbering it. You do NOT need to fetch the project just to get the revision. Each successful edit returns the new revision for chaining further edits. Requires the editor:write scope.",
3265
- inputSchema: {
3266
- projectId: z.string().describe('The canvas project id.'),
3267
- ops: z.array(z.object({ op: z.string() }).passthrough()).describe('The canvas ops to apply, in order.'),
3268
- userIntent: z.string().describe('A short description of what this edit does (for attribution).'),
3269
- expectedRevision: z
3270
- .number()
3271
- .int()
3272
- .optional()
3273
- .describe('The revision from get_project; rejects with a conflict if a concurrent edit landed.'),
3274
- includeRenderUrl: z.boolean().optional().describe('Also return a preview still URL of the resulting composition.'),
3275
- },
3276
- }, async (args, extra) => {
3277
- try {
3278
- const client = await getClient(extra);
3279
- return editorOpsResult(await client.applyEditorOps({
3280
- projectId: args.projectId,
3281
- ops: args.ops,
3282
- userIntent: args.userIntent,
3283
- expectedRevision: args.expectedRevision,
3284
- includeRenderUrl: args.includeRenderUrl,
3285
- }));
3286
- }
3287
- catch (err) {
3288
- return errorResult(err);
3289
- }
3290
- });
3291
- }
3292
- /**
3293
- * Our own version, baked in at build time.
3294
- *
3295
- * ⛔ This used to parse `../package.json` relative to `import.meta.url` inside a try/catch returning
3296
- * '0.0.0'. Same defect as the widget read and the same blast radius: under a bundler the path does not
3297
- * exist, so the hosted server reported 0.0.0 and the catch made it silent. A version is a build-time fact,
3298
- * so it is now generated alongside the widget and there is no path resolution left in this package to get
3299
- * wrong.
3300
- */
3301
- function readVersion() {
3302
- return PACKAGE_VERSION;
3303
- }
3304
- /**
3305
- * Build a stdio-style server bound to a single env-configured client. The model
3306
- * enums are resolved live from the discovery catalog (the client has a key).
3307
- */
3308
- export async function buildServer(options = {}) {
3309
- const getClient = options.getClient ?? defaultGetClient;
3310
- const models = await resolveModelEnums(getClient);
3311
- const server = new McpServer({ name: 'contenthero', version: readVersion() });
3312
- registerTools(server, { getClient: () => getClient(), models });
3313
- return server;
3314
- }
3315
- //# sourceMappingURL=server.js.map