@contenthero/mcp 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -13,8 +13,7 @@
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
15
  * search_media - semantic search of the editable media library (with scene timestamps)
16
- * get_generation_status - poll an image/video outputId to its final URLs
17
- * wait_for_generation - block until one or more outputIds finish (batch)
16
+ * get_generation_status - check 1-8 outputIds; blocks until terminal by default
18
17
  * get_balance - credit balance + tier
19
18
  * ... plus the content-pipeline, brand-kit-write, inspiration, brand-account,
20
19
  * and connected-account tools.
@@ -38,7 +37,7 @@ import { z } from 'zod';
38
37
  import { GenerationTimeoutError, pendingOutputId, } from '@contenthero/sdk';
39
38
  import { getClient as defaultGetClient } from './client.js';
40
39
  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';
40
+ import { audioResult, avatarListResult, avatarResult, balanceResult, brandKitListResult, brandKitResult, brandKnowledgeListResult, brandKnowledgeDetailResult, brandKnowledgeSearchResult, brandKnowledgeItemResult, completedResult, connectedAccountListResult, connectedAccountResult, costResult, accountDetailResult, inspirationContentResult, mediaListResult, mediaSearchResult, folderListResult, folderContentsResult, mediaBatchResult, mediaUploadResult, uploadedMediaResult, tagListResult, tagResult, tagDeletedResult, modelListResult, modelResult, platformListResult, platformResult, elementListResult, elementResult, elementDeletedResult, errorResult, generationBatchResult, 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';
42
41
  /** Platforms a post or destination may target. */
43
42
  const POST_PLATFORMS = [
44
43
  'youtube',
@@ -753,7 +752,7 @@ export function registerTools(server, opts) {
753
752
  server.registerTool('create_brand_kit', {
754
753
  title: 'Create Brand Kit',
755
754
  annotations: WRITE,
756
- description: "Create a brand kit. THREE SOURCES, chosen by what you pass: (1) EMPTY, just a name, then fill it in with update_brand_kit; (2) FROM A WEBSITE, pass websiteUrl + extract:true and ContentHero scrapes that site and fills in business name, positioning, voice, colours, typography, logos and assets by itself, which is by far the fastest way to get a real kit; (3) A COPY, pass duplicateFrom with an existing kit id, which copies its sections and brand media (assets re-link rather than duplicate, so a copy costs no storage). With extract it RETURNS IMMEDIATELY, before the kit has any content: that empty kit is the handle, and the fields fill in over the next minute or two, so poll extractionStatus with get_brand_kit rather than assuming it failed. name is OPTIONAL when websiteUrl is given (it defaults to the site's hostname until extraction finds the real business name). Brand kits are capped by plan, so this fails with a limit error near the cap, and a duplicate counts against it like any other kit. Requires the brandkit:write scope.",
755
+ description: "Create a brand kit. THREE SOURCES, chosen by what you pass: (1) EMPTY, just a name, then fill it in with update_brand_kit; (2) FROM A WEBSITE, pass websiteUrl + extract:true and ContentHero scrapes that site and fills in business name, positioning, voice, colours, typography, logos and assets by itself, which is by far the fastest way to get a real kit; (3) A COPY, pass duplicateFrom with an existing kit id, which copies its sections and brand media (assets re-link rather than duplicate, so a copy costs no storage). A brand with NO WEBSITE (so nothing to extract) is built by passing its fields directly, including logos, whose entries may name outputId to bring in a generation you just made rather than a url. With extract it RETURNS IMMEDIATELY, before the kit has any content: that empty kit is the handle, and the fields fill in over the next minute or two, so poll extractionStatus with get_brand_kit rather than assuming it failed. name is OPTIONAL when websiteUrl is given (it defaults to the site's hostname until extraction finds the real business name). Brand kits are capped by plan, so this fails with a limit error near the cap, and a duplicate counts against it like any other kit. Requires the brandkit:write scope.",
757
756
  inputSchema: {
758
757
  name: z.string().optional().describe("The kit's name. Optional when websiteUrl is given."),
759
758
  websiteUrl: z.string().optional().describe('The business website. Required to use extract.'),
@@ -771,6 +770,12 @@ export function registerTools(server, opts) {
771
770
  visualStyle: z.string().optional(),
772
771
  designPrinciples: z.array(z.string()).optional(),
773
772
  contentStrategy: z.record(z.string(), z.unknown()).optional().describe('Content strategy object (free-form).'),
773
+ logos: z.array(z.unknown()).optional().describe("The kit's logos, each { url | outputId, name?, is_primary?, layout?, colorMode? }. Use outputId to bring in a generation."),
774
+ assets: z.array(z.unknown()).optional().describe("The kit's brand assets, each { url | outputId, name? }."),
775
+ sections: z
776
+ .array(z.unknown())
777
+ .optional()
778
+ .describe("The kit's curated sections, each { tab, sectionName, sortOrder?, fields? }. Array position is the default order."),
774
779
  },
775
780
  }, async (args, extra) => {
776
781
  try {
@@ -781,7 +786,13 @@ export function registerTools(server, opts) {
781
786
  if (args.extract && !args.websiteUrl) {
782
787
  return errorResult(new Error('create_brand_kit: extract requires a websiteUrl to scrape.'));
783
788
  }
784
- const { brandKit, extraction } = await client.createBrandKit(args);
789
+ const { logos, assets, sections, ...rest } = args;
790
+ const { brandKit, extraction } = await client.createBrandKit({
791
+ ...rest,
792
+ ...(logos !== undefined ? { logos } : {}),
793
+ ...(assets !== undefined ? { assets } : {}),
794
+ ...(sections !== undefined ? { sections: sections } : {}),
795
+ });
785
796
  return brandKitResult(brandKit, extraction);
786
797
  }
787
798
  catch (err) {
@@ -792,7 +803,7 @@ export function registerTools(server, opts) {
792
803
  server.registerTool('update_brand_kit', {
793
804
  title: 'Update Brand Kit',
794
805
  annotations: WRITE,
795
- description: "Update a brand kit: identity fields (business name, positioning, audience, voice profile, visual style, content strategy), its brand media, which kit is the DEFAULT, and which tracked accounts it is LINKED to. Only the fields you pass change. Get the current kit first with get_brand_kit. Requires the brandkit:write scope. THREE MODES, chosen by what you pass: (1) pass brandKitId to patch one kit; (2) pass orderedIds ALONE to reorder the whole set, which is collection-level because ordering is a property of the set and a per-kit position would let two kits claim one slot, so pass every id in the order you want; (3) pass brandKitId + extract:true to RE-RUN website extraction, which returns immediately and fills the kit in the background from its websiteUrl (poll extractionStatus via get_brand_kit). logos/assets/brandAccountIds/inspirationAccountIds are DECLARATIVE: a patch REPLACES the whole list, so pass the full set and use [] to clear. brandAccountIds are the account owner's OWN profiles (performance), inspirationAccountIds are competitors and creators they watch; they are separate lists because they mean opposite things. isDefault only accepts true (passing false would leave the account with no default at all, so to move the default, name the kit that should hold it).",
806
+ description: "Update a brand kit: identity fields (business name, positioning, audience, voice profile, visual style, content strategy), its brand media, which kit is the DEFAULT, and which tracked accounts it is LINKED to. Only the fields you pass change. Get the current kit first with get_brand_kit. Requires the brandkit:write scope. THREE MODES, chosen by what you pass: (1) pass brandKitId to patch one kit; (2) pass orderedIds ALONE to reorder the whole set, which is collection-level because ordering is a property of the set and a per-kit position would let two kits claim one slot, so pass every id in the order you want; (3) pass brandKitId + extract:true to RE-RUN website extraction, which returns immediately and fills the kit in the background from its websiteUrl (poll extractionStatus via get_brand_kit). logos/assets/sections/brandAccountIds/inspirationAccountIds are DECLARATIVE: a patch REPLACES the whole list, so pass the full set and use [] to clear. THIS IS ALSO HOW YOU ADD NEW MEDIA TO A KIT: a logo or asset entry names either a url it already has, or outputId to bring in a generation that is not in the kit yet ('<id>', or '<id>-2' for variation 2 of a batch), whose bytes get COPIED into the kit so trashing that generation later cannot empty it. To add a logo, read the kit, append one entry, and send the whole list back; sending an outputId twice adds it twice. brandAccountIds are the account owner's OWN profiles (performance), inspirationAccountIds are competitors and creators they watch; they are separate lists because they mean opposite things. isDefault only accepts true (passing false would leave the account with no default at all, so to move the default, name the kit that should hold it).",
796
807
  inputSchema: {
797
808
  brandKitId: z.string().optional().describe('The brand kit id. Omit ONLY when reordering with orderedIds.'),
798
809
  orderedIds: z
@@ -803,8 +814,12 @@ export function registerTools(server, opts) {
803
814
  .boolean()
804
815
  .optional()
805
816
  .describe('Re-run website extraction for this kit. Returns immediately; poll extractionStatus.'),
806
- logos: z.array(z.unknown()).optional().describe('The kit\'s logos. REPLACES the list; [] clears it.'),
807
- assets: z.array(z.unknown()).optional().describe('The kit\'s brand assets. REPLACES the list; [] clears it.'),
817
+ logos: z.array(z.unknown()).optional().describe('The kit\'s logos, each { url | outputId, name?, is_primary?, layout?: horizontal|stacked|icon|wordmark, colorMode?: full_color|light|dark|grayscale }. REPLACES the list; [] clears it. Exactly one ends up primary (the kit\'s cover); name none and the first wins.'),
818
+ assets: z.array(z.unknown()).optional().describe('The kit\'s brand assets, each { url | outputId, name? }. REPLACES the list; [] clears it.'),
819
+ sections: z
820
+ .array(z.unknown())
821
+ .optional()
822
+ .describe("The kit's curated sections, each { tab, sectionName, sortOrder?, fields? }. REPLACES the set, keyed by (tab, sectionName); a section left out is ARCHIVED, never deleted. Array position is the default order."),
808
823
  isDefault: z.literal(true).optional().describe('Make this the default kit, un-defaulting every other.'),
809
824
  brandAccountIds: z
810
825
  .array(z.string())
@@ -829,7 +844,15 @@ export function registerTools(server, opts) {
829
844
  }, async (args, extra) => {
830
845
  try {
831
846
  const client = await getClient(extra);
832
- const { brandKitId, orderedIds, extract, ...input } = args;
847
+ const { brandKitId, orderedIds, extract, logos, assets, sections, ...rest } = args;
848
+ // The declarative arrays are `unknown[]` in the schema (their entries are free-form objects the
849
+ // server validates), so they are cast at this one boundary rather than restating the shape in zod.
850
+ const input = {
851
+ ...rest,
852
+ ...(logos !== undefined ? { logos } : {}),
853
+ ...(assets !== undefined ? { assets } : {}),
854
+ ...(sections !== undefined ? { sections: sections } : {}),
855
+ };
833
856
  // Reorder is the collection-level mode and takes no kit id at all.
834
857
  if (orderedIds && !brandKitId) {
835
858
  return brandKitListResult(await client.reorderBrandKits(orderedIds));
@@ -856,56 +879,7 @@ export function registerTools(server, opts) {
856
879
  }
857
880
  });
858
881
  // -- add_brand_kit_section ------------------------------------------------
859
- server.registerTool('add_brand_kit_section', {
860
- title: 'Add Brand Kit Section',
861
- annotations: WRITE,
862
- description: "Add a curated section to a brand kit (a tab + name + a list of fields). Fields are objects like { key, label, type, value }. Requires the brandkit:write scope.",
863
- inputSchema: {
864
- brandKitId: z.string().describe('The brand kit id.'),
865
- tab: z.string().describe('The tab the section belongs to (e.g. "voice", "overview").'),
866
- sectionName: z.string().describe('The section title.'),
867
- sortOrder: z.number().int().optional().describe('Order within the tab (default 99 = end).'),
868
- fields: z.array(z.record(z.string(), z.unknown())).optional().describe('Field objects: { key, label, type, value }.'),
869
- },
870
- }, async (args, extra) => {
871
- try {
872
- const client = await getClient(extra);
873
- return brandKitSectionResult(await client.addBrandKitSection(args.brandKitId, {
874
- tab: args.tab,
875
- sectionName: args.sectionName,
876
- sortOrder: args.sortOrder,
877
- fields: args.fields,
878
- }), 'Added section');
879
- }
880
- catch (err) {
881
- return errorResult(err);
882
- }
883
- });
884
882
  // -- update_brand_kit_section ---------------------------------------------
885
- server.registerTool('update_brand_kit_section', {
886
- title: 'Update Brand Kit Section',
887
- annotations: WRITE,
888
- description: "Update a brand-kit section's name, order, or fields. Pass the full fields array to replace it. Requires the brandkit:write scope.",
889
- inputSchema: {
890
- brandKitId: z.string().describe('The brand kit id.'),
891
- sectionId: z.string().describe('The section id (from get_brand_kit).'),
892
- sectionName: z.string().optional(),
893
- sortOrder: z.number().int().optional(),
894
- fields: z.array(z.record(z.string(), z.unknown())).optional().describe('Replacement field objects.'),
895
- },
896
- }, async (args, extra) => {
897
- try {
898
- const client = await getClient(extra);
899
- return brandKitSectionResult(await client.updateBrandKitSection(args.brandKitId, args.sectionId, {
900
- sectionName: args.sectionName,
901
- sortOrder: args.sortOrder,
902
- fields: args.fields,
903
- }), 'Updated section');
904
- }
905
- catch (err) {
906
- return errorResult(err);
907
- }
908
- });
909
883
  // -- search_brand_knowledge -----------------------------------------------
910
884
  server.registerTool('search_brand_knowledge', {
911
885
  title: 'Search Brand Knowledge',
@@ -1141,20 +1115,59 @@ export function registerTools(server, opts) {
1141
1115
  return errorResult(err);
1142
1116
  }
1143
1117
  });
1118
+ /**
1119
+ * One item's universal identity. NO folder_id: the folder is named by the tool's own folder_id /
1120
+ * folder_ids now, which is what lets one call file many items into many folders.
1121
+ */
1122
+ const itemRefBodySchema = z.object({
1123
+ source_table: z.string().describe("The item's source table (e.g. as returned by search_media)."),
1124
+ source_record_id: z.string().describe("The item's source record id."),
1125
+ variant: z.number().int().optional().describe('The variation index (default 0 for single-asset items).'),
1126
+ });
1144
1127
  server.registerTool('update_folder', {
1145
1128
  title: 'Update Folder',
1146
1129
  annotations: WRITE,
1147
- 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.",
1130
+ description: "Update the account's own folders: rename one, MOVE folders under a different parent (or to the top level with a null parent), change a smart folder's saved query, and FILE or UNFILE items. addItems/removeItems are DELTAS of { source_table, source_record_id, variant? }, not a list to replace, because an item can sit in several folders at once and a replace would silently unfile it from the others. Filing never moves or copies anything: it adds a pointer, and only manual folders accept items (a smart folder computes its own membership). Pass folderIds to patch several folders at once, which crossed with addItems files the same items into all of them; renaming and re-querying still need exactly one folder. NOTE the asymmetry: nesting a FOLDER via parentId is a MOVE (a folder has one parent), while filing an ITEM is a pointer that leaves its other folders alone.",
1148
1131
  inputSchema: {
1149
1132
  folder_id: z.string().describe('The folder id to update.'),
1133
+ folder_ids: z
1134
+ .array(z.string())
1135
+ .optional()
1136
+ .describe('Patch several folders at once. Attribute fields (name, query) still need exactly one.'),
1150
1137
  name: z.string().optional().describe('A new name.'),
1151
- parent_id: z.string().nullable().optional().describe('A new parent folder id, or null to move to the top level.'),
1138
+ parent_id: z.string().nullable().optional().describe('A new parent folder id, or null to move to the top level. MOVES the folder.'),
1152
1139
  query: smartQuerySchema,
1140
+ add_items: z
1141
+ .array(itemRefBodySchema)
1142
+ .optional()
1143
+ .describe('File these items into the folder(s). A delta: their other folders are untouched.'),
1144
+ remove_items: z
1145
+ .array(itemRefBodySchema)
1146
+ .optional()
1147
+ .describe('Unfile these items. Only the pointer goes; the asset is never deleted.'),
1153
1148
  },
1154
1149
  }, async (args, extra) => {
1155
1150
  try {
1156
- const f = await (await getClient(extra)).updateFolder(args.folder_id, { name: args.name, parentId: args.parent_id, query: args.query });
1157
- return text(`Updated folder "${f.name}" (id ${f.id}).`);
1151
+ const client = await getClient(extra);
1152
+ const patch = {
1153
+ name: args.name,
1154
+ parentId: args.parent_id,
1155
+ query: args.query,
1156
+ addItems: args.add_items?.map((r) => ({ sourceTable: r.source_table, sourceRecordId: r.source_record_id, variant: r.variant })),
1157
+ removeItems: args.remove_items?.map((r) => ({ sourceTable: r.source_table, sourceRecordId: r.source_record_id, variant: r.variant })),
1158
+ };
1159
+ const targets = args.folder_ids?.length ? args.folder_ids : [args.folder_id];
1160
+ const folders = targets.length > 1
1161
+ ? await client.updateFolders(targets, patch)
1162
+ : [await client.updateFolder(targets[0], patch)];
1163
+ const filed = args.add_items?.length ?? 0;
1164
+ const unfiled = args.remove_items?.length ?? 0;
1165
+ const what = [
1166
+ filed ? `filed ${filed} item(s)` : null,
1167
+ unfiled ? `unfiled ${unfiled} item(s)` : null,
1168
+ ].filter(Boolean).join(', ');
1169
+ const names = folders.map((f) => `"${f.name}" (id ${f.id})`).join(', ');
1170
+ return text(`Updated ${folders.length === 1 ? 'folder' : `${folders.length} folders`} ${names}${what ? `: ${what}` : '.'}`);
1158
1171
  }
1159
1172
  catch (err) {
1160
1173
  return errorResult(err);
@@ -1174,40 +1187,6 @@ export function registerTools(server, opts) {
1174
1187
  return errorResult(err);
1175
1188
  }
1176
1189
  });
1177
- const itemRefSchema = {
1178
- folder_id: z.string().describe('The folder id.'),
1179
- source_table: z.string().describe("The item's source table (e.g. as returned by search_media)."),
1180
- source_record_id: z.string().describe("The item's source record id."),
1181
- variant: z.number().int().optional().describe('The variation index (default 0 for single-asset items).'),
1182
- };
1183
- server.registerTool('add_to_folder', {
1184
- title: 'Add to Folder',
1185
- annotations: WRITE,
1186
- 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).',
1187
- inputSchema: itemRefSchema,
1188
- }, async (args, extra) => {
1189
- try {
1190
- await (await getClient(extra)).addToFolder(args.folder_id, { sourceTable: args.source_table, sourceRecordId: args.source_record_id, variant: args.variant });
1191
- return text('Filed into the folder.');
1192
- }
1193
- catch (err) {
1194
- return errorResult(err);
1195
- }
1196
- });
1197
- server.registerTool('remove_from_folder', {
1198
- title: 'Remove from Folder',
1199
- annotations: WRITE,
1200
- description: 'Remove an item from a manual folder by its universal identity. This unfiles the pointer only; the underlying asset is never deleted.',
1201
- inputSchema: itemRefSchema,
1202
- }, async (args, extra) => {
1203
- try {
1204
- await (await getClient(extra)).removeFromFolder(args.folder_id, { sourceTable: args.source_table, sourceRecordId: args.source_record_id, variant: args.variant });
1205
- return text('Removed from the folder.');
1206
- }
1207
- catch (err) {
1208
- return errorResult(err);
1209
- }
1210
- });
1211
1190
  // -- get_media ------------------------------------------------------------
1212
1191
  server.registerTool('get_media', {
1213
1192
  title: 'Get Media',
@@ -1488,35 +1467,17 @@ export function registerTools(server, opts) {
1488
1467
  server.registerTool('get_generation_status', {
1489
1468
  title: 'Get Generation Status',
1490
1469
  annotations: READ,
1491
- description: 'Get the current status of an image or video generation by its outputId (returned by generate_image / generate_video when a render is still in progress). Returns the final URLs once complete, otherwise the current status plus a poll_after_seconds hint. For a blocking wait on one or more outputIds, use wait_for_generation.',
1492
- inputSchema: {
1493
- outputId: z.string().describe('The outputId from generate_image or generate_video.'),
1494
- },
1495
- }, async (args, extra) => {
1496
- try {
1497
- const client = await getClient(extra);
1498
- const gen = await client.getGeneration(args.outputId);
1499
- return generationStatusResult(gen);
1500
- }
1501
- catch (err) {
1502
- return errorResult(err);
1503
- }
1504
- });
1505
- // -- wait_for_generation --------------------------------------------------
1506
- server.registerTool('wait_for_generation', {
1507
- title: 'Wait For Generation',
1508
- annotations: READ,
1509
- description: 'Wait for one or more in-progress generations (outputIds from generate_image / generate_video / upscale / generate_lip_sync / generate_board) to finish, and return their final URLs. Blocks up to ~50s per call; if a render is still running it returns the current status with a poll_after_seconds hint to call again. Pass wait=false for an instant status snapshot instead of blocking.',
1470
+ description: "Check one or more in-progress generations (outputIds from generate_image / generate_video / upscale / generate_lip_sync / generate_board) and get their final URLs. BY DEFAULT THIS BLOCKS until they finish, up to ~50s per call, because that is almost always what you want after starting a render; if one is still running it comes back with the current status and a poll_after_seconds hint, so call again. Pass wait:false for an instant snapshot with no blocking. Accepts 1-8 outputIds in one call.",
1510
1471
  inputSchema: {
1511
1472
  outputIds: z
1512
1473
  .array(z.string())
1513
1474
  .min(1)
1514
1475
  .max(8)
1515
- .describe('1-8 outputIds to wait on (each from a prior generate_* call).'),
1476
+ .describe('1-8 outputIds to check (each from a prior generate_* call).'),
1516
1477
  wait: z
1517
1478
  .boolean()
1518
1479
  .optional()
1519
- .describe('Block until terminal (up to ~50s) when true (the default). false = an instant snapshot, no blocking.'),
1480
+ .describe('Block until terminal (up to ~50s), the default. false = an instant snapshot.'),
1520
1481
  },
1521
1482
  }, async (args, extra) => {
1522
1483
  try {
@@ -1649,7 +1610,7 @@ export function registerTools(server, opts) {
1649
1610
  server.registerTool('update_post', {
1650
1611
  title: 'Update Post',
1651
1612
  annotations: WRITE,
1652
- description: "Update a post's fields: title, description, script, notes, status, platform, cover (coverUrl/coverOutputId), or pipeline stage (move it through the pipeline by passing `stage`). Requires the pipeline:write scope.",
1613
+ description: "Update a post: its fields (title, description, script, notes, status, platform, cover, pipeline stage), its DESTINATIONS (which platforms it publishes to), its ASSETS (the media on it, in order), and its SCHEDULE. destinations and assets are DECLARATIVE: pass the WHOLE set, because anything you leave out is removed. Destinations key on platform. Assets key on id, and THE ARRAY ORDER IS THE carousel ORDER, so reordering is just sending the same ids in a different order; keep an existing asset by id, add a new one by assetUrl or outputId. scheduledAt sets the time on the post AND every destination (pass null to clear); give a destination its own scheduledAt to override it for that platform. To publish NOW, use publish_post. Requires the pipeline:write scope.",
1653
1614
  inputSchema: {
1654
1615
  postId: z.string().describe('The post id.'),
1655
1616
  title: z.string().optional(),
@@ -1668,163 +1629,31 @@ export function registerTools(server, opts) {
1668
1629
  .array(z.string())
1669
1630
  .optional()
1670
1631
  .describe('Tag names to set on the post (must already exist; replaces the set). Omit to leave tags unchanged.'),
1671
- },
1672
- }, async (args, extra) => {
1673
- try {
1674
- const client = await getClient(extra);
1675
- const { postId, ...input } = args;
1676
- return postSummaryResult(await client.updatePost(postId, input), 'Updated');
1677
- }
1678
- catch (err) {
1679
- return errorResult(err);
1680
- }
1681
- });
1682
- // -- add_post_destination -------------------------------------------------
1683
- server.registerTool('add_post_destination', {
1684
- title: 'Add Post Destination',
1685
- annotations: WRITE,
1686
- 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.",
1687
- inputSchema: {
1688
- postId: z.string().describe('The post id.'),
1689
- platform: z.enum(POST_PLATFORMS).describe('Destination platform.'),
1690
- format: z.string().optional().describe("Platform format, e.g. 'post', 'reel', 'story', 'short', 'thread'."),
1691
- connectedAccountId: z.string().optional().describe('The connected account to publish through.'),
1692
- scheduledAt: z.string().optional().describe('ISO-8601 scheduled time for this destination.'),
1693
- platformSettings: z
1694
- .record(z.string(), z.unknown())
1695
- .optional()
1696
- .describe('Per-platform/per-format publish config (mediaItems, caption, thumbnails, privacy, etc.). Get the exact field shape for this platform + format from get_platform.'),
1697
- },
1698
- }, async (args, extra) => {
1699
- try {
1700
- const client = await getClient(extra);
1701
- return destinationResult(await client.addPostDestination(args.postId, {
1702
- platform: args.platform,
1703
- format: args.format,
1704
- connectedAccountId: args.connectedAccountId,
1705
- scheduledAt: args.scheduledAt,
1706
- platformSettings: args.platformSettings,
1707
- }));
1708
- }
1709
- catch (err) {
1710
- return errorResult(err);
1711
- }
1712
- });
1713
- // -- update_post_destination ----------------------------------------------
1714
- server.registerTool('update_post_destination', {
1715
- title: 'Update Post Destination',
1716
- annotations: WRITE,
1717
- description: "Update one of a post's destinations (format, connected account, scheduled time, status, or platformSettings). Pass platformSettings (the publish payload: media, caption, thumbnail, privacy) shaped to the platform + format; call get_platform for the exact fields. It replaces the destination's settings, so include the full object. Requires the pipeline:write scope.",
1718
- inputSchema: {
1719
- postId: z.string().describe('The post id.'),
1720
- destinationId: z.string().describe('The destination id (from get_post).'),
1721
- format: z.string().optional(),
1722
- connectedAccountId: z.string().optional(),
1723
- scheduledAt: z.string().optional().describe('ISO-8601 scheduled time, or empty to clear.'),
1724
- status: z.string().optional(),
1725
- platformSettings: z
1726
- .record(z.string(), z.unknown())
1727
- .optional()
1728
- .describe('Per-platform/per-format publish config (mediaItems, caption, thumbnails, privacy, etc.); replaces the existing settings. Get the field shape from get_platform.'),
1729
- },
1730
- }, async (args, extra) => {
1731
- try {
1732
- const client = await getClient(extra);
1733
- return destinationResult(await client.updatePostDestination(args.postId, args.destinationId, {
1734
- format: args.format,
1735
- connectedAccountId: args.connectedAccountId,
1736
- scheduledAt: args.scheduledAt,
1737
- status: args.status,
1738
- platformSettings: args.platformSettings,
1739
- }));
1740
- }
1741
- catch (err) {
1742
- return errorResult(err);
1743
- }
1744
- });
1745
- // -- add_post_asset -------------------------------------------------------
1746
- server.registerTool('add_post_asset', {
1747
- title: 'Add Post Asset',
1748
- annotations: WRITE,
1749
- description: "Attach an asset to a post, by outputId (generated or uploaded media, resolved to its URL) or by a public assetUrl. With outputId the assetType is inferred. Sets the post cover from the first image. Requires the assets:write scope.",
1750
- inputSchema: {
1751
- postId: z.string().describe('The post id.'),
1752
- outputId: z
1632
+ scheduledAt: z
1753
1633
  .string()
1634
+ .nullable()
1754
1635
  .optional()
1755
- .describe('A media token (output id, first-8, or "-N") of generated/uploaded media. Provide this or assetUrl.'),
1756
- assetUrl: z.string().optional().describe('Public URL of the asset. Provide this or outputId.'),
1757
- assetType: z
1758
- .enum(['image', 'video', 'audio', 'document', 'link'])
1636
+ .describe('ISO time to publish. Sets the post AND every destination. null clears the schedule.'),
1637
+ destinations: z
1638
+ .array(z.unknown())
1759
1639
  .optional()
1760
- .describe('The kind of asset. Required with assetUrl; inferred when using outputId.'),
1761
- displayName: z.string().optional().describe('Optional display name.'),
1762
- },
1763
- }, async (args, extra) => {
1764
- try {
1765
- const client = await getClient(extra);
1766
- return assetResult(await client.addPostAsset(args.postId, {
1767
- outputId: args.outputId,
1768
- assetType: args.assetType,
1769
- assetUrl: args.assetUrl,
1770
- displayName: args.displayName,
1771
- }));
1772
- }
1773
- catch (err) {
1774
- return errorResult(err);
1775
- }
1776
- });
1777
- // -- reorder_post_assets --------------------------------------------------
1778
- server.registerTool('reorder_post_assets', {
1779
- title: 'Reorder Post Assets',
1780
- annotations: WRITE,
1781
- description: "Set a post's asset order (e.g. the carousel slide order; the first image is the cover). Pass assetIds as ALL of the post's asset ids (from get_post) in the desired order. Requires the assets:write scope.",
1782
- inputSchema: {
1783
- postId: z.string().describe('The post id.'),
1784
- assetIds: z
1785
- .array(z.string())
1786
- .describe("All of the post's asset ids (from get_post), in the desired order."),
1787
- },
1788
- }, async (args, extra) => {
1789
- try {
1790
- const client = await getClient(extra);
1791
- return assetOrderResult(await client.reorderPostAssets(args.postId, args.assetIds));
1792
- }
1793
- catch (err) {
1794
- return errorResult(err);
1795
- }
1796
- });
1797
- // -- remove_post_asset ----------------------------------------------------
1798
- server.registerTool('remove_post_asset', {
1799
- title: 'Remove Post Asset',
1800
- annotations: WRITE,
1801
- description: 'Detach an asset from a post by its asset id (from get_post). Requires the assets:write scope.',
1802
- inputSchema: {
1803
- postId: z.string().describe('The post id.'),
1804
- assetId: z.string().describe('The asset id (from get_post).'),
1805
- },
1806
- }, async (args, extra) => {
1807
- try {
1808
- const client = await getClient(extra);
1809
- return assetRemovedResult(await client.removePostAsset(args.postId, args.assetId));
1810
- }
1811
- catch (err) {
1812
- return errorResult(err);
1813
- }
1814
- });
1815
- // -- remove_post_destination ----------------------------------------------
1816
- server.registerTool('remove_post_destination', {
1817
- title: 'Remove Post Destination',
1818
- annotations: WRITE,
1819
- description: 'Detach a publish destination from a post by its destination id (from get_post). Requires the pipeline:write scope.',
1820
- inputSchema: {
1821
- postId: z.string().describe('The post id.'),
1822
- destinationId: z.string().describe('The destination id (from get_post).'),
1640
+ .describe("The post's destinations, each { platform, format?, connectedAccountId?, platformSpecificData?, scheduledAt?, status? }. REPLACES the set, keyed by platform; [] detaches all."),
1641
+ assets: z
1642
+ .array(z.unknown())
1643
+ .optional()
1644
+ .describe("The post's assets IN ORDER, each { id } to keep an existing one or { assetUrl | outputId, assetType?, displayName? } to add. REPLACES the list; [] clears it."),
1823
1645
  },
1824
1646
  }, async (args, extra) => {
1825
1647
  try {
1826
1648
  const client = await getClient(extra);
1827
- return destinationRemovedResult(await client.removePostDestination(args.postId, args.destinationId));
1649
+ const { postId, destinations, assets, ...input } = args;
1650
+ // The two declarative arrays are `unknown[]` in the schema (their entries are free-form objects the
1651
+ // server validates), so they are cast at this one boundary rather than duplicating the shape in zod.
1652
+ return postSummaryResult(await client.updatePost(postId, {
1653
+ ...input,
1654
+ ...(destinations !== undefined ? { destinations: destinations } : {}),
1655
+ ...(assets !== undefined ? { assets: assets } : {}),
1656
+ }), 'Updated');
1828
1657
  }
1829
1658
  catch (err) {
1830
1659
  return errorResult(err);
@@ -1897,27 +1726,6 @@ export function registerTools(server, opts) {
1897
1726
  return errorResult(err);
1898
1727
  }
1899
1728
  });
1900
- // -- schedule_post --------------------------------------------------------
1901
- server.registerTool('schedule_post', {
1902
- title: 'Schedule Post',
1903
- annotations: WRITE,
1904
- description: 'Queue a post for future publishing: set the scheduled time on the post and all its destinations (pass scheduledAt=null to clear). This only queues; use publish_post to publish now. Requires the pipeline:write scope.',
1905
- inputSchema: {
1906
- postId: z.string().describe('The post id.'),
1907
- scheduledAt: z
1908
- .string()
1909
- .nullable()
1910
- .describe('ISO-8601 timestamp to schedule, or null to clear the schedule.'),
1911
- },
1912
- }, async (args, extra) => {
1913
- try {
1914
- const client = await getClient(extra);
1915
- return postSummaryResult(await client.schedulePost(args.postId, args.scheduledAt), 'Scheduled');
1916
- }
1917
- catch (err) {
1918
- return errorResult(err);
1919
- }
1920
- });
1921
1729
  // -- publish_post ---------------------------------------------------------
1922
1730
  server.registerTool('publish_post', {
1923
1731
  title: 'Publish Post',
@@ -1936,52 +1744,77 @@ export function registerTools(server, opts) {
1936
1744
  return errorResult(err);
1937
1745
  }
1938
1746
  });
1939
- // -- list_inspiration_accounts --------------------------------------------
1940
- server.registerTool('list_inspiration_accounts', {
1941
- title: 'List Inspiration Accounts',
1747
+ // -- list_accounts --------------------------------------------------------
1748
+ server.registerTool('list_accounts', {
1749
+ title: 'List Tracked Accounts',
1942
1750
  annotations: READ,
1943
- description: "List the creators/competitors the account tracks for inspiration. Use these as grounding for research; call list_outliers for their top content or get_inspiration_account for one account's detail. Pass brandKitId to scope to the inspiration accounts linked to a specific brand kit.",
1751
+ description: "List the social accounts this ContentHero account tracks. TWO KINDS, in one list: accountType 'inspiration' is the creators and competitors they watch for research, 'brand' is their OWN profiles (distinct from list_brand_kits, which are the brand identity documents). Every row reports its own accountType, so omit the filter to see both. Call get_account for one account's performance, or list_content for the posts. Pass brandKitId to scope to the accounts linked to a specific brand kit.",
1944
1752
  inputSchema: {
1945
- brandKitId: z.string().optional().describe('Scope to the inspiration accounts linked to this brand kit (from get_brand_kit).'),
1753
+ accountType: z
1754
+ .enum(['inspiration', 'brand'])
1755
+ .optional()
1756
+ .describe("Narrow to one kind. Omitted, both come back."),
1757
+ brandKitId: z.string().optional().describe('Scope to the accounts linked to this brand kit (from get_brand_kit).'),
1946
1758
  },
1947
1759
  }, async (args, extra) => {
1948
1760
  try {
1949
1761
  const client = await getClient(extra);
1950
- return trackedAccountListResult(await client.listInspirationAccounts({ brandKitId: args.brandKitId }), 'inspiration account(s)');
1762
+ return trackedAccountListResult(await client.listAccounts(args));
1951
1763
  }
1952
1764
  catch (err) {
1953
1765
  return errorResult(err);
1954
1766
  }
1955
1767
  });
1956
- // -- get_inspiration_account ----------------------------------------------
1957
- server.registerTool('get_inspiration_account', {
1958
- title: 'Get Inspiration Account',
1768
+ // -- get_account ----------------------------------------------------------
1769
+ server.registerTool('get_account', {
1770
+ title: 'Get Tracked Account',
1959
1771
  annotations: READ,
1960
- description: "Get one tracked inspiration account with its content count and a few top outliers (by score). Use it to study a specific creator.",
1772
+ description: "Get one tracked account with how its content actually performs: post count, total and average views/likes/comments, average engagement and outlier score, plus its top posts by outlier score and its most recent ones. Works for either kind of account: use it on one of the owner's OWN accounts to ground decisions in their real numbers, or on a creator they watch to study what works for that creator.",
1961
1773
  inputSchema: {
1962
- accountId: z.string().describe('The account id from list_inspiration_accounts.'),
1774
+ accountId: z.string().describe('The account id from list_accounts.'),
1963
1775
  },
1964
1776
  }, async (args, extra) => {
1965
1777
  try {
1966
1778
  const client = await getClient(extra);
1967
- return inspirationAccountResult(await client.getInspirationAccount(args.accountId));
1779
+ const detail = await client.getAccount(args.accountId);
1780
+ return accountDetailResult(detail);
1968
1781
  }
1969
1782
  catch (err) {
1970
1783
  return errorResult(err);
1971
1784
  }
1972
1785
  });
1973
- // -- list_outliers --------------------------------------------------------
1974
- server.registerTool('list_outliers', {
1975
- title: 'List Outliers',
1786
+ // -- list_content ---------------------------------------------------------
1787
+ server.registerTool('list_content', {
1788
+ title: 'List Tracked Content',
1976
1789
  annotations: READ,
1977
- 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.",
1790
+ description: "The core research read: social posts this account tracks, ranked by OUTLIER SCORE (how far a post overperformed its own creator's baseline, so a small account's hit still surfaces). SPANS BOTH the creators they watch and their OWN posts by default; set scope to narrow, and every row carries isOwn either way. This is how you answer both \"what is working for the people I watch\" and \"how did my own posts do\" without picking a subsystem first. Filter by platform, content type, a published window (publicationDate like 'week' or 'month', or exact publishedAfter/publishedBefore), and ranges over score, views, duration and follower count. Call get_content for one post in full, including its transcript.",
1978
1791
  inputSchema: {
1792
+ scope: z
1793
+ .enum(['all', 'inspiration', 'brand'])
1794
+ .optional()
1795
+ .describe("'inspiration' = creators they watch, 'brand' = their own accounts, 'all' = both (default)."),
1979
1796
  platform: z.enum(['youtube', 'instagram']).optional().describe('Filter to one platform.'),
1980
1797
  contentType: z.string().optional().describe("Filter by content type, e.g. 'video', 'short', 'reel'."),
1981
- minOutlierScore: z.number().optional().describe('Only content at or above this outlier score.'),
1798
+ outlierScoreMin: z.number().optional().describe('Only content at or above this outlier score.'),
1799
+ outlierScoreMax: z.number().optional().describe('Only content at or below this outlier score.'),
1800
+ viewsMin: z.number().optional(),
1801
+ viewsMax: z.number().optional(),
1802
+ durationMin: z.number().optional().describe('Minimum duration in seconds.'),
1803
+ durationMax: z.number().optional().describe('Maximum duration in seconds.'),
1804
+ subscribersMin: z.number().optional().describe("Minimum follower count of the post's account."),
1805
+ subscribersMax: z.number().optional().describe("Maximum follower count of the post's account."),
1806
+ publicationDate: z
1807
+ .enum(['week', 'month', '3months', '6months', 'year', '2years'])
1808
+ .optional()
1809
+ .describe('Published within this window. Use publishedAfter for an exact date instead.'),
1810
+ publishedAfter: z.string().optional().describe('ISO timestamp. Wins over publicationDate.'),
1811
+ publishedBefore: z.string().optional().describe('ISO timestamp.'),
1982
1812
  search: z.string().optional().describe('Text search across title, creator, handle, and description.'),
1983
- sortBy: z.enum(['score', 'date', 'views']).optional().describe("Sort order (default 'score')."),
1984
- brandKitId: z.string().optional().describe('Scope to the inspiration accounts linked to this brand kit (from get_brand_kit).'),
1813
+ sortBy: z.enum(['score', 'date', 'views', 'engagement']).optional().describe("Sort field (default 'score')."),
1814
+ sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort direction (default 'desc')."),
1815
+ accountIds: z.array(z.string()).optional().describe('Limit to these tracked account ids (from list_accounts).'),
1816
+ addedByYou: z.boolean().optional().describe('Only the one-off posts the owner saved by url.'),
1817
+ brandKitId: z.string().optional().describe('Scope to the accounts linked to this brand kit.'),
1985
1818
  favorited: z.boolean().optional().describe('Only content the account has favorited.'),
1986
1819
  limit: z.number().int().min(1).max(100).optional().describe('How many to return (default 20).'),
1987
1820
  offset: z.number().int().min(0).optional().describe('Pagination offset.'),
@@ -1989,68 +1822,35 @@ export function registerTools(server, opts) {
1989
1822
  }, async (args, extra) => {
1990
1823
  try {
1991
1824
  const client = await getClient(extra);
1992
- return outlierListResult(await client.listOutliers({
1993
- platform: args.platform,
1994
- contentType: args.contentType,
1995
- minOutlierScore: args.minOutlierScore,
1996
- search: args.search,
1997
- sortBy: args.sortBy,
1998
- brandKitId: args.brandKitId,
1999
- favorited: args.favorited,
2000
- limit: args.limit,
2001
- offset: args.offset,
2002
- }));
1825
+ return outlierListResult(await client.listContent(args));
2003
1826
  }
2004
1827
  catch (err) {
2005
1828
  return errorResult(err);
2006
1829
  }
2007
1830
  });
2008
- // -- get_inspiration_content ----------------------------------------------
2009
- server.registerTool('get_inspiration_content', {
2010
- title: 'Get Inspiration Content',
1831
+ // -- get_content ----------------------------------------------------------
1832
+ server.registerTool('get_content', {
1833
+ title: 'Get Tracked Content',
2011
1834
  annotations: READ,
2012
- description: "Get one tracked-content item in full: engagement stats, outlier score, hashtags, and the transcript when available. Use it to study exactly what a high-performing post says and does.",
1835
+ description: "Get one tracked post in full: engagement stats, outlier score, hashtags, keywords, mentions and audio info. Works for a creator's post and for the owner's own. THE TRANSCRIPT IS OPT-IN because a long video is a large document: pass transcript='text' for the whole thing, or transcript='segments' for timed slices, and then narrow with startMs/endMs or transcriptSearch to pull only the part that matters. The transcript reports a status: 'complete', 'not_applicable' (there is nothing to transcribe), 'failed' (it will be retried), 'processing', or 'absent' (never attempted), so an empty result is never ambiguous.",
2013
1836
  inputSchema: {
2014
- contentId: z.string().describe('The content id from list_outliers or get_inspiration_account.'),
2015
- },
2016
- }, async (args, extra) => {
2017
- try {
2018
- const client = await getClient(extra);
2019
- return inspirationContentResult(await client.getInspirationContent(args.contentId));
2020
- }
2021
- catch (err) {
2022
- return errorResult(err);
2023
- }
2024
- });
2025
- // -- list_brand_accounts --------------------------------------------------
2026
- server.registerTool('list_brand_accounts', {
2027
- title: 'List Brand Accounts',
2028
- annotations: READ,
2029
- description: "List the account owner's OWN connected social accounts that ContentHero tracks for performance (distinct from list_brand_kits, which are the brand identity documents). Call get_brand_account_performance for one account's stats. Pass brandKitId to scope to the brand accounts linked to a specific brand kit.",
2030
- inputSchema: {
2031
- brandKitId: z.string().optional().describe('Scope to the brand accounts linked to this brand kit (from get_brand_kit).'),
2032
- },
2033
- }, async (args, extra) => {
2034
- try {
2035
- const client = await getClient(extra);
2036
- return trackedAccountListResult(await client.listBrandAccounts({ brandKitId: args.brandKitId }), 'brand account(s)');
2037
- }
2038
- catch (err) {
2039
- return errorResult(err);
2040
- }
2041
- });
2042
- // -- get_brand_account_performance ----------------------------------------
2043
- server.registerTool('get_brand_account_performance', {
2044
- title: 'Get Brand Account Performance',
2045
- annotations: READ,
2046
- description: "Get the performance summary for one of the owner's brand accounts: content count, total and average views/likes/comments, average engagement and outlier score, plus top and recent content. Use it to ground decisions in how the owner's own content actually performs.",
2047
- inputSchema: {
2048
- accountId: z.string().describe('The account id from list_brand_accounts.'),
1837
+ contentId: z.string().describe('The content id from list_content or get_account.'),
1838
+ transcript: z
1839
+ .enum(['none', 'text', 'segments'])
1840
+ .optional()
1841
+ .describe("How much transcript to include. Default 'none'."),
1842
+ startMs: z.number().optional().describe('Window start, ms from the start of the media. Implies segments.'),
1843
+ endMs: z.number().optional().describe('Window end, ms from the start of the media. Implies segments.'),
1844
+ transcriptSearch: z
1845
+ .string()
1846
+ .optional()
1847
+ .describe('Return only the segments containing this phrase. Implies segments.'),
2049
1848
  },
2050
1849
  }, async (args, extra) => {
2051
1850
  try {
2052
1851
  const client = await getClient(extra);
2053
- return brandPerformanceResult(await client.getBrandAccountPerformance(args.accountId));
1852
+ const { contentId, ...options } = args;
1853
+ return inspirationContentResult(await client.getContent(contentId, options));
2054
1854
  }
2055
1855
  catch (err) {
2056
1856
  return errorResult(err);
@@ -2108,7 +1908,7 @@ export function registerTools(server, opts) {
2108
1908
  server.registerTool('favorite', {
2109
1909
  title: 'Favorite',
2110
1910
  annotations: WRITE,
2111
- 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.",
1911
+ description: "Favorite or UNfavorite an asset: pass favorited:false to clear it (default true). 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 in both directions.",
2112
1912
  inputSchema: {
2113
1913
  assetType: z
2114
1914
  .enum(['post', 'voice', 'brand_kit', 'project', 'inspiration_content', 'gallery', 'transition'])
@@ -2121,50 +1921,25 @@ export function registerTools(server, opts) {
2121
1921
  .min(1)
2122
1922
  .optional()
2123
1923
  .describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
1924
+ favorited: z.boolean().optional().describe('Default true. Pass false to UNfavorite.'),
2124
1925
  },
2125
1926
  }, async (args, extra) => {
2126
1927
  try {
2127
1928
  const client = await getClient(extra);
2128
- await client.favorite({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
2129
- return statusActionResult('Favorited', args);
1929
+ const favorited = args.favorited ?? true;
1930
+ await client.favorite({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex, favorited });
1931
+ return statusActionResult(favorited ? 'Favorited' : 'Unfavorited', args);
2130
1932
  }
2131
1933
  catch (err) {
2132
1934
  return errorResult(err);
2133
1935
  }
2134
1936
  });
2135
1937
  // -- unfavorite -----------------------------------------------------------
2136
- server.registerTool('unfavorite', {
2137
- title: 'Unfavorite',
2138
- annotations: WRITE,
2139
- 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.',
2140
- inputSchema: {
2141
- assetType: z
2142
- .enum(['post', 'voice', 'brand_kit', 'project', 'inspiration_content', 'gallery', 'transition'])
2143
- .optional()
2144
- .describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
2145
- id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
2146
- variationIndex: z
2147
- .number()
2148
- .int()
2149
- .min(1)
2150
- .optional()
2151
- .describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
2152
- },
2153
- }, async (args, extra) => {
2154
- try {
2155
- const client = await getClient(extra);
2156
- await client.unfavorite({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
2157
- return statusActionResult('Unfavorited', args);
2158
- }
2159
- catch (err) {
2160
- return errorResult(err);
2161
- }
2162
- });
2163
1938
  // -- archive --------------------------------------------------------------
2164
1939
  server.registerTool('archive', {
2165
1940
  title: 'Archive',
2166
1941
  annotations: WRITE,
2167
- 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.",
1942
+ description: "Archive or UNarchive an asset: pass archived:false to restore it (default true). ContentHero never hard-deletes, so this is always reversible. For a top-level asset, pass assetType + id (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'; restoring returns it to 'draft'. Requires the favorites:write scope. Idempotent in both directions.",
2168
1943
  inputSchema: {
2169
1944
  assetType: z
2170
1945
  .enum(['post', 'brand_kit', 'brand_kit_section', 'project'])
@@ -2177,48 +1952,20 @@ export function registerTools(server, opts) {
2177
1952
  .min(1)
2178
1953
  .optional()
2179
1954
  .describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
1955
+ archived: z.boolean().optional().describe('Default true. Pass false to RESTORE (unarchive).'),
2180
1956
  },
2181
1957
  }, async (args, extra) => {
2182
1958
  try {
2183
1959
  const client = await getClient(extra);
2184
- await client.archive({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
2185
- return statusActionResult('Archived', args);
1960
+ const archived = args.archived ?? true;
1961
+ await client.archive({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex, archived });
1962
+ return statusActionResult(archived ? 'Archived' : 'Unarchived', args);
2186
1963
  }
2187
1964
  catch (err) {
2188
1965
  return errorResult(err);
2189
1966
  }
2190
1967
  });
2191
1968
  // -- unarchive ------------------------------------------------------------
2192
- server.registerTool('unarchive', {
2193
- title: 'Unarchive',
2194
- annotations: WRITE,
2195
- 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.",
2196
- inputSchema: {
2197
- assetType: z
2198
- .enum(['post', 'brand_kit', 'brand_kit_section', 'project'])
2199
- .optional()
2200
- .describe('The kind of asset. Required unless targeting a media variation via variationIndex.'),
2201
- id: z.string().describe('The asset id (or studio output id when using variationIndex).'),
2202
- variationIndex: z
2203
- .number()
2204
- .int()
2205
- .min(1)
2206
- .optional()
2207
- .describe('1-based studio media variation slot. When set, id is a studio output id and assetType is ignored.'),
2208
- },
2209
- }, async (args, extra) => {
2210
- try {
2211
- const client = await getClient(extra);
2212
- await client.unarchive({ assetType: args.assetType, id: args.id, variationIndex: args.variationIndex });
2213
- return statusActionResult('Unarchived', args);
2214
- }
2215
- catch (err) {
2216
- return errorResult(err);
2217
- }
2218
- });
2219
- // ===========================================================================
2220
- // Editor / canvas ops (programmatic parity with the manual UI + in-app agent)
2221
- // ===========================================================================
2222
1969
  server.registerTool('list_projects', {
2223
1970
  title: 'List Projects',
2224
1971
  annotations: READ,
@@ -2528,7 +2275,7 @@ export function registerTools(server, opts) {
2528
2275
  server.registerTool('update_timeline', {
2529
2276
  title: 'Update Timeline',
2530
2277
  annotations: WRITE,
2531
- 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.",
2278
+ description: "Apply a batch of ops to an EDITOR (video timeline) project. update_timeline both CREATES and EDITS. EDIT ops act on existing clips: disable_ranges, delete_ranges, merge_clips, move_clip, trim_clip, split, delete_clip, duplicate, set_disabled, set_hidden, set_locked, group, ungroup, update_group, update_clip. CREATE ops add new clips/tracks: create_clip ({ op: 'create_clip', trackId, clip }) appends a clip to a track; insert_track ({ op: 'insert_track', referenceTrackId, position: 'above'|'below', trackType }) adds an empty track; insert_prebuilt_track ({ op: 'insert_prebuilt_track', index, track: { id, name, items: [item], trackType } }) inserts a whole track WITH its clips in one op (index 0 = top overlay) - the one-shot way to drop a graphic/text/shape onto a project without an existing empty track. TRANSITIONS: add_transition ({ op: 'add_transition', trackId, leftClipId, rightClipId, preset, durationFrames?, timing? }) adds a transition at the CUT between two TOUCHING adjacent clips (leftClipId's out-point meets rightClipId's in-point); preset is fade | crossfade | slide-left|right|up|down | wipe-left|right|up|down | flip | iris | clock-wipe; durationFrames defaults to 1s; one transition per cut (re-creating on the same pair replaces it); the result carries createdTransitionId. update_transition ({ op: 'update_transition', transitionId, patch: { preset?, durationFrames?, timing? } }) and remove_transition ({ op: 'remove_transition', transitionId }) edit or remove one (transition ids are on each track.transitions[] in get_project). ANIMATIONS: add_animation ({ op: 'add_animation', clipId, edge: 'in'|'out', preset, durationFrames?, timing? }) gives a single clip an ENTRANCE (edge 'in') or EXIT (edge 'out') animation, the clip animating against emptiness on that edge; preset is fade | slide-left|right|up|down | wipe-left|right|up|down | flip | iris | clock-wipe (single-clip presets, NOT crossfade which blends two clips); durationFrames defaults to 15 (0.5s at 30fps), timing 'linear' (default) or 'spring'; idempotent, a per-clip field keyed by clipId + edge so it creates or updates that edge's animation. remove_animation ({ op: 'remove_animation', clipId, edge: 'in'|'out' }) clears it. Choose add_animation when the motion belongs to ONE clip against emptiness; choose add_transition for a blend BETWEEN two adjacent clips. BACKGROUND REMOVAL: remove_background ({ op: 'remove_background', clipId }) cuts out the background of an IMAGE or VIDEO clip, replacing it with transparency, as an ASYNC job: the result carries a generatingOutputId to poll with get_generation_status, and the clip's media swaps to the transparent cutout when it completes (the original is kept, so it stays restorable). Image removal is FREE; video removal is a PREMIUM metered feature (Champion+, charged per second, 60s cap) and returns an error if the plan or credits are insufficient. Only image/video clips have a background; other clip types return an error. MASKS: to add a CapCut-style shape mask to an IMAGE or VIDEO clip, set its `masks` array via update_clip (or update_clips) - each entry is a ClipMask cutout (shape, normalized position/size, rotation, feather, invert) that keeps only the pixels inside its shape, multiple masks union, and invert:true subtracts; masks are a clip PROPERTY, not an op, so patch them like any other field (a patch REPLACES the whole array; set [] to clear), and read get_timeline_types for the exact ClipMask shape. CAPTIONS: add_captions ({ op: 'add_captions', style?, clipIds? }) generates word-timed captions from the project's transcript, one block per spoken clip on a dedicated caption track - whole-timeline by default, or pass clipIds to scope; `style` is a caption template key (omit for the default); clips without a ready transcript are skipped and reported in the result warnings (media is normally transcribed on ingest); re-running refreshes + restyles existing caption blocks. update_captions ({ op: 'update_captions', style?, patch?, clipIds? }) restyles EXISTING captions - `style` re-resolves a template, `patch` sets caption overlay props directly (e.g. { textColor: '#FFDD00', fontSize: 32 }); pass one or both; it never creates captions where none exist (that is add_captions). remove_captions ({ op: 'remove_captions', clipIds? }) removes captions (all, or a clipIds subset) and drops the caption track if empty. Build the `clip` from get_timeline_types, which returns a copy-pasteable `example` skeleton per clip type plus the `creation` op shapes; mint your own string ids and put overlays on a NON-primary media track so they do not ripple the primary. Each op is an object with an `op` name plus its fields (e.g. { op: 'delete_clip', clipIds: ['clip-id'] } or { op: 'move_clip', clipId: 'clip-id', toFrame: 90, toTrackIndex: 0 }). CONTENT-AWARE EDITING: to cut sections you found in get_transcript, DEFAULT to disable_ranges: { op: 'disable_ranges', clipId, ranges: [{ startMs, endMs }], note? } - it takes SOURCE-media time ranges, splits the clip and marks those ranges disabled (non-destructively excluded from the render but still on the timeline, so the user can review via skip-disabled playback and toggle any back on). Prefer the SILENCE edges get_transcript reports as your cut boundaries (they already include breathing room, so cuts do not clip words or feel abrupt) rather than exact word starts. Pass all of a clip's ranges in ONE disable_ranges op. The optional `note` is shown to the USER, so keep it concise and human and use mm:ss for any times (never raw ms). delete_ranges has the same shape but HARD-deletes (ripple-closes the gap, irreversible) - use it ONLY after the user explicitly approves a permanent delete; otherwise always prefer disable_ranges. set_disabled toggles a WHOLE clip by id. RE-TIMING: a time-based clip's timeline length is DERIVED from its source media and its playback speed, so you never set its `durationInFrames` directly. To retime a clip, set the duration-affecting property in the `update_clip` (or `update_clips`) patch (today that property is `speed`) and the reducer recomputes the clip's length for you: the clip keeps covering the same span of its source, so its timeline length scales inversely with the speed change (2x speed halves its length, 0.5x doubles it). Re-timing then honors each track's positioning in the SAME op: on the magnetic primary track the following clips ripple so that no gap opens and none is left behind, while clips on other (free) tracks, and every clip while the magnetic track is off, keep their absolute positions. So a speed change, whether on one clip or a bulk `update_clips`, lands gap-free in a single call with no per-clip length math on your side. Any `durationInFrames` you pass for such a clip is ignored in favor of the derived value. update_clips ({ op: 'update_clips', clipIds?, groupId?, patch }) applies one patch to a SET of clips at once, the bulk form of update_clip; target an explicit clipIds array OR a whole group via groupId (resolves to its members; clipIds wins if both). Every listed clip takes the same patch, but a duration-affecting property re-times each clip from ITS OWN values (a `speed` change recomputes each clip's length from its own source and speed, per the RE-TIMING rule above), and the magnetic primary track ripples so the whole batch lands gap-free in one op. Use it for a bulk property change (speed, volume, opacity, and so on) instead of many update_clip ops. GROUPS: group ({ op: 'group', clipIds, name? }) links 2+ clips under one shared groupId, stamping a stable 'Group N' ordinal that never renumbers (the result carries groupId + groupOrdinal); optional name labels it. ungroup ({ op: 'ungroup', clipIds }) clears the group. update_group ({ op: 'update_group', groupId, patch: { name } }) renames a group. List groups with their ids / ordinals / names / member clips via get_project's top-level `groups`, then target a whole group with update_group or update_clips { groupId }. merge_clips ({ op: 'merge_clips', clipIds }) rejoins adjacent, same-source, contiguous clips into one (the inverse of split; use it to clean up fragments a range edit leaves behind, or to reverse a cut after re-enabling the disabled pieces). ONE-SHOT CLEANUPS (prefer these over hand-rolling ranges for the common cases): remove_silence ({ op: 'remove_silence', paceThresholdMs?, paddingStartMs?, paddingEndMs? }) detects and disables dead-air pauses across the WHOLE timeline (paceThresholdMs = min pause length to cut, default 500; paddingStartMs/paddingEndMs = breathing room, default 200) - idempotent + re-adjustable, so re-running re-cuts at the new settings; remove_filler_words ({ op: 'remove_filler_words' }) disables high-confidence disfluencies (um/uh/er) across the whole timeline; extract_audio ({ op: 'extract_audio', clipIds? }) splits each video clip's audio onto its own track (whole-timeline, or a clipIds subset). remove_silence + remove_filler_words need a ready transcript; each reports a warning + changes nothing when there is nothing to do (no transcript / no gaps / no fillers / no video). They expand to the same disable_ranges / create_clip primitives, so reach for get_transcript + disable_ranges only for CONTEXTUAL or selective cuts the macros cannot express. expectedRevision is OPTIONAL: omit it to apply to the project's current revision (last-write-wins, fine for single-editor and id-targeted ops), or pass the revision from a prior get_project/get_transcript to fail loudly on a concurrent change instead of clobbering it. You do NOT need to fetch the project just to get the revision. Each successful edit returns the new revision for chaining further edits. Requires the editor:write scope.",
2532
2279
  inputSchema: {
2533
2280
  projectId: z.string().describe('The editor project id.'),
2534
2281
  ops: z.array(z.object({ op: z.string() }).passthrough()).describe('The timeline ops to apply, in order.'),
@@ -2558,7 +2305,7 @@ export function registerTools(server, opts) {
2558
2305
  server.registerTool('update_canvas', {
2559
2306
  title: 'Update Canvas',
2560
2307
  annotations: WRITE,
2561
- 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.",
2308
+ description: "Apply a batch of ops to a CANVAS (slides/layers) project. Ops act on layers + slides: create_layer, update_layer, delete_layer, reorder_layer, duplicate_layers, set_layer_hidden, set_layer_locked, group_layers, ungroup_layers, set_layer_as_background, create_slide, update_slide, delete_slide, duplicate_slides, reorder_slides, set_background, and more. BACKGROUND REMOVAL: remove_background ({ op: 'remove_background', layerId }) cuts out the background of an IMAGE or VIDEO layer, replacing it with transparency, as an ASYNC job (the result carries a generatingOutputId to poll with get_generation_status; the layer's media swaps to the transparent cutout when done, the original kept). Image removal is FREE; video removal is PREMIUM + metered (Champion+, per second, 60s cap). Other layer types return an error. Each op is an object with an `op` name plus its fields. expectedRevision is OPTIONAL: omit it to apply to the project's current revision (last-write-wins, fine for a single editor), or pass the revision from a prior get_project to fail loudly on a concurrent change instead of clobbering it. You do NOT need to fetch the project just to get the revision. Each successful edit returns the new revision for chaining further edits. Requires the editor:write scope.",
2562
2309
  inputSchema: {
2563
2310
  projectId: z.string().describe('The canvas project id.'),
2564
2311
  ops: z.array(z.object({ op: z.string() }).passthrough()).describe('The canvas ops to apply, in order.'),