@contenthero/mcp 0.3.2 → 0.3.3
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/client.d.ts +18 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +27 -7
- package/dist/client.js.map +1 -1
- package/dist/format.d.ts +74 -5
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +456 -13
- package/dist/format.js.map +1 -1
- package/dist/models.d.ts +5 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +17 -0
- package/dist/models.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +954 -87
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* list_voices / get_voice - the account's saved voices
|
|
13
13
|
* list_brand_kits / get_brand_kit - the account's brand kits (full brand context)
|
|
14
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)
|
|
15
16
|
* get_generation_status - poll an image/video outputId to its final URLs
|
|
16
17
|
* wait_for_generation - block until one or more outputIds finish (batch)
|
|
17
18
|
* get_balance - credit balance + tier
|
|
@@ -29,12 +30,15 @@
|
|
|
29
30
|
* (generate / upscale / lip-sync) gets a tool whose schema only carries its own
|
|
30
31
|
* fields, and per-tool modelId enums prevent cross-type model misuse.
|
|
31
32
|
*/
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
32
36
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
33
37
|
import { z } from 'zod';
|
|
34
|
-
import { GenerationTimeoutError, } from '@contenthero/sdk';
|
|
38
|
+
import { GenerationTimeoutError, pendingOutputId, } from '@contenthero/sdk';
|
|
35
39
|
import { getClient as defaultGetClient } from './client.js';
|
|
36
|
-
import { resolveModelEnums, BOARD_TYPES, BOARD_TYPE_GUIDANCE, IMAGE_MODEL_GUIDANCE, VIDEO_MODEL_GUIDANCE, AUDIO_MODEL_GUIDANCE, UPSCALE_MODEL_GUIDANCE, LIP_SYNC_MODEL_GUIDANCE, } from './models.js';
|
|
37
|
-
import { assetResult, audioResult, avatarListResult, avatarResult, balanceResult,
|
|
40
|
+
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';
|
|
41
|
+
import { assetResult, audioResult, avatarListResult, avatarResult, balanceResult, brandKitListResult, brandKitResult, brandKitSectionResult, brandKnowledgeListResult, brandKnowledgeDetailResult, brandKnowledgeSearchResult, brandKnowledgeItemResult, brandPerformanceResult, completedResult, connectedAccountListResult, connectedAccountResult, costResult, destinationResult, inspirationAccountResult, inspirationContentResult, mediaListResult, mediaSearchResult, folderListResult, folderContentsResult, mediaBatchResult, mediaUploadResult, uploadedMediaResult, assetOrderResult, assetRemovedResult, destinationRemovedResult, tagListResult, tagResult, tagDeletedResult, modelListResult, modelResult, platformListResult, platformResult, elementListResult, elementResult, elementDeletedResult, errorResult, generationBatchResult, generationStatusResult, outlierListResult, enhanceClipsResult, pendingResult, pipelineStageListResult, postListResult, postResult, postSummaryResult, publishResult, statusActionResult, editorOpsResult, text, projectDetailResult, liveContextResult, projectListResult, projectCreatedResult, projectDeletedResult, layerTypesResult, timelineTypesResult, editorTranscriptResult, exportJobResult, exportFormatsResult, trackedAccountListResult, transcriptResult, voiceListResult, voiceResult, } from './format.js';
|
|
38
42
|
/** Platforms a post or destination may target. */
|
|
39
43
|
const POST_PLATFORMS = [
|
|
40
44
|
'youtube',
|
|
@@ -62,6 +66,134 @@ const SMART_WAIT_MS = 50_000;
|
|
|
62
66
|
const READ = { readOnlyHint: true };
|
|
63
67
|
const WRITE = { readOnlyHint: false };
|
|
64
68
|
const PUBLISH = { readOnlyHint: false, destructiveHint: true };
|
|
69
|
+
/**
|
|
70
|
+
* Placement intent for the generative tools' optional one-call timeline placement. Mirrors the SDK
|
|
71
|
+
* `PlacementIntent` union. All positional fields are in SECONDS (resolved to frames server-side via the
|
|
72
|
+
* project fps). Shared across the generative tools as they gain `projectId` placement.
|
|
73
|
+
*/
|
|
74
|
+
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.";
|
|
75
|
+
const TIMELINE_PLACEMENT_SCHEMA = z.discriminatedUnion('mode', [
|
|
76
|
+
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.'),
|
|
77
|
+
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.'),
|
|
78
|
+
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.'),
|
|
79
|
+
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.'),
|
|
80
|
+
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.'),
|
|
81
|
+
]);
|
|
82
|
+
/**
|
|
83
|
+
* CANVAS placement (6A A8): places the generated asset as a LAYER on a slide. Flat (no mode); the server reads it
|
|
84
|
+
* only for a canvas-design project. All fields optional. Positions/sizes are design pixels; the response returns
|
|
85
|
+
* the created layerId + resolvedSlideId so you can chain further ops (animate / reposition / reorder).
|
|
86
|
+
*/
|
|
87
|
+
const CANVAS_PLACEMENT_SCHEMA = z.object({
|
|
88
|
+
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.'),
|
|
89
|
+
slideIndex: z.number().int().min(1).optional().describe('1-based slide number, an alternative to slideId.'),
|
|
90
|
+
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."),
|
|
91
|
+
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).'),
|
|
92
|
+
x: z.number().optional().describe('Fine position offset in design pixels from the anchor (from slide center when no anchor is given).'),
|
|
93
|
+
y: z.number().optional().describe('Fine position offset in design pixels from the anchor.'),
|
|
94
|
+
width: z.number().optional().describe('Explicit layer width in design pixels; the precise escape hatch that overrides fit.'),
|
|
95
|
+
height: z.number().optional().describe('Explicit layer height in design pixels; overrides fit.'),
|
|
96
|
+
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)."),
|
|
97
|
+
}).describe('Canvas placement: place the asset as a layer on a slide (or as the slide background with asBackground).');
|
|
98
|
+
const PLACEMENT_SCHEMA = z.union([TIMELINE_PLACEMENT_SCHEMA, CANVAS_PLACEMENT_SCHEMA]);
|
|
99
|
+
/** The optional one-call placement input fields, shared across the generative tools that gain projectId. */
|
|
100
|
+
const PLACEMENT_INPUT_FIELDS = {
|
|
101
|
+
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).'),
|
|
102
|
+
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).'),
|
|
103
|
+
playheadFrame: z.number().optional().describe('The current playhead frame, for playhead-relative timeline placement.'),
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Fetch a get_context snapshot signed URL and base64-encode it, so get_context can return an IMAGE content
|
|
107
|
+
* block the calling model actually sees. Best-effort: any failure returns null and the tool still returns the
|
|
108
|
+
* textual context. The signed URL is self-authorizing (no secret needed here).
|
|
109
|
+
*/
|
|
110
|
+
async function fetchSnapshotBase64(url) {
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(url);
|
|
113
|
+
if (!res.ok)
|
|
114
|
+
return null;
|
|
115
|
+
const mimeType = res.headers.get('content-type') || 'image/webp';
|
|
116
|
+
const data = Buffer.from(await res.arrayBuffer()).toString('base64');
|
|
117
|
+
return { data, mimeType };
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* True when an image URL is safe to fetch into an image block. SSRF allowlist:
|
|
125
|
+
* our storage hosts plus the finite set of generation-provider CDNs that our
|
|
126
|
+
* finalize pipeline stores as video posters (fal, cloudinary). The values fed
|
|
127
|
+
* here are server-produced (a resolved variation url or a DB-stored thumbnail),
|
|
128
|
+
* not raw caller input (the API already allowlists raw caller urls more strictly).
|
|
129
|
+
*/
|
|
130
|
+
function isAllowedImageHost(url) {
|
|
131
|
+
try {
|
|
132
|
+
const u = new URL(url);
|
|
133
|
+
if (u.protocol !== 'https:')
|
|
134
|
+
return false;
|
|
135
|
+
if (u.username || u.password)
|
|
136
|
+
return false;
|
|
137
|
+
return (u.host === 'cloud.contenthero.ai' ||
|
|
138
|
+
u.host.endsWith('.supabase.co') ||
|
|
139
|
+
u.host.endsWith('.fal.media') ||
|
|
140
|
+
u.host.endsWith('.cloudinary.com'));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The optimized `.preview.webp` sibling of a studio-outputs image object, or the
|
|
148
|
+
* url unchanged. Mirrors the app's previewImageSrc convention so we can prefer
|
|
149
|
+
* the light derivative when it exists (and fall back to the raw when it does not,
|
|
150
|
+
* e.g. an uploaded image or a not-yet-optimized video thumbnail). Pure string rule.
|
|
151
|
+
*/
|
|
152
|
+
function optimizedImageSibling(url) {
|
|
153
|
+
if (!url.includes('/object/public/studio-outputs/'))
|
|
154
|
+
return url;
|
|
155
|
+
if (url.includes('.preview.webp'))
|
|
156
|
+
return url;
|
|
157
|
+
const qIdx = url.indexOf('?');
|
|
158
|
+
const path = qIdx < 0 ? url : url.slice(0, qIdx);
|
|
159
|
+
const query = qIdx < 0 ? '' : url.slice(qIdx + 1);
|
|
160
|
+
const rewritten = path.replace(/\.(png|jpe?g|webp|gif|avif|tiff?)$/i, '.preview.webp');
|
|
161
|
+
if (rewritten === path)
|
|
162
|
+
return url;
|
|
163
|
+
return query ? `${rewritten}?${query}` : rewritten;
|
|
164
|
+
}
|
|
165
|
+
async function fetchImageBytes(url) {
|
|
166
|
+
if (!isAllowedImageHost(url))
|
|
167
|
+
return null;
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch(url);
|
|
170
|
+
if (!res.ok)
|
|
171
|
+
return null;
|
|
172
|
+
const mimeType = res.headers.get('content-type') || 'image/jpeg';
|
|
173
|
+
if (!mimeType.startsWith('image/'))
|
|
174
|
+
return null;
|
|
175
|
+
const data = Buffer.from(await res.arrayBuffer()).toString('base64');
|
|
176
|
+
return { data, mimeType };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Fetch a still image URL for an image content block, preferring the optimized
|
|
184
|
+
* `.preview.webp` sibling and falling back to the raw url if that is missing. This
|
|
185
|
+
* auto-upgrades as the optimization pipeline backfills derivatives, with no code
|
|
186
|
+
* change here. Best-effort: any failure returns null and the item stays text-only.
|
|
187
|
+
*/
|
|
188
|
+
async function fetchMediaImageBase64(url) {
|
|
189
|
+
const optimized = optimizedImageSibling(url);
|
|
190
|
+
if (optimized !== url) {
|
|
191
|
+
const hit = await fetchImageBytes(optimized);
|
|
192
|
+
if (hit)
|
|
193
|
+
return hit;
|
|
194
|
+
}
|
|
195
|
+
return fetchImageBytes(url);
|
|
196
|
+
}
|
|
65
197
|
/** Drop undefined values so the request payload stays minimal. */
|
|
66
198
|
function compact(obj) {
|
|
67
199
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
@@ -80,7 +212,7 @@ export function registerTools(server, opts) {
|
|
|
80
212
|
server.registerTool('generate_image', {
|
|
81
213
|
title: 'Generate Image',
|
|
82
214
|
annotations: WRITE,
|
|
83
|
-
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.',
|
|
215
|
+
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.',
|
|
84
216
|
inputSchema: {
|
|
85
217
|
modelId: z.enum(models.image).describe(IMAGE_MODEL_GUIDANCE),
|
|
86
218
|
prompt: z
|
|
@@ -99,6 +231,7 @@ export function registerTools(server, opts) {
|
|
|
99
231
|
.array(z.string())
|
|
100
232
|
.optional()
|
|
101
233
|
.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.'),
|
|
234
|
+
...PLACEMENT_INPUT_FIELDS,
|
|
102
235
|
getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
|
|
103
236
|
},
|
|
104
237
|
}, async (args, extra) => {
|
|
@@ -114,6 +247,9 @@ export function registerTools(server, opts) {
|
|
|
114
247
|
seed: args.seed,
|
|
115
248
|
references: buildReferences({ images: args.referenceImages }),
|
|
116
249
|
parameters: args.mode ? { mode: args.mode } : undefined,
|
|
250
|
+
projectId: args.projectId,
|
|
251
|
+
placement: args.placement,
|
|
252
|
+
playheadFrame: args.playheadFrame,
|
|
117
253
|
});
|
|
118
254
|
if (args.getCost)
|
|
119
255
|
return costResult(await client.estimateCost(request));
|
|
@@ -121,8 +257,12 @@ export function registerTools(server, opts) {
|
|
|
121
257
|
return completedResult(gen);
|
|
122
258
|
}
|
|
123
259
|
catch (err) {
|
|
124
|
-
|
|
125
|
-
|
|
260
|
+
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
261
|
+
// poll hit a transient error, returning the outputId lets the caller resume;
|
|
262
|
+
// dropping it invites a retry that generates and charges a second time.
|
|
263
|
+
const pending = pendingOutputId(err);
|
|
264
|
+
if (pending)
|
|
265
|
+
return pendingResult(pending);
|
|
126
266
|
return errorResult(err);
|
|
127
267
|
}
|
|
128
268
|
});
|
|
@@ -167,8 +307,12 @@ export function registerTools(server, opts) {
|
|
|
167
307
|
return completedResult(gen);
|
|
168
308
|
}
|
|
169
309
|
catch (err) {
|
|
170
|
-
|
|
171
|
-
|
|
310
|
+
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
311
|
+
// poll hit a transient error, returning the outputId lets the caller resume;
|
|
312
|
+
// dropping it invites a retry that generates and charges a second time.
|
|
313
|
+
const pending = pendingOutputId(err);
|
|
314
|
+
if (pending)
|
|
315
|
+
return pendingResult(pending);
|
|
172
316
|
return errorResult(err);
|
|
173
317
|
}
|
|
174
318
|
});
|
|
@@ -176,7 +320,7 @@ export function registerTools(server, opts) {
|
|
|
176
320
|
server.registerTool('generate_video', {
|
|
177
321
|
title: 'Generate Video',
|
|
178
322
|
annotations: WRITE,
|
|
179
|
-
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.',
|
|
323
|
+
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.',
|
|
180
324
|
inputSchema: {
|
|
181
325
|
modelId: z.enum(models.video).describe(VIDEO_MODEL_GUIDANCE),
|
|
182
326
|
prompt: z
|
|
@@ -221,6 +365,7 @@ export function registerTools(server, opts) {
|
|
|
221
365
|
.array(z.object({ prompt: z.string(), duration: z.number() }))
|
|
222
366
|
.optional()
|
|
223
367
|
.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.'),
|
|
368
|
+
...PLACEMENT_INPUT_FIELDS,
|
|
224
369
|
getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
|
|
225
370
|
},
|
|
226
371
|
}, async (args, extra) => {
|
|
@@ -253,6 +398,9 @@ export function registerTools(server, opts) {
|
|
|
253
398
|
audio: args.referenceAudio,
|
|
254
399
|
elements: args.elements,
|
|
255
400
|
}),
|
|
401
|
+
projectId: args.projectId,
|
|
402
|
+
placement: args.placement,
|
|
403
|
+
playheadFrame: args.playheadFrame,
|
|
256
404
|
});
|
|
257
405
|
if (args.getCost)
|
|
258
406
|
return costResult(await client.estimateCost(request));
|
|
@@ -260,8 +408,12 @@ export function registerTools(server, opts) {
|
|
|
260
408
|
return completedResult(gen);
|
|
261
409
|
}
|
|
262
410
|
catch (err) {
|
|
263
|
-
|
|
264
|
-
|
|
411
|
+
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
412
|
+
// poll hit a transient error, returning the outputId lets the caller resume;
|
|
413
|
+
// dropping it invites a retry that generates and charges a second time.
|
|
414
|
+
const pending = pendingOutputId(err);
|
|
415
|
+
if (pending)
|
|
416
|
+
return pendingResult(pending);
|
|
265
417
|
return errorResult(err);
|
|
266
418
|
}
|
|
267
419
|
});
|
|
@@ -269,7 +421,7 @@ export function registerTools(server, opts) {
|
|
|
269
421
|
server.registerTool('generate_audio', {
|
|
270
422
|
title: 'Generate Audio',
|
|
271
423
|
annotations: WRITE,
|
|
272
|
-
description: 'Generate audio with ElevenLabs: speech (TTS), music, or a sound effect. Returns the audio URL directly (synchronous, no polling).',
|
|
424
|
+
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.',
|
|
273
425
|
inputSchema: {
|
|
274
426
|
modelId: z.enum(models.audio).describe(AUDIO_MODEL_GUIDANCE),
|
|
275
427
|
prompt: z.string().optional().describe('For music / sfx: what to generate.'),
|
|
@@ -283,6 +435,7 @@ export function registerTools(server, opts) {
|
|
|
283
435
|
.max(1)
|
|
284
436
|
.optional()
|
|
285
437
|
.describe('For sfx: how literally to follow the prompt (0 to 1).'),
|
|
438
|
+
...PLACEMENT_INPUT_FIELDS,
|
|
286
439
|
getCost: z.boolean().optional().describe('Return the credit cost estimate instead of generating (nothing runs, nothing is charged).'),
|
|
287
440
|
},
|
|
288
441
|
}, async (args, extra) => {
|
|
@@ -297,6 +450,9 @@ export function registerTools(server, opts) {
|
|
|
297
450
|
voiceName: args.voiceName,
|
|
298
451
|
durationSeconds: args.durationSeconds,
|
|
299
452
|
promptInfluence: args.promptInfluence,
|
|
453
|
+
projectId: args.projectId,
|
|
454
|
+
placement: args.placement,
|
|
455
|
+
playheadFrame: args.playheadFrame,
|
|
300
456
|
});
|
|
301
457
|
if (args.getCost)
|
|
302
458
|
return costResult(await client.estimateCost(request));
|
|
@@ -307,6 +463,63 @@ export function registerTools(server, opts) {
|
|
|
307
463
|
return errorResult(err);
|
|
308
464
|
}
|
|
309
465
|
});
|
|
466
|
+
// -- edit_audio (existing audio -> audio) ---------------------------------
|
|
467
|
+
server.registerTool('edit_audio', {
|
|
468
|
+
title: 'Edit Audio',
|
|
469
|
+
annotations: WRITE,
|
|
470
|
+
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.',
|
|
471
|
+
inputSchema: {
|
|
472
|
+
modelId: z.enum(models.editAudio).describe(EDIT_AUDIO_MODEL_GUIDANCE),
|
|
473
|
+
sourceUrl: z
|
|
474
|
+
.string()
|
|
475
|
+
.optional()
|
|
476
|
+
.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.'),
|
|
477
|
+
durationSeconds: z
|
|
478
|
+
.number()
|
|
479
|
+
.optional()
|
|
480
|
+
.describe('Source audio length in seconds. Required for getCost, and for enhancement pricing when the source is not a stored ContentHero asset.'),
|
|
481
|
+
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.'),
|
|
482
|
+
clipIds: z
|
|
483
|
+
.array(z.string())
|
|
484
|
+
.optional()
|
|
485
|
+
.describe('IN-PLACE mode: enhance the audio of these clips on projectId. Omit with enhanceClips:true to enhance every audible clip on the timeline.'),
|
|
486
|
+
enhanceClips: z
|
|
487
|
+
.boolean()
|
|
488
|
+
.optional()
|
|
489
|
+
.describe('IN-PLACE mode for the whole timeline, without naming clips. Implied when clipIds is given.'),
|
|
490
|
+
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.'),
|
|
491
|
+
playheadFrame: z.number().optional().describe('The current playhead frame, for playhead-relative placement.'),
|
|
492
|
+
getCost: z.boolean().optional().describe('Return the credit cost estimate instead of running (nothing runs, nothing is charged).'),
|
|
493
|
+
},
|
|
494
|
+
}, async (args, extra) => {
|
|
495
|
+
try {
|
|
496
|
+
const client = await getClient(extra);
|
|
497
|
+
const request = compact({
|
|
498
|
+
modelId: args.modelId,
|
|
499
|
+
sourceUrl: args.sourceUrl,
|
|
500
|
+
durationSeconds: args.durationSeconds,
|
|
501
|
+
projectId: args.projectId,
|
|
502
|
+
placement: args.placement,
|
|
503
|
+
playheadFrame: args.playheadFrame,
|
|
504
|
+
clipIds: args.clipIds,
|
|
505
|
+
enhanceClips: args.enhanceClips,
|
|
506
|
+
});
|
|
507
|
+
if (args.getCost)
|
|
508
|
+
return costResult(await client.estimateEditAudioCost(request));
|
|
509
|
+
const result = await client.editAudio(request);
|
|
510
|
+
// IN-PLACE mode returns one job per SOURCE, so the agent is handed every outputId rather than just the
|
|
511
|
+
// first: polling only `outputId` would report the whole edit as done when one recording had finished.
|
|
512
|
+
if (result.outputs)
|
|
513
|
+
return enhanceClipsResult(result);
|
|
514
|
+
// Enhancement is async (status 'processing'); isolation returns URLs inline.
|
|
515
|
+
if (result.status === 'processing')
|
|
516
|
+
return pendingResult(result.outputId);
|
|
517
|
+
return audioResult(result);
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
return errorResult(err);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
310
523
|
// -- upscale --------------------------------------------------------------
|
|
311
524
|
server.registerTool('upscale', {
|
|
312
525
|
title: 'Upscale',
|
|
@@ -339,8 +552,12 @@ export function registerTools(server, opts) {
|
|
|
339
552
|
return completedResult(gen);
|
|
340
553
|
}
|
|
341
554
|
catch (err) {
|
|
342
|
-
|
|
343
|
-
|
|
555
|
+
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
556
|
+
// poll hit a transient error, returning the outputId lets the caller resume;
|
|
557
|
+
// dropping it invites a retry that generates and charges a second time.
|
|
558
|
+
const pending = pendingOutputId(err);
|
|
559
|
+
if (pending)
|
|
560
|
+
return pendingResult(pending);
|
|
344
561
|
return errorResult(err);
|
|
345
562
|
}
|
|
346
563
|
});
|
|
@@ -396,8 +613,12 @@ export function registerTools(server, opts) {
|
|
|
396
613
|
return completedResult(gen);
|
|
397
614
|
}
|
|
398
615
|
catch (err) {
|
|
399
|
-
|
|
400
|
-
|
|
616
|
+
// A SUBMITTED generation is running and charged. Whether the wait timed out or a
|
|
617
|
+
// poll hit a transient error, returning the outputId lets the caller resume;
|
|
618
|
+
// dropping it invites a retry that generates and charges a second time.
|
|
619
|
+
const pending = pendingOutputId(err);
|
|
620
|
+
if (pending)
|
|
621
|
+
return pendingResult(pending);
|
|
401
622
|
return errorResult(err);
|
|
402
623
|
}
|
|
403
624
|
});
|
|
@@ -405,7 +626,7 @@ export function registerTools(server, opts) {
|
|
|
405
626
|
server.registerTool('transcribe', {
|
|
406
627
|
title: 'Transcribe Audio',
|
|
407
628
|
annotations: READ,
|
|
408
|
-
description: 'Transcribe an audio URL to text (speech-to-text). Returns the transcript directly (synchronous,
|
|
629
|
+
description: 'Transcribe an audio URL to text (speech-to-text). Returns the transcript directly (synchronous, no polling). Metered per minute of audio, so the result reports the credits it cost.',
|
|
409
630
|
inputSchema: {
|
|
410
631
|
audioUrl: z.string().describe('Public URL of the audio file to transcribe.'),
|
|
411
632
|
languageCode: z
|
|
@@ -463,11 +684,14 @@ export function registerTools(server, opts) {
|
|
|
463
684
|
server.registerTool('list_voices', {
|
|
464
685
|
title: 'List Voices',
|
|
465
686
|
annotations: READ,
|
|
466
|
-
description: "List the account's saved voices (favorites first). Each has a voiceId for generate_lip_sync / generate_audio (TTS). Call get_voice for full detail.",
|
|
467
|
-
|
|
687
|
+
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.",
|
|
688
|
+
inputSchema: {
|
|
689
|
+
favorited: z.boolean().optional().describe('Only favorited voices.'),
|
|
690
|
+
},
|
|
691
|
+
}, async (args, extra) => {
|
|
468
692
|
try {
|
|
469
693
|
const client = await getClient(extra);
|
|
470
|
-
return voiceListResult(await client.listVoices());
|
|
694
|
+
return voiceListResult(await client.listVoices({ favorited: args.favorited }));
|
|
471
695
|
}
|
|
472
696
|
catch (err) {
|
|
473
697
|
return errorResult(err);
|
|
@@ -494,11 +718,15 @@ export function registerTools(server, opts) {
|
|
|
494
718
|
server.registerTool('list_brand_kits', {
|
|
495
719
|
title: 'List Brand Kits',
|
|
496
720
|
annotations: READ,
|
|
497
|
-
description: "List the account's brand kits (default first). Call get_brand_kit for one kit's full brand context (voice, visual identity, audience, sections, accounts, knowledge) to write on-brand content.",
|
|
498
|
-
|
|
721
|
+
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.",
|
|
722
|
+
inputSchema: {
|
|
723
|
+
favorited: z.boolean().optional().describe('Only favorited brand kits.'),
|
|
724
|
+
archived: z.boolean().optional().describe('Only archived brand kits (default excludes archived).'),
|
|
725
|
+
},
|
|
726
|
+
}, async (args, extra) => {
|
|
499
727
|
try {
|
|
500
728
|
const client = await getClient(extra);
|
|
501
|
-
return brandKitListResult(await client.listBrandKits());
|
|
729
|
+
return brandKitListResult(await client.listBrandKits({ favorited: args.favorited, archived: args.archived }));
|
|
502
730
|
}
|
|
503
731
|
catch (err) {
|
|
504
732
|
return errorResult(err);
|
|
@@ -550,23 +778,6 @@ export function registerTools(server, opts) {
|
|
|
550
778
|
return errorResult(err);
|
|
551
779
|
}
|
|
552
780
|
});
|
|
553
|
-
// -- archive_brand_kit ----------------------------------------------------
|
|
554
|
-
server.registerTool('archive_brand_kit', {
|
|
555
|
-
title: 'Archive Brand Kit',
|
|
556
|
-
annotations: WRITE,
|
|
557
|
-
description: 'Archive a brand kit (reversible; ContentHero never hard-deletes). Requires the brandkit:write scope.',
|
|
558
|
-
inputSchema: {
|
|
559
|
-
brandKitId: z.string().describe('The brand kit id to archive.'),
|
|
560
|
-
},
|
|
561
|
-
}, async (args, extra) => {
|
|
562
|
-
try {
|
|
563
|
-
const client = await getClient(extra);
|
|
564
|
-
return brandKitArchivedResult(await client.archiveBrandKit(args.brandKitId));
|
|
565
|
-
}
|
|
566
|
-
catch (err) {
|
|
567
|
-
return errorResult(err);
|
|
568
|
-
}
|
|
569
|
-
});
|
|
570
781
|
// -- add_brand_kit_section ------------------------------------------------
|
|
571
782
|
server.registerTool('add_brand_kit_section', {
|
|
572
783
|
title: 'Add Brand Kit Section',
|
|
@@ -618,24 +829,6 @@ export function registerTools(server, opts) {
|
|
|
618
829
|
return errorResult(err);
|
|
619
830
|
}
|
|
620
831
|
});
|
|
621
|
-
// -- archive_brand_kit_section --------------------------------------------
|
|
622
|
-
server.registerTool('archive_brand_kit_section', {
|
|
623
|
-
title: 'Archive Brand Kit Section',
|
|
624
|
-
annotations: WRITE,
|
|
625
|
-
description: 'Archive a brand-kit section (soft delete, reversible). Use it to remove a section an agent added. Requires the brandkit:write scope.',
|
|
626
|
-
inputSchema: {
|
|
627
|
-
brandKitId: z.string().describe('The brand kit id.'),
|
|
628
|
-
sectionId: z.string().describe('The section id to archive.'),
|
|
629
|
-
},
|
|
630
|
-
}, async (args, extra) => {
|
|
631
|
-
try {
|
|
632
|
-
const client = await getClient(extra);
|
|
633
|
-
return brandKitSectionResult(await client.archiveBrandKitSection(args.brandKitId, args.sectionId), 'Archived section');
|
|
634
|
-
}
|
|
635
|
-
catch (err) {
|
|
636
|
-
return errorResult(err);
|
|
637
|
-
}
|
|
638
|
-
});
|
|
639
832
|
// -- search_brand_knowledge -----------------------------------------------
|
|
640
833
|
server.registerTool('search_brand_knowledge', {
|
|
641
834
|
title: 'Search Brand Knowledge',
|
|
@@ -751,8 +944,12 @@ export function registerTools(server, opts) {
|
|
|
751
944
|
server.registerTool('list_media', {
|
|
752
945
|
title: 'List Media',
|
|
753
946
|
annotations: READ,
|
|
754
|
-
description: "List the account's
|
|
947
|
+
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).",
|
|
755
948
|
inputSchema: {
|
|
949
|
+
source: z
|
|
950
|
+
.enum(['creations', 'uploads', 'stock', 'all'])
|
|
951
|
+
.optional()
|
|
952
|
+
.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)."),
|
|
756
953
|
contentType: z
|
|
757
954
|
.enum(['image', 'video', 'audio', 'transcript'])
|
|
758
955
|
.optional()
|
|
@@ -760,17 +957,22 @@ export function registerTools(server, opts) {
|
|
|
760
957
|
kind: z
|
|
761
958
|
.enum(['creation', 'board', 'look'])
|
|
762
959
|
.optional()
|
|
763
|
-
.describe("Filter by asset class: 'creation' (normal generations), 'board' (reference boards), or 'look'. Omit to list all."),
|
|
960
|
+
.describe("Creations only. Filter by asset class: 'creation' (normal generations), 'board' (reference boards), or 'look'. Omit to list all."),
|
|
764
961
|
status: z.string().optional().describe("Status filter; defaults to 'completed'."),
|
|
962
|
+
favorited: z.boolean().optional().describe('Creations only. Only outputs that have a favorited variation.'),
|
|
963
|
+
archived: z.boolean().optional().describe('Creations only. Only outputs that have an archived variation.'),
|
|
765
964
|
limit: z.number().int().min(1).max(100).optional().describe('How many to return (default 20).'),
|
|
766
965
|
},
|
|
767
966
|
}, async (args, extra) => {
|
|
768
967
|
try {
|
|
769
968
|
const client = await getClient(extra);
|
|
770
969
|
return mediaListResult(await client.listMedia({
|
|
970
|
+
source: args.source,
|
|
771
971
|
contentType: args.contentType,
|
|
772
972
|
kind: args.kind,
|
|
773
973
|
status: args.status,
|
|
974
|
+
favorited: args.favorited,
|
|
975
|
+
archived: args.archived,
|
|
774
976
|
limit: args.limit,
|
|
775
977
|
}));
|
|
776
978
|
}
|
|
@@ -778,20 +980,197 @@ export function registerTools(server, opts) {
|
|
|
778
980
|
return errorResult(err);
|
|
779
981
|
}
|
|
780
982
|
});
|
|
983
|
+
// -- search_media ---------------------------------------------------------
|
|
984
|
+
server.registerTool('search_media', {
|
|
985
|
+
title: 'Search Media',
|
|
986
|
+
annotations: READ,
|
|
987
|
+
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).",
|
|
988
|
+
inputSchema: {
|
|
989
|
+
query: z
|
|
990
|
+
.string()
|
|
991
|
+
.describe('A natural-language description of the media to find, describing its visible or audible content.'),
|
|
992
|
+
kinds: z
|
|
993
|
+
.array(z.enum(['image', 'video', 'audio']))
|
|
994
|
+
.optional()
|
|
995
|
+
.describe('Restrict results to these media kinds. Omit to search all kinds.'),
|
|
996
|
+
limit: z.number().int().min(1).max(50).optional().describe('Maximum number of assets to return (default 12, max 50).'),
|
|
997
|
+
},
|
|
998
|
+
}, async (args, extra) => {
|
|
999
|
+
try {
|
|
1000
|
+
const client = await getClient(extra);
|
|
1001
|
+
return mediaSearchResult(await client.searchMedia(args.query, { kinds: args.kinds, limit: args.limit }));
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
return errorResult(err);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
// -- library folder tools (Phase D) ---------------------------------------
|
|
1008
|
+
const smartQuerySchema = z
|
|
1009
|
+
.object({
|
|
1010
|
+
text: z.string().optional().describe('Natural-language description to match semantically.'),
|
|
1011
|
+
kinds: z.array(z.enum(['image', 'video', 'audio'])).optional(),
|
|
1012
|
+
sources: z.array(z.enum(['creations', 'uploads', 'stock'])).optional(),
|
|
1013
|
+
tags: z.array(z.string()).optional().describe('Require all of these tags.'),
|
|
1014
|
+
favoritedOnly: z.boolean().optional(),
|
|
1015
|
+
sort: z.enum(['relevance', 'recent', 'name']).optional(),
|
|
1016
|
+
})
|
|
1017
|
+
.optional()
|
|
1018
|
+
.describe('For a smart folder: the live query that defines its membership (the same filters as the library search bar).');
|
|
1019
|
+
server.registerTool('list_folders', {
|
|
1020
|
+
title: 'List Folders',
|
|
1021
|
+
annotations: READ,
|
|
1022
|
+
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, posts). Use this to see how the library is organized before browsing or filing items.",
|
|
1023
|
+
inputSchema: {},
|
|
1024
|
+
}, async (_args, extra) => {
|
|
1025
|
+
try {
|
|
1026
|
+
return folderListResult(await (await getClient(extra)).listFolders());
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
return errorResult(err);
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
server.registerTool('get_folder', {
|
|
1033
|
+
title: 'Get Folder',
|
|
1034
|
+
annotations: READ,
|
|
1035
|
+
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.",
|
|
1036
|
+
inputSchema: {
|
|
1037
|
+
folder_id: z.string().describe('A folder id, or a derived-folder key (recents, favorites, edits, canvas, posts).'),
|
|
1038
|
+
},
|
|
1039
|
+
}, async (args, extra) => {
|
|
1040
|
+
try {
|
|
1041
|
+
const r = await (await getClient(extra)).getFolder(args.folder_id);
|
|
1042
|
+
return folderContentsResult(r.folder, r.items);
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
return errorResult(err);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
server.registerTool('create_folder', {
|
|
1049
|
+
title: 'Create Folder',
|
|
1050
|
+
annotations: WRITE,
|
|
1051
|
+
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.',
|
|
1052
|
+
inputSchema: {
|
|
1053
|
+
name: z.string().describe('The folder name.'),
|
|
1054
|
+
type: z.enum(['manual', 'smart']).optional().describe("'manual' (a collection you file items into) or 'smart' (a saved live query). Defaults to manual."),
|
|
1055
|
+
query: smartQuerySchema,
|
|
1056
|
+
parent_id: z.string().optional().describe('Nest the new folder under this parent folder id.'),
|
|
1057
|
+
},
|
|
1058
|
+
}, async (args, extra) => {
|
|
1059
|
+
try {
|
|
1060
|
+
const f = await (await getClient(extra)).createFolder({ name: args.name, type: args.type, query: args.query, parentId: args.parent_id });
|
|
1061
|
+
return text(`Created ${f.type} folder "${f.name}" (id ${f.id}).`);
|
|
1062
|
+
}
|
|
1063
|
+
catch (err) {
|
|
1064
|
+
return errorResult(err);
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
server.registerTool('update_folder', {
|
|
1068
|
+
title: 'Update Folder',
|
|
1069
|
+
annotations: WRITE,
|
|
1070
|
+
description: "Update one of the account's own folders: rename it, move it under a different parent (or to the top level with a null parent), or change a smart folder's saved query. Only the provided fields change.",
|
|
1071
|
+
inputSchema: {
|
|
1072
|
+
folder_id: z.string().describe('The folder id to update.'),
|
|
1073
|
+
name: z.string().optional().describe('A new name.'),
|
|
1074
|
+
parent_id: z.string().nullable().optional().describe('A new parent folder id, or null to move to the top level.'),
|
|
1075
|
+
query: smartQuerySchema,
|
|
1076
|
+
},
|
|
1077
|
+
}, async (args, extra) => {
|
|
1078
|
+
try {
|
|
1079
|
+
const f = await (await getClient(extra)).updateFolder(args.folder_id, { name: args.name, parentId: args.parent_id, query: args.query });
|
|
1080
|
+
return text(`Updated folder "${f.name}" (id ${f.id}).`);
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
return errorResult(err);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
server.registerTool('delete_folder', {
|
|
1087
|
+
title: 'Delete Folder',
|
|
1088
|
+
annotations: WRITE,
|
|
1089
|
+
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.",
|
|
1090
|
+
inputSchema: { folder_id: z.string().describe('The folder id to delete.') },
|
|
1091
|
+
}, async (args, extra) => {
|
|
1092
|
+
try {
|
|
1093
|
+
await (await getClient(extra)).deleteFolder(args.folder_id);
|
|
1094
|
+
return text(`Deleted folder ${args.folder_id}.`);
|
|
1095
|
+
}
|
|
1096
|
+
catch (err) {
|
|
1097
|
+
return errorResult(err);
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
const itemRefSchema = {
|
|
1101
|
+
folder_id: z.string().describe('The folder id.'),
|
|
1102
|
+
source_table: z.string().describe("The item's source table (e.g. as returned by search_media)."),
|
|
1103
|
+
source_record_id: z.string().describe("The item's source record id."),
|
|
1104
|
+
variant: z.number().int().optional().describe('The variation index (default 0 for single-asset items).'),
|
|
1105
|
+
};
|
|
1106
|
+
server.registerTool('add_to_folder', {
|
|
1107
|
+
title: 'Add to Folder',
|
|
1108
|
+
annotations: WRITE,
|
|
1109
|
+
description: 'File an item into a manual folder by its universal identity (source_table, source_record_id, and variant, as returned by search_media). Filing never moves or copies the asset; it adds a pointer, so the same item can live in several folders. Only manual folders accept items (a smart folder computes its own membership).',
|
|
1110
|
+
inputSchema: itemRefSchema,
|
|
1111
|
+
}, async (args, extra) => {
|
|
1112
|
+
try {
|
|
1113
|
+
await (await getClient(extra)).addToFolder(args.folder_id, { sourceTable: args.source_table, sourceRecordId: args.source_record_id, variant: args.variant });
|
|
1114
|
+
return text('Filed into the folder.');
|
|
1115
|
+
}
|
|
1116
|
+
catch (err) {
|
|
1117
|
+
return errorResult(err);
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
server.registerTool('remove_from_folder', {
|
|
1121
|
+
title: 'Remove from Folder',
|
|
1122
|
+
annotations: WRITE,
|
|
1123
|
+
description: 'Remove an item from a manual folder by its universal identity. This unfiles the pointer only; the underlying asset is never deleted.',
|
|
1124
|
+
inputSchema: itemRefSchema,
|
|
1125
|
+
}, async (args, extra) => {
|
|
1126
|
+
try {
|
|
1127
|
+
await (await getClient(extra)).removeFromFolder(args.folder_id, { sourceTable: args.source_table, sourceRecordId: args.source_record_id, variant: args.variant });
|
|
1128
|
+
return text('Removed from the folder.');
|
|
1129
|
+
}
|
|
1130
|
+
catch (err) {
|
|
1131
|
+
return errorResult(err);
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
781
1134
|
// -- get_media ------------------------------------------------------------
|
|
782
1135
|
server.registerTool('get_media', {
|
|
783
1136
|
title: 'Get Media',
|
|
784
1137
|
annotations: READ,
|
|
785
|
-
description: '
|
|
1138
|
+
description: 'SEE specific media. Pass a batch of items (up to 10) to view them at once: each item is either a { url } (e.g. a URL threaded from get_context, a layer/asset URL from get_project / get_post, 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.',
|
|
786
1139
|
inputSchema: {
|
|
787
|
-
|
|
788
|
-
.
|
|
789
|
-
.
|
|
1140
|
+
items: z
|
|
1141
|
+
.array(z.union([
|
|
1142
|
+
z.object({
|
|
1143
|
+
url: z.string().describe('A media URL on our storage (from get_context / get_project / get_post).'),
|
|
1144
|
+
fromSec: z.number().min(0).optional().describe('Video keyframes: start of the source-time window (seconds). Omit for the whole clip.'),
|
|
1145
|
+
toSec: z.number().min(0).optional().describe('Video keyframes: end of the source-time window (seconds).'),
|
|
1146
|
+
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.'),
|
|
1147
|
+
}),
|
|
1148
|
+
z.object({
|
|
1149
|
+
mediaId: z.string().describe('A studio output id (full or first-8 characters).'),
|
|
1150
|
+
variation: z
|
|
1151
|
+
.number()
|
|
1152
|
+
.int()
|
|
1153
|
+
.positive()
|
|
1154
|
+
.optional()
|
|
1155
|
+
.describe('1-based variation to view; omit for the primary variation only.'),
|
|
1156
|
+
fromSec: z.number().min(0).optional().describe('Video keyframes: start of the source-time window (seconds). Omit for the whole clip.'),
|
|
1157
|
+
toSec: z.number().min(0).optional().describe('Video keyframes: end of the source-time window (seconds).'),
|
|
1158
|
+
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.'),
|
|
1159
|
+
}),
|
|
1160
|
+
]))
|
|
1161
|
+
.min(1)
|
|
1162
|
+
.max(10)
|
|
1163
|
+
.describe('The media to view, up to 10 items per call. Paginate with another call for more.'),
|
|
790
1164
|
},
|
|
791
1165
|
}, async (args, extra) => {
|
|
792
1166
|
try {
|
|
793
1167
|
const client = await getClient(extra);
|
|
794
|
-
|
|
1168
|
+
const result = await client.getMediaBatch(args.items);
|
|
1169
|
+
// Image blocks are an MCP-layer concern: fetch the resolver-chosen still
|
|
1170
|
+
// (imageUrl) for each item that has one (images + video posters). audio /
|
|
1171
|
+
// transcript / posterless items stay text-only. See get-context §9.5.
|
|
1172
|
+
const images = await Promise.all(result.items.map((it) => it.ok && it.imageUrl ? fetchMediaImageBase64(it.imageUrl) : Promise.resolve(null)));
|
|
1173
|
+
return mediaBatchResult(result, images);
|
|
795
1174
|
}
|
|
796
1175
|
catch (err) {
|
|
797
1176
|
return errorResult(err);
|
|
@@ -801,7 +1180,7 @@ export function registerTools(server, opts) {
|
|
|
801
1180
|
server.registerTool('create_media_upload', {
|
|
802
1181
|
title: 'Create Media Upload',
|
|
803
1182
|
annotations: WRITE,
|
|
804
|
-
description:
|
|
1183
|
+
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 add_post_asset. For a file already on a public URL, use import_media instead. Requires the assets:write scope.',
|
|
805
1184
|
inputSchema: {
|
|
806
1185
|
fileName: z.string().describe('The file name (used for its extension), e.g. "cover.png".'),
|
|
807
1186
|
contentType: z.string().describe('The file MIME type, e.g. "image/png" or "video/mp4".'),
|
|
@@ -1073,9 +1452,17 @@ export function registerTools(server, opts) {
|
|
|
1073
1452
|
return await client.waitForGeneration(id, { timeoutMs: SMART_WAIT_MS });
|
|
1074
1453
|
}
|
|
1075
1454
|
catch (err) {
|
|
1455
|
+
// Timeout is expected for a slow render. A transient poll error is not, but
|
|
1456
|
+
// it must not fail the whole BATCH either: fall back to a status snapshot so
|
|
1457
|
+
// the other ids still report, and only surface the error if even that fails.
|
|
1076
1458
|
if (err instanceof GenerationTimeoutError)
|
|
1077
1459
|
return client.getGeneration(id);
|
|
1078
|
-
|
|
1460
|
+
try {
|
|
1461
|
+
return await client.getGeneration(id);
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
throw err;
|
|
1465
|
+
}
|
|
1079
1466
|
}
|
|
1080
1467
|
}));
|
|
1081
1468
|
return generationBatchResult(gens);
|
|
@@ -1215,28 +1602,11 @@ export function registerTools(server, opts) {
|
|
|
1215
1602
|
return errorResult(err);
|
|
1216
1603
|
}
|
|
1217
1604
|
});
|
|
1218
|
-
// -- archive_post ---------------------------------------------------------
|
|
1219
|
-
server.registerTool('archive_post', {
|
|
1220
|
-
title: 'Archive Post',
|
|
1221
|
-
annotations: WRITE,
|
|
1222
|
-
description: 'Archive a post (sets status to archived; reversible by updating the status back). ContentHero never hard-deletes. Requires the pipeline:write scope.',
|
|
1223
|
-
inputSchema: {
|
|
1224
|
-
postId: z.string().describe('The post id to archive.'),
|
|
1225
|
-
},
|
|
1226
|
-
}, async (args, extra) => {
|
|
1227
|
-
try {
|
|
1228
|
-
const client = await getClient(extra);
|
|
1229
|
-
return postSummaryResult(await client.archivePost(args.postId), 'Archived');
|
|
1230
|
-
}
|
|
1231
|
-
catch (err) {
|
|
1232
|
-
return errorResult(err);
|
|
1233
|
-
}
|
|
1234
|
-
});
|
|
1235
1605
|
// -- add_post_destination -------------------------------------------------
|
|
1236
1606
|
server.registerTool('add_post_destination', {
|
|
1237
1607
|
title: 'Add Post Destination',
|
|
1238
1608
|
annotations: WRITE,
|
|
1239
|
-
description: "Attach a publish destination (one platform) to a post, or replace the existing one for that platform. Set connectedAccountId (from list_connected_accounts
|
|
1609
|
+
description: "Attach a publish destination (one platform) to a post, or replace the existing one for that platform. Set connectedAccountId (an id from list_connected_accounts) to make it publishable. Pass platformSettings (the publish payload: media, caption, thumbnail, privacy) shaped to the platform + format; call get_platform first for the exact fields. In platformSettings, media URL fields (mediaItems, videoUrl, thumbnailUrl, ...) also accept an outputId of generated/uploaded media, resolved server-side. Requires the pipeline:write scope.",
|
|
1240
1610
|
inputSchema: {
|
|
1241
1611
|
postId: z.string().describe('The post id.'),
|
|
1242
1612
|
platform: z.enum(POST_PLATFORMS).describe('Destination platform.'),
|
|
@@ -1527,7 +1897,7 @@ export function registerTools(server, opts) {
|
|
|
1527
1897
|
server.registerTool('list_outliers', {
|
|
1528
1898
|
title: 'List Outliers',
|
|
1529
1899
|
annotations: READ,
|
|
1530
|
-
description: "List top-performing content (outliers) from the creators the account tracks, ranked by outlier score (how far a post overperformed its creator's baseline). Filter by platform, content type, minimum score, or a text search. Call get_inspiration_content for one item's full detail incl. transcript. This is the core research read for finding what's working.",
|
|
1900
|
+
description: "List top-performing content (outliers) from the creators the account tracks, ranked by outlier score (how far a post overperformed its creator's baseline). Filter by platform, content type, minimum score, or a text search. Set favorited=true to show only content the account has favorited. Call get_inspiration_content for one item's full detail incl. transcript. This is the core research read for finding what's working.",
|
|
1531
1901
|
inputSchema: {
|
|
1532
1902
|
platform: z.enum(['youtube', 'instagram']).optional().describe('Filter to one platform.'),
|
|
1533
1903
|
contentType: z.string().optional().describe("Filter by content type, e.g. 'video', 'short', 'reel'."),
|
|
@@ -1535,6 +1905,7 @@ export function registerTools(server, opts) {
|
|
|
1535
1905
|
search: z.string().optional().describe('Text search across title, creator, handle, and description.'),
|
|
1536
1906
|
sortBy: z.enum(['score', 'date', 'views']).optional().describe("Sort order (default 'score')."),
|
|
1537
1907
|
brandKitId: z.string().optional().describe('Scope to the inspiration accounts linked to this brand kit (from get_brand_kit).'),
|
|
1908
|
+
favorited: z.boolean().optional().describe('Only content the account has favorited.'),
|
|
1538
1909
|
limit: z.number().int().min(1).max(100).optional().describe('How many to return (default 20).'),
|
|
1539
1910
|
offset: z.number().int().min(0).optional().describe('Pagination offset.'),
|
|
1540
1911
|
},
|
|
@@ -1548,6 +1919,7 @@ export function registerTools(server, opts) {
|
|
|
1548
1919
|
search: args.search,
|
|
1549
1920
|
sortBy: args.sortBy,
|
|
1550
1921
|
brandKitId: args.brandKitId,
|
|
1922
|
+
favorited: args.favorited,
|
|
1551
1923
|
limit: args.limit,
|
|
1552
1924
|
offset: args.offset,
|
|
1553
1925
|
}));
|
|
@@ -1652,6 +2024,501 @@ export function registerTools(server, opts) {
|
|
|
1652
2024
|
return errorResult(err);
|
|
1653
2025
|
}
|
|
1654
2026
|
});
|
|
2027
|
+
// ===========================================================================
|
|
2028
|
+
// Favorites & archive (one universal pair each, across asset types)
|
|
2029
|
+
// ===========================================================================
|
|
2030
|
+
// -- favorite -------------------------------------------------------------
|
|
2031
|
+
server.registerTool('favorite', {
|
|
2032
|
+
title: 'Favorite',
|
|
2033
|
+
annotations: WRITE,
|
|
2034
|
+
description: "Mark an asset as a favorite. For a top-level asset, pass assetType + id (post, voice, brand_kit, project, inspiration_content, gallery, transition). 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.",
|
|
2035
|
+
inputSchema: {
|
|
2036
|
+
assetType: z
|
|
2037
|
+
.enum(['post', 'voice', 'brand_kit', 'project', 'inspiration_content', 'gallery', 'transition'])
|
|
2038
|
+
.optional()
|
|
2039
|
+
.describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
|
|
2040
|
+
id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
|
|
2041
|
+
variationIndex: z
|
|
2042
|
+
.number()
|
|
2043
|
+
.int()
|
|
2044
|
+
.min(1)
|
|
2045
|
+
.optional()
|
|
2046
|
+
.describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
|
|
2047
|
+
},
|
|
2048
|
+
}, async (args, extra) => {
|
|
2049
|
+
try {
|
|
2050
|
+
const client = await getClient(extra);
|
|
2051
|
+
await client.favorite({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
|
|
2052
|
+
return statusActionResult('Favorited', args);
|
|
2053
|
+
}
|
|
2054
|
+
catch (err) {
|
|
2055
|
+
return errorResult(err);
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
// -- unfavorite -----------------------------------------------------------
|
|
2059
|
+
server.registerTool('unfavorite', {
|
|
2060
|
+
title: 'Unfavorite',
|
|
2061
|
+
annotations: WRITE,
|
|
2062
|
+
description: 'Remove the favorite flag from an asset. Same target shape as favorite: assetType + id for a top-level asset, or output id + variationIndex (1-based) for a studio media variation. Requires the favorites:write scope. Idempotent.',
|
|
2063
|
+
inputSchema: {
|
|
2064
|
+
assetType: z
|
|
2065
|
+
.enum(['post', 'voice', 'brand_kit', 'project', 'inspiration_content', 'gallery', 'transition'])
|
|
2066
|
+
.optional()
|
|
2067
|
+
.describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
|
|
2068
|
+
id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
|
|
2069
|
+
variationIndex: z
|
|
2070
|
+
.number()
|
|
2071
|
+
.int()
|
|
2072
|
+
.min(1)
|
|
2073
|
+
.optional()
|
|
2074
|
+
.describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
|
|
2075
|
+
},
|
|
2076
|
+
}, async (args, extra) => {
|
|
2077
|
+
try {
|
|
2078
|
+
const client = await getClient(extra);
|
|
2079
|
+
await client.unfavorite({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
|
|
2080
|
+
return statusActionResult('Unfavorited', args);
|
|
2081
|
+
}
|
|
2082
|
+
catch (err) {
|
|
2083
|
+
return errorResult(err);
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
// -- archive --------------------------------------------------------------
|
|
2087
|
+
server.registerTool('archive', {
|
|
2088
|
+
title: 'Archive',
|
|
2089
|
+
annotations: WRITE,
|
|
2090
|
+
description: "Archive an asset (reversible; ContentHero never hard-deletes). For a top-level asset, pass assetType + id (post, brand_kit, brand_kit_section, project). To archive a single studio media variation, pass the output id + variationIndex (1-based) and omit assetType. Archiving a post sets its status to 'archived'. Requires the favorites:write scope. Idempotent.",
|
|
2091
|
+
inputSchema: {
|
|
2092
|
+
assetType: z
|
|
2093
|
+
.enum(['post', 'brand_kit', 'brand_kit_section', 'project'])
|
|
2094
|
+
.optional()
|
|
2095
|
+
.describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
|
|
2096
|
+
id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
|
|
2097
|
+
variationIndex: z
|
|
2098
|
+
.number()
|
|
2099
|
+
.int()
|
|
2100
|
+
.min(1)
|
|
2101
|
+
.optional()
|
|
2102
|
+
.describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
|
|
2103
|
+
},
|
|
2104
|
+
}, async (args, extra) => {
|
|
2105
|
+
try {
|
|
2106
|
+
const client = await getClient(extra);
|
|
2107
|
+
await client.archive({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
|
|
2108
|
+
return statusActionResult('Archived', args);
|
|
2109
|
+
}
|
|
2110
|
+
catch (err) {
|
|
2111
|
+
return errorResult(err);
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
// -- unarchive ------------------------------------------------------------
|
|
2115
|
+
server.registerTool('unarchive', {
|
|
2116
|
+
title: 'Unarchive',
|
|
2117
|
+
annotations: WRITE,
|
|
2118
|
+
description: "Unarchive an asset (restore it). For a top-level asset, pass assetType + id (post, brand_kit, brand_kit_section, project). To unarchive a single studio media variation, pass the output id + variationIndex (1-based) and omit assetType. Unarchiving a post restores it to 'draft'. Requires the favorites:write scope. Idempotent.",
|
|
2119
|
+
inputSchema: {
|
|
2120
|
+
assetType: z
|
|
2121
|
+
.enum(['post', 'brand_kit', 'brand_kit_section', 'project'])
|
|
2122
|
+
.optional()
|
|
2123
|
+
.describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
|
|
2124
|
+
id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
|
|
2125
|
+
variationIndex: z
|
|
2126
|
+
.number()
|
|
2127
|
+
.int()
|
|
2128
|
+
.min(1)
|
|
2129
|
+
.optional()
|
|
2130
|
+
.describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
|
|
2131
|
+
},
|
|
2132
|
+
}, async (args, extra) => {
|
|
2133
|
+
try {
|
|
2134
|
+
const client = await getClient(extra);
|
|
2135
|
+
await client.unarchive({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
|
|
2136
|
+
return statusActionResult('Unarchived', args);
|
|
2137
|
+
}
|
|
2138
|
+
catch (err) {
|
|
2139
|
+
return errorResult(err);
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
// ===========================================================================
|
|
2143
|
+
// Editor / canvas ops (programmatic parity with the manual UI + in-app agent)
|
|
2144
|
+
// ===========================================================================
|
|
2145
|
+
server.registerTool('list_projects', {
|
|
2146
|
+
title: 'List Projects',
|
|
2147
|
+
annotations: READ,
|
|
2148
|
+
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.",
|
|
2149
|
+
inputSchema: {
|
|
2150
|
+
filter: z.enum(['archived', 'favorited']).optional().describe('archived -> only archived; favorited -> favorited and not archived; omitted -> active (not archived).'),
|
|
2151
|
+
surface: z.enum(['editor', 'canvas']).optional().describe('Restrict to one surface; omitted returns both.'),
|
|
2152
|
+
kind: z.enum(['editor', 'canvas']).optional().describe('Deprecated alias for `surface`. Prefer `surface`; this is accepted for one release window.'),
|
|
2153
|
+
search: z.string().optional().describe('Case-insensitive title search.'),
|
|
2154
|
+
},
|
|
2155
|
+
}, async (args, extra) => {
|
|
2156
|
+
try {
|
|
2157
|
+
const client = await getClient(extra);
|
|
2158
|
+
return projectListResult(await client.listProjects(args));
|
|
2159
|
+
}
|
|
2160
|
+
catch (err) {
|
|
2161
|
+
return errorResult(err);
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
server.registerTool('get_project', {
|
|
2165
|
+
title: 'Get Project',
|
|
2166
|
+
annotations: READ,
|
|
2167
|
+
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.",
|
|
2168
|
+
inputSchema: {
|
|
2169
|
+
projectId: z.string().describe('The project id to read.'),
|
|
2170
|
+
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."),
|
|
2171
|
+
fromFrame: z.number().int().min(0).optional().describe('Timeline only: start of a frame window; returns clips overlapping [fromFrame, toFrame].'),
|
|
2172
|
+
toFrame: z.number().int().min(0).optional().describe('Timeline only: end of the frame window (see fromFrame).'),
|
|
2173
|
+
trackId: z.string().optional().describe('Timeline only: scope the read to a single track by id.'),
|
|
2174
|
+
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.'),
|
|
2175
|
+
includeRenderUrl: z.boolean().optional().describe('Also return a preview still URL of the current composition (renders one only if it changed).'),
|
|
2176
|
+
},
|
|
2177
|
+
}, async (args, extra) => {
|
|
2178
|
+
try {
|
|
2179
|
+
const client = await getClient(extra);
|
|
2180
|
+
return projectDetailResult(await client.getProject(args.projectId, {
|
|
2181
|
+
includeRenderUrl: args.includeRenderUrl,
|
|
2182
|
+
detail: args.detail,
|
|
2183
|
+
fromFrame: args.fromFrame,
|
|
2184
|
+
toFrame: args.toFrame,
|
|
2185
|
+
trackId: args.trackId,
|
|
2186
|
+
slideId: args.slideId,
|
|
2187
|
+
}));
|
|
2188
|
+
}
|
|
2189
|
+
catch (err) {
|
|
2190
|
+
return errorResult(err);
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
server.registerTool('get_context', {
|
|
2194
|
+
title: 'Get Live Context',
|
|
2195
|
+
annotations: READ,
|
|
2196
|
+
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.",
|
|
2197
|
+
inputSchema: {
|
|
2198
|
+
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.'),
|
|
2199
|
+
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."),
|
|
2200
|
+
render: z.boolean().optional().describe('Also return an inline render (image[s]) of your work, so you can visually verify edits. Ephemeral, stored nowhere, counts against no quota, works without a live tab. render=true alone renders the current focus point as a still. Use mode=filmstrip for several frames across a range. Use this to check your work, not export_project. To watch a RAW source clip use get_media with a video item; for a composed VIDEO of a range use create_preview.'),
|
|
2201
|
+
mode: z.enum(['still', 'filmstrip']).optional().describe("Render tier (inferred from the params if omitted): 'still' = one composed editor frame / canvas slide; 'filmstrip' = several composed frames across an editor range (judge motion / flow / cut placement)."),
|
|
2202
|
+
frame: z.number().int().min(0).optional().describe('still (editor): which timeline frame to render. Omit to render the current playhead frame.'),
|
|
2203
|
+
slideId: z.string().optional().describe('still (canvas): the id of the slide to render. Omit to render the focused slide.'),
|
|
2204
|
+
slideIndex: z.number().int().min(1).optional().describe('still (canvas): the 1-based slide index to render (alternative to slideId).'),
|
|
2205
|
+
fromFrame: z.number().int().min(0).optional().describe('filmstrip: start timeline frame of the range. Omit to start at the beginning.'),
|
|
2206
|
+
toFrame: z.number().int().min(0).optional().describe('filmstrip: end timeline frame of the range. Omit to run to the end.'),
|
|
2207
|
+
count: z.number().int().min(1).optional().describe('filmstrip: how many frames to return. Omit for a proportional default.'),
|
|
2208
|
+
width: z.number().int().min(48).max(1440).optional().describe('still: render at an explicit DISPLAY width in pixels, to judge legibility at the size the output will actually be seen (a course tile, a thumbnail, a feed card) rather than at full resolution, where small type always looks fine. Height follows the composition aspect ratio and is not settable. Clamped; the size produced is reported back on rendered.'),
|
|
2209
|
+
},
|
|
2210
|
+
}, async (args, extra) => {
|
|
2211
|
+
try {
|
|
2212
|
+
const client = await getClient(extra);
|
|
2213
|
+
const result = await client.getContext({
|
|
2214
|
+
projectId: args.projectId,
|
|
2215
|
+
capture: args.capture,
|
|
2216
|
+
render: args.render,
|
|
2217
|
+
mode: args.mode,
|
|
2218
|
+
frame: args.frame,
|
|
2219
|
+
slideId: args.slideId,
|
|
2220
|
+
slideIndex: args.slideIndex,
|
|
2221
|
+
fromFrame: args.fromFrame,
|
|
2222
|
+
toFrame: args.toFrame,
|
|
2223
|
+
count: args.count,
|
|
2224
|
+
width: args.width,
|
|
2225
|
+
});
|
|
2226
|
+
const snapshotUrl = typeof result.context?.snapshotUrl === 'string' ? result.context.snapshotUrl : null;
|
|
2227
|
+
const snapshot = snapshotUrl ? await fetchSnapshotBase64(snapshotUrl) : null;
|
|
2228
|
+
return liveContextResult(result, snapshot);
|
|
2229
|
+
}
|
|
2230
|
+
catch (err) {
|
|
2231
|
+
return errorResult(err);
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2234
|
+
server.registerTool('create_preview', {
|
|
2235
|
+
title: 'Create Preview',
|
|
2236
|
+
annotations: READ,
|
|
2237
|
+
description: "Create an async PREVIEW of your work (ephemeral, never stored, not a deliverable). Currently a short low-res COMPOSED VIDEO of an editor range, so you can assess motion, cuts, transitions, and pacing that a still cannot show. This is a JOB: it returns a renderId + bucketName; poll get_preview with those until it is done, then fetch the returned url. To see a single frame or a few frames instead (cheaper, instant), use get_context render. Requires the context:read scope.",
|
|
2238
|
+
inputSchema: {
|
|
2239
|
+
projectId: z.string().describe('The editor project to preview.'),
|
|
2240
|
+
fromFrame: z.number().int().min(0).optional().describe('Start timeline frame of the range. Omit to start at the beginning.'),
|
|
2241
|
+
toFrame: z.number().int().min(0).optional().describe('End timeline frame. Omit to run to the end (capped to a short preview length).'),
|
|
2242
|
+
},
|
|
2243
|
+
}, async (args, extra) => {
|
|
2244
|
+
try {
|
|
2245
|
+
const client = await getClient(extra);
|
|
2246
|
+
const job = await client.createPreview({ projectId: args.projectId, fromFrame: args.fromFrame, toFrame: args.toFrame });
|
|
2247
|
+
return text(`Preview render started (frames ${job.fromFrame}-${job.toFrame}, ~${job.durationSeconds}s).\n` +
|
|
2248
|
+
`Poll get_preview with renderId="${job.renderId}" and bucketName="${job.bucketName}" until status is "done", then fetch the returned url.`);
|
|
2249
|
+
}
|
|
2250
|
+
catch (err) {
|
|
2251
|
+
return errorResult(err);
|
|
2252
|
+
}
|
|
2253
|
+
});
|
|
2254
|
+
server.registerTool('get_preview', {
|
|
2255
|
+
title: 'Get Preview',
|
|
2256
|
+
annotations: READ,
|
|
2257
|
+
description: 'Poll a preview started with create_preview. While rendering, returns the progress; when done, returns a short-lived url to the ephemeral preview output (plus the estimated cost). Requires the context:read scope.',
|
|
2258
|
+
inputSchema: {
|
|
2259
|
+
renderId: z.string().describe('The renderId returned by create_preview.'),
|
|
2260
|
+
bucketName: z.string().describe('The bucketName returned by create_preview.'),
|
|
2261
|
+
},
|
|
2262
|
+
}, async (args, extra) => {
|
|
2263
|
+
try {
|
|
2264
|
+
const client = await getClient(extra);
|
|
2265
|
+
const s = await client.getPreview({ renderId: args.renderId, bucketName: args.bucketName });
|
|
2266
|
+
if (s.status === 'done') {
|
|
2267
|
+
return text(`Preview ready. url: ${s.url}${typeof s.estimatedCostUsd === 'number' ? ` (est. cost $${s.estimatedCostUsd.toFixed(4)})` : ''}`);
|
|
2268
|
+
}
|
|
2269
|
+
if (s.status === 'failed')
|
|
2270
|
+
return text(`Preview render failed: ${s.error ?? 'unknown error'}.`, true);
|
|
2271
|
+
return text(`Preview still rendering${typeof s.progress === 'number' ? ` (${Math.round(s.progress * 100)}%)` : ''}. Poll again in a few seconds.`);
|
|
2272
|
+
}
|
|
2273
|
+
catch (err) {
|
|
2274
|
+
return errorResult(err);
|
|
2275
|
+
}
|
|
2276
|
+
});
|
|
2277
|
+
server.registerTool('get_layer_types', {
|
|
2278
|
+
title: 'Get Layer Types',
|
|
2279
|
+
annotations: READ,
|
|
2280
|
+
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.',
|
|
2281
|
+
inputSchema: {},
|
|
2282
|
+
}, async (_args, extra) => {
|
|
2283
|
+
try {
|
|
2284
|
+
const client = await getClient(extra);
|
|
2285
|
+
return layerTypesResult(await client.getLayerTypes());
|
|
2286
|
+
}
|
|
2287
|
+
catch (err) {
|
|
2288
|
+
return errorResult(err);
|
|
2289
|
+
}
|
|
2290
|
+
});
|
|
2291
|
+
server.registerTool('get_timeline_types', {
|
|
2292
|
+
title: 'Get Timeline Types',
|
|
2293
|
+
annotations: READ,
|
|
2294
|
+
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.',
|
|
2295
|
+
inputSchema: {},
|
|
2296
|
+
}, async (_args, extra) => {
|
|
2297
|
+
try {
|
|
2298
|
+
const client = await getClient(extra);
|
|
2299
|
+
return timelineTypesResult(await client.getTimelineTypes());
|
|
2300
|
+
}
|
|
2301
|
+
catch (err) {
|
|
2302
|
+
return errorResult(err);
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
server.registerTool('get_transcript', {
|
|
2306
|
+
title: 'Get Transcript',
|
|
2307
|
+
annotations: READ,
|
|
2308
|
+
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.",
|
|
2309
|
+
inputSchema: {
|
|
2310
|
+
projectId: z.string().describe('The editor project id.'),
|
|
2311
|
+
search: z.string().optional().describe('Case-insensitive substring; returns only clip segments whose text contains it.'),
|
|
2312
|
+
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).'),
|
|
2313
|
+
endMs: z.number().int().min(0).optional().describe('Source-media end time in ms; companion to startMs.'),
|
|
2314
|
+
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."),
|
|
2315
|
+
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.'),
|
|
2316
|
+
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.'),
|
|
2317
|
+
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.'),
|
|
2318
|
+
},
|
|
2319
|
+
}, async (args, extra) => {
|
|
2320
|
+
try {
|
|
2321
|
+
const client = await getClient(extra);
|
|
2322
|
+
const { projectId, ...options } = args;
|
|
2323
|
+
return editorTranscriptResult(await client.getTranscript(projectId, options));
|
|
2324
|
+
}
|
|
2325
|
+
catch (err) {
|
|
2326
|
+
return errorResult(err);
|
|
2327
|
+
}
|
|
2328
|
+
});
|
|
2329
|
+
server.registerTool('create_project', {
|
|
2330
|
+
title: 'Create Project',
|
|
2331
|
+
annotations: WRITE,
|
|
2332
|
+
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.",
|
|
2333
|
+
inputSchema: {
|
|
2334
|
+
surface: z.enum(['editor', 'canvas']).optional().describe("The surface. Defaults to 'editor'."),
|
|
2335
|
+
kind: z.enum(['editor', 'canvas']).optional().describe("Deprecated alias for `surface`. Prefer `surface`; accepted for one release window."),
|
|
2336
|
+
title: z.string().optional().describe("Project title. Defaults to 'Untitled'."),
|
|
2337
|
+
orientation: z.string().optional().describe("Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to '16:9'."),
|
|
2338
|
+
width: z.number().optional().describe('Pixel width. Defaults from the orientation.'),
|
|
2339
|
+
height: z.number().optional().describe('Pixel height. Defaults from the orientation.'),
|
|
2340
|
+
brandKitId: z.string().optional().describe('Optional brand kit to associate.'),
|
|
2341
|
+
},
|
|
2342
|
+
}, async (args, extra) => {
|
|
2343
|
+
try {
|
|
2344
|
+
const client = await getClient(extra);
|
|
2345
|
+
return projectCreatedResult(await client.createProject(args));
|
|
2346
|
+
}
|
|
2347
|
+
catch (err) {
|
|
2348
|
+
return errorResult(err);
|
|
2349
|
+
}
|
|
2350
|
+
});
|
|
2351
|
+
server.registerTool('import_project', {
|
|
2352
|
+
title: 'Import Project',
|
|
2353
|
+
annotations: WRITE,
|
|
2354
|
+
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.",
|
|
2355
|
+
inputSchema: {
|
|
2356
|
+
sourceType: z.enum(['pptx', 'canva']).describe("'pptx' for a file URL, 'canva' for a Canva design id."),
|
|
2357
|
+
fileUrl: z.string().optional().describe("Required when sourceType is 'pptx': a URL to the .pptx / slides file."),
|
|
2358
|
+
designId: z.string().optional().describe("Required when sourceType is 'canva': the Canva design id."),
|
|
2359
|
+
title: z.string().optional().describe("Title for the created project. Defaults to 'Imported deck'."),
|
|
2360
|
+
},
|
|
2361
|
+
}, async (args, extra) => {
|
|
2362
|
+
try {
|
|
2363
|
+
if (args.sourceType === 'pptx' && !args.fileUrl)
|
|
2364
|
+
return errorResult(new Error("fileUrl is required when sourceType is 'pptx'."));
|
|
2365
|
+
if (args.sourceType === 'canva' && !args.designId)
|
|
2366
|
+
return errorResult(new Error("designId is required when sourceType is 'canva'."));
|
|
2367
|
+
const source = args.sourceType === 'pptx'
|
|
2368
|
+
? { type: 'pptx', fileUrl: args.fileUrl }
|
|
2369
|
+
: { type: 'canva', designId: args.designId };
|
|
2370
|
+
const client = await getClient(extra);
|
|
2371
|
+
return projectCreatedResult(await client.importProject({ source, title: args.title }));
|
|
2372
|
+
}
|
|
2373
|
+
catch (err) {
|
|
2374
|
+
return errorResult(err);
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
2377
|
+
server.registerTool('export_project', {
|
|
2378
|
+
title: 'Export Project',
|
|
2379
|
+
annotations: WRITE,
|
|
2380
|
+
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'. For mp4, resolution ('720p' default; 1080p/2k/4k are plan-gated) and watermark (default on; removing it is plan-gated) apply. Returns the download URL when the render finishes in time, otherwise an exportId to poll with get_export. Requires the editor:write scope.",
|
|
2381
|
+
inputSchema: {
|
|
2382
|
+
projectId: z.string().describe('The project to export.'),
|
|
2383
|
+
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."),
|
|
2384
|
+
resolution: z.enum(['480p', '720p', '1080p', '2k', '4k']).optional().describe("mp4 video resolution. Defaults '720p'. 1080p+ is plan-gated."),
|
|
2385
|
+
quality: z.enum(['low', 'recommended', 'high']).optional().describe('mp4 video quality. Defaults recommended.'),
|
|
2386
|
+
watermark: z.boolean().optional().describe('Keep the watermark. Defaults true; removing it is plan-gated.'),
|
|
2387
|
+
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.'),
|
|
2388
|
+
},
|
|
2389
|
+
}, async (args, extra) => {
|
|
2390
|
+
try {
|
|
2391
|
+
const client = await getClient(extra);
|
|
2392
|
+
const { projectId, ...input } = args;
|
|
2393
|
+
const job = await client.exportProjectAndWait(projectId, input, { timeoutMs: SMART_WAIT_MS });
|
|
2394
|
+
return exportJobResult(job);
|
|
2395
|
+
}
|
|
2396
|
+
catch (err) {
|
|
2397
|
+
if (err instanceof GenerationTimeoutError) {
|
|
2398
|
+
return exportJobResult({ exportId: err.outputId, status: 'rendering' });
|
|
2399
|
+
}
|
|
2400
|
+
return errorResult(err);
|
|
2401
|
+
}
|
|
2402
|
+
});
|
|
2403
|
+
server.registerTool('get_export', {
|
|
2404
|
+
title: 'Get Export',
|
|
2405
|
+
annotations: READ,
|
|
2406
|
+
description: 'Poll an export job started by export_project. Returns its status and, when done, the download URL. Requires the editor:read scope.',
|
|
2407
|
+
inputSchema: {
|
|
2408
|
+
exportId: z.string().describe('The export id returned by export_project.'),
|
|
2409
|
+
},
|
|
2410
|
+
}, async (args, extra) => {
|
|
2411
|
+
try {
|
|
2412
|
+
const client = await getClient(extra);
|
|
2413
|
+
return exportJobResult(await client.getExport(args.exportId));
|
|
2414
|
+
}
|
|
2415
|
+
catch (err) {
|
|
2416
|
+
return errorResult(err);
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
server.registerTool('get_export_formats', {
|
|
2420
|
+
title: 'Get Export Formats',
|
|
2421
|
+
annotations: READ,
|
|
2422
|
+
description: 'List the export formats (and their options) available per project surface, so you know what export_project accepts. Requires the editor:read scope.',
|
|
2423
|
+
inputSchema: {},
|
|
2424
|
+
}, async (_args, extra) => {
|
|
2425
|
+
try {
|
|
2426
|
+
const client = await getClient(extra);
|
|
2427
|
+
return exportFormatsResult(await client.getExportFormats());
|
|
2428
|
+
}
|
|
2429
|
+
catch (err) {
|
|
2430
|
+
return errorResult(err);
|
|
2431
|
+
}
|
|
2432
|
+
});
|
|
2433
|
+
server.registerTool('delete_project', {
|
|
2434
|
+
title: 'Delete Project',
|
|
2435
|
+
annotations: WRITE,
|
|
2436
|
+
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.",
|
|
2437
|
+
inputSchema: {
|
|
2438
|
+
projectId: z.string().describe('The project id to permanently delete.'),
|
|
2439
|
+
confirm: z.literal(true).describe('Must be true to confirm the irreversible permanent delete.'),
|
|
2440
|
+
},
|
|
2441
|
+
}, async (args, extra) => {
|
|
2442
|
+
try {
|
|
2443
|
+
const client = await getClient(extra);
|
|
2444
|
+
await client.deleteProject(args.projectId);
|
|
2445
|
+
return projectDeletedResult(args.projectId);
|
|
2446
|
+
}
|
|
2447
|
+
catch (err) {
|
|
2448
|
+
return errorResult(err);
|
|
2449
|
+
}
|
|
2450
|
+
});
|
|
2451
|
+
server.registerTool('update_timeline', {
|
|
2452
|
+
title: 'Update Timeline',
|
|
2453
|
+
annotations: WRITE,
|
|
2454
|
+
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 wait_for_generation on, 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.",
|
|
2455
|
+
inputSchema: {
|
|
2456
|
+
projectId: z.string().describe('The editor project id.'),
|
|
2457
|
+
ops: z.array(z.object({ op: z.string() }).passthrough()).describe('The timeline ops to apply, in order.'),
|
|
2458
|
+
userIntent: z.string().describe('A short description of what this edit does (for attribution).'),
|
|
2459
|
+
expectedRevision: z
|
|
2460
|
+
.number()
|
|
2461
|
+
.int()
|
|
2462
|
+
.optional()
|
|
2463
|
+
.describe('The revision from get_project; rejects with a conflict if a concurrent edit landed.'),
|
|
2464
|
+
includeRenderUrl: z.boolean().optional().describe('Also return a preview still URL of the resulting composition.'),
|
|
2465
|
+
},
|
|
2466
|
+
}, async (args, extra) => {
|
|
2467
|
+
try {
|
|
2468
|
+
const client = await getClient(extra);
|
|
2469
|
+
return editorOpsResult(await client.applyEditorOps({
|
|
2470
|
+
projectId: args.projectId,
|
|
2471
|
+
ops: args.ops,
|
|
2472
|
+
userIntent: args.userIntent,
|
|
2473
|
+
expectedRevision: args.expectedRevision,
|
|
2474
|
+
includeRenderUrl: args.includeRenderUrl,
|
|
2475
|
+
}));
|
|
2476
|
+
}
|
|
2477
|
+
catch (err) {
|
|
2478
|
+
return errorResult(err);
|
|
2479
|
+
}
|
|
2480
|
+
});
|
|
2481
|
+
server.registerTool('update_canvas', {
|
|
2482
|
+
title: 'Update Canvas',
|
|
2483
|
+
annotations: WRITE,
|
|
2484
|
+
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 wait_for_generation on; 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.",
|
|
2485
|
+
inputSchema: {
|
|
2486
|
+
projectId: z.string().describe('The canvas project id.'),
|
|
2487
|
+
ops: z.array(z.object({ op: z.string() }).passthrough()).describe('The canvas ops to apply, in order.'),
|
|
2488
|
+
userIntent: z.string().describe('A short description of what this edit does (for attribution).'),
|
|
2489
|
+
expectedRevision: z
|
|
2490
|
+
.number()
|
|
2491
|
+
.int()
|
|
2492
|
+
.optional()
|
|
2493
|
+
.describe('The revision from get_project; rejects with a conflict if a concurrent edit landed.'),
|
|
2494
|
+
includeRenderUrl: z.boolean().optional().describe('Also return a preview still URL of the resulting composition.'),
|
|
2495
|
+
},
|
|
2496
|
+
}, async (args, extra) => {
|
|
2497
|
+
try {
|
|
2498
|
+
const client = await getClient(extra);
|
|
2499
|
+
return editorOpsResult(await client.applyEditorOps({
|
|
2500
|
+
projectId: args.projectId,
|
|
2501
|
+
ops: args.ops,
|
|
2502
|
+
userIntent: args.userIntent,
|
|
2503
|
+
expectedRevision: args.expectedRevision,
|
|
2504
|
+
includeRenderUrl: args.includeRenderUrl,
|
|
2505
|
+
}));
|
|
2506
|
+
}
|
|
2507
|
+
catch (err) {
|
|
2508
|
+
return errorResult(err);
|
|
2509
|
+
}
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
/** Read our own version from package.json (kept in lockstep with sdk). */
|
|
2513
|
+
function readVersion() {
|
|
2514
|
+
try {
|
|
2515
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2516
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
|
|
2517
|
+
return pkg.version ?? '0.0.0';
|
|
2518
|
+
}
|
|
2519
|
+
catch {
|
|
2520
|
+
return '0.0.0';
|
|
2521
|
+
}
|
|
1655
2522
|
}
|
|
1656
2523
|
/**
|
|
1657
2524
|
* Build a stdio-style server bound to a single env-configured client. The model
|
|
@@ -1660,7 +2527,7 @@ export function registerTools(server, opts) {
|
|
|
1660
2527
|
export async function buildServer(options = {}) {
|
|
1661
2528
|
const getClient = options.getClient ?? defaultGetClient;
|
|
1662
2529
|
const models = await resolveModelEnums(getClient);
|
|
1663
|
-
const server = new McpServer({ name: 'contenthero', version:
|
|
2530
|
+
const server = new McpServer({ name: 'contenthero', version: readVersion() });
|
|
1664
2531
|
registerTools(server, { getClient: () => getClient(), models });
|
|
1665
2532
|
return server;
|
|
1666
2533
|
}
|