@contenthero/mcp 0.4.3 → 0.4.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
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  import { GenerationTimeoutError, pendingOutputId, } from '@contenthero/sdk';
38
38
  import { getClient as defaultGetClient } from './client.js';
39
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';
40
- import { audioResult, avatarListResult, avatarResult, avatarPendingResult, balanceResult, brandKitListResult, brandKitResult, brandKnowledgeListResult, brandKnowledgeDetailResult, brandKnowledgeSearchResult, brandKnowledgeItemResult, completedResult, connectedAccountListResult, connectedAccountResult, costResult, accountDetailResult, inspirationContentResult, mediaListResult, mediaSearchResult, folderListResult, folderContentsResult, mediaBatchResult, mediaUploadResult, importedMediaResult, uploadedMediaResult, tagListResult, tagResult, tagDeletedResult, modelListResult, modelResult, platformListResult, platformResult, elementListResult, elementResult, elementDeletedResult, errorResult, generationBatchResult, outlierListResult, enhanceClipsResult, pendingResult, stageListResult, spaceDeletedResult, spaceListResult, spaceResult, cardListResult, cardResult, 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, avatarPendingResult, balanceResult, brandKitListResult, brandKitResult, brandKnowledgeListResult, brandKnowledgeDetailResult, brandKnowledgeSearchResult, brandKnowledgeItemResult, completedResult, connectedAccountListResult, connectedAccountResult, costResult, accountDetailResult, inspirationContentResult, mediaListResult, mediaSearchResult, folderListResult, folderContentsResult, mediaBatchResult, mediaUploadResult, importedMediaResult, uploadedMediaResult, tagListResult, tagResult, tagDeletedResult, modelListResult, modelResult, platformListResult, platformResult, elementListResult, elementResult, elementDeletedResult, errorResult, generationBatchResult, outlierListResult, enhanceClipsResult, pendingResult, stageListResult, stageResult, stageDeletedResult, spaceDeletedResult, spaceListResult, spaceResult, cardListResult, cardResult, postSummaryResult, publishResult, statusActionResult, editorOpsResult, text, projectDetailResult, liveContextResult, projectListResult, projectCreatedResult, projectDeletedResult, layerTypesResult, timelineTypesResult, editorTranscriptResult, exportJobResult, exportFormatsResult, trackedAccountListResult, transcriptResult, voiceListResult, voiceResult, } from './format.js';
41
41
  /** Platforms a card or one of its posts may target. */
42
42
  const POST_PLATFORMS = [
43
43
  'youtube',
@@ -207,6 +207,36 @@ function buildReferences(parts) {
207
207
  */
208
208
  export function registerTools(server, opts) {
209
209
  const { getClient, models } = opts;
210
+ /**
211
+ * 🚨🚨 **EVERY TOOL'S INPUT IS STRICT. AN UNDECLARED PARAMETER IS A 400, NEVER A SILENT DROP.**
212
+ *
213
+ * Zod object schemas STRIP unknown keys by default, and the SDK builds one from each `inputSchema` shape.
214
+ * So before this wrapper, all 85 tools accepted any parameter they did not declare, discarded it, and ran
215
+ * on whatever survived. The failure is invisible by construction: the caller gets a success.
216
+ *
217
+ * ⚠️ **THE MEASURED CASE, 2026-09-08.** `update_card` does not declare `ops`. A call passing `ops` had it
218
+ * stripped, leaving only `cardId`, and returned **"Updated: 01.3 Design Your Character"** having written
219
+ * nothing. A second call passing the nonsense op `__probe__` did the same. An agent running a batch of
220
+ * partial edits would collect a full set of success messages and zero writes.
221
+ *
222
+ * ⭐⭐ **WRAPPED HERE RATHER THAN AT 85 CALL SITES, AND THAT IS THE POINT.** A rule every registration must
223
+ * remember is a rule that holds until someone adds the 86th tool. Same move as `withSpineRegistration`
224
+ * wrapping the Supabase client instead of asking 73 upload sites to declare an owner.
225
+ *
226
+ * ⚠️ **NESTED `.passthrough()` SURVIVES, DELIBERATELY.** `update_timeline` and `update_canvas` declare
227
+ * `ops: z.array(z.object({ op: z.string() }).passthrough())` because a timeline op carries a different
228
+ * shape per op type. Strictness here applies to the TOP-LEVEL argument object only, so those keep taking
229
+ * varied op payloads while still rejecting an undeclared top-level parameter.
230
+ */
231
+ const rawRegisterTool = server.registerTool.bind(server);
232
+ server.registerTool = ((name, config, cb) => {
233
+ const shape = config.inputSchema;
234
+ // A tool with no inputs, or one that already passed a built schema, is left exactly as it was.
235
+ const strict = shape && typeof shape === 'object' && !(shape instanceof z.ZodType)
236
+ ? z.object(shape).strict()
237
+ : shape;
238
+ return rawRegisterTool(name, { ...config, inputSchema: strict }, cb);
239
+ });
210
240
  /**
211
241
  * ⚠️ THESE SHAPES ARE DECLARED, NOT LEFT AS `z.unknown()`. An array of unknown serialises to
212
242
  * `{"type":"array","items":{}}`, which tells a client NOTHING about what may go inside it. The server
@@ -816,6 +846,10 @@ export function registerTools(server, opts) {
816
846
  imageUrls: z
817
847
  .array(z.string())
818
848
  .describe('Images the account already owns, as URLs: an upload, a creation, an editor export, or another avatar look. Anything not owned by this account is skipped rather than failing the call.'),
849
+ name: z
850
+ .string()
851
+ .optional()
852
+ .describe("What to call the look. Applied to every image in this op. Omit and it stays unnamed, displaying by its source label ('from_media'), which is rarely what you want for a look you will pick from a list later."),
819
853
  }),
820
854
  z.object({
821
855
  op: z.literal('remove_look'),
@@ -1710,7 +1744,10 @@ export function registerTools(server, opts) {
1710
1744
  .string()
1711
1745
  .optional()
1712
1746
  .describe("Which space's board to list, from list_spaces. Omit ONLY when you mean the account's default space; omitting it does not search every board."),
1713
- status: z.enum(['draft', 'active', 'completed', 'archived']).optional().describe('Filter by lifecycle status.'),
1747
+ archived: z
1748
+ .boolean()
1749
+ .optional()
1750
+ .describe('Only ARCHIVED cards. Archived cards are excluded by default, matching the board.'),
1714
1751
  platform: z.enum(POST_PLATFORMS).optional().describe('Filter by the post platform.'),
1715
1752
  stage: z.string().optional().describe('Filter by a stage id, slug, or name. Resolved within the chosen space.'),
1716
1753
  search: z.string().optional().describe('Case-insensitive title search, scoped to the chosen space.'),
@@ -1722,7 +1759,7 @@ export function registerTools(server, opts) {
1722
1759
  const client = await getClient(extra);
1723
1760
  return cardListResult(await client.listCards({
1724
1761
  spaceId: args.spaceId,
1725
- status: args.status,
1762
+ archived: args.archived,
1726
1763
  platform: args.platform,
1727
1764
  stage: args.stage,
1728
1765
  search: args.search,
@@ -1873,14 +1910,116 @@ export function registerTools(server, opts) {
1873
1910
  return errorResult(err);
1874
1911
  }
1875
1912
  });
1913
+ // -- create_stage ---------------------------------------------------------
1914
+ server.registerTool('create_stage', {
1915
+ title: 'Create Stage',
1916
+ annotations: WRITE,
1917
+ description: "Create a stage (a column on one board). ⚠️ STAGES ARE PER-SPACE: without spaceId this creates on the account's DEFAULT board, which is rarely what you want once more than one space exists, so call list_spaces first. The slug is DERIVED from the name and is not settable; a board cannot hold two columns whose names produce the same slug and the server refuses the second rather than renaming it for you. Place the column with afterId/beforeId, or omit both to put it at the end. Requires the planner:write scope.",
1918
+ inputSchema: {
1919
+ name: z.string().describe('The column name, for example "In Review". Must contain a letter or number.'),
1920
+ spaceId: z
1921
+ .string()
1922
+ .optional()
1923
+ .describe("Which board, from list_spaces. Omit only when you mean the account's default space."),
1924
+ color: z.string().optional().describe('A hex color such as "#3B82F6".'),
1925
+ afterId: z.string().optional().describe('Put the new column immediately after this stage id.'),
1926
+ beforeId: z.string().optional().describe('Put the new column immediately before this stage id.'),
1927
+ },
1928
+ }, async (args, extra) => {
1929
+ try {
1930
+ const client = await getClient(extra);
1931
+ return stageResult(await client.createStage({
1932
+ name: args.name,
1933
+ spaceId: args.spaceId,
1934
+ color: args.color,
1935
+ afterId: args.afterId,
1936
+ beforeId: args.beforeId,
1937
+ }));
1938
+ }
1939
+ catch (err) {
1940
+ return errorResult(err);
1941
+ }
1942
+ });
1943
+ // -- update_stage ---------------------------------------------------------
1944
+ server.registerTool('update_stage', {
1945
+ title: 'Update Stage',
1946
+ annotations: WRITE,
1947
+ description: "Rename, recolor or move one stage. This is a PATCH: a field you omit is left alone. ⚠️ spaceId is REQUIRED, because a stage id alone does not tell the server which board you mean and guessing the wrong one silently changes nothing. Renaming re-derives the slug, so renaming a column away from 'Published' also stops publishing auto-moving cards into it; a rename that collides with another column on the same board is refused. Moving names NEIGHBORS, not a position: pass afterId or beforeId. Requires the planner:write scope.",
1948
+ inputSchema: {
1949
+ stageId: z.string().describe('The stage id to update, from list_stages.'),
1950
+ spaceId: z.string().describe('The board this stage is on, from list_stages or list_spaces.'),
1951
+ name: z.string().optional().describe('A new name. The slug follows it automatically.'),
1952
+ color: z.string().optional().describe('A new hex color such as "#3B82F6".'),
1953
+ afterId: z
1954
+ .string()
1955
+ .optional()
1956
+ .describe('Move it immediately after this stage id. Use the empty string to move it to the far left.'),
1957
+ beforeId: z
1958
+ .string()
1959
+ .optional()
1960
+ .describe('Move it immediately before this stage id. Use the empty string to move it to the far right.'),
1961
+ },
1962
+ }, async (args, extra) => {
1963
+ try {
1964
+ const client = await getClient(extra);
1965
+ /*
1966
+ An empty string is how a tool caller says "the edge": JSON Schema cannot distinguish an
1967
+ omitted string from an explicit null in a plain string field, and the two mean opposite
1968
+ things here. Omitted means "do not move it"; null means "move it to the end of the board".
1969
+ `update_space` resolves the same ambiguity the same way for coverUrl.
1970
+ */
1971
+ const edge = (v) => (v === undefined ? undefined : v === '' ? null : v);
1972
+ const { stage, respaced } = await client.updateStage(args.stageId, {
1973
+ spaceId: args.spaceId,
1974
+ name: args.name,
1975
+ color: args.color,
1976
+ afterId: edge(args.afterId),
1977
+ beforeId: edge(args.beforeId),
1978
+ });
1979
+ return stageResult(stage, respaced);
1980
+ }
1981
+ catch (err) {
1982
+ return errorResult(err);
1983
+ }
1984
+ });
1985
+ // -- delete_stage ---------------------------------------------------------
1986
+ server.registerTool('delete_stage', {
1987
+ title: 'Delete Stage',
1988
+ annotations: WRITE,
1989
+ description: 'Delete a stage and move its cards to another column. The server REFUSES a column that still holds cards when you name no targetStageId, and tells you how many there are: cards are never destroyed by deleting a column, and the delete and the reassignment happen in one transaction. Returns the board that is left. Requires the planner:write scope.',
1990
+ inputSchema: {
1991
+ stageId: z.string().describe('The stage id to delete, from list_stages.'),
1992
+ spaceId: z.string().describe('The board this stage is on.'),
1993
+ targetStageId: z
1994
+ .string()
1995
+ .optional()
1996
+ .describe('Where this column\'s cards should go. Required unless the column is empty.'),
1997
+ },
1998
+ }, async (args, extra) => {
1999
+ try {
2000
+ const client = await getClient(extra);
2001
+ const result = await client.deleteStage(args.stageId, {
2002
+ spaceId: args.spaceId,
2003
+ targetStageId: args.targetStageId ?? null,
2004
+ });
2005
+ return stageDeletedResult(result.id, result.movedCards, result.stages);
2006
+ }
2007
+ catch (err) {
2008
+ return errorResult(err);
2009
+ }
2010
+ });
1876
2011
  // -- create_card ----------------------------------------------------------
1877
2012
  server.registerTool('create_card', {
1878
2013
  title: 'Create Card',
1879
2014
  annotations: WRITE,
1880
- description: "Create a card, the container in the content pipeline. A card holds the work (title, script, notes, cover) and the posts that publish it. Attach posts and media by passing `posts` and `assets` to update_card, then publish with publish_post. `stage` accepts a stage id/slug/name (defaults to the first stage). Requires a key with the planner:write scope.",
2015
+ description: "Create a card, the container in the content pipeline. A card holds the work (title, script, notes, cover) and the posts that publish it. Attach posts and media by passing `posts` and `assets` to update_card, then publish with publish_post. ⚠️ WITHOUT spaceId THIS LANDS ON THE ACCOUNT'S DEFAULT BOARD, which is rarely what you want once more than one space exists, so call list_spaces first. `stage` accepts a stage id/slug/name and DECIDES the space when it is an id; a spaceId that disagrees with it is rejected rather than guessed. Requires a key with the planner:write scope.",
1881
2016
  inputSchema: {
1882
2017
  title: z.string().describe('Post title (required).'),
1883
2018
  platform: z.enum(POST_PLATFORMS).describe('Primary platform for the post.'),
2019
+ spaceId: z
2020
+ .string()
2021
+ .optional()
2022
+ .describe("Which board to create the card on, from list_spaces. Omit only when you mean the account's default space."),
1884
2023
  stage: z.string().optional().describe('Pipeline stage id, slug, or name. Defaults to the first stage.'),
1885
2024
  coverUrl: z.string().optional().describe('Public URL for the post cover (the card thumbnail).'),
1886
2025
  coverOutputId: z
@@ -1898,6 +2037,7 @@ export function registerTools(server, opts) {
1898
2037
  return postSummaryResult(await client.createCard({
1899
2038
  title: args.title,
1900
2039
  platform: args.platform,
2040
+ spaceId: args.spaceId,
1901
2041
  stage: args.stage,
1902
2042
  coverUrl: args.coverUrl,
1903
2043
  coverOutputId: args.coverOutputId,
@@ -1912,12 +2052,11 @@ export function registerTools(server, opts) {
1912
2052
  server.registerTool('update_card', {
1913
2053
  title: 'Update Card',
1914
2054
  annotations: WRITE,
1915
- description: "Update a card: its fields (title, description, script, notes, status, platform, cover, stage), its POSTS (one per platform, which is how it publishes), its ASSETS (the media on it, in order), and its SCHEDULE. posts and assets are DECLARATIVE: pass the WHOLE set, because anything you leave out is removed. Posts key on platform. Assets key on id, and THE ARRAY ORDER IS THE carousel ORDER, so reordering is just sending the same ids in a different order; keep an existing asset by id, add a new one by assetUrl or outputId. scheduledAt sets the time on the card AND every post (pass null to clear); give a post its own scheduledAt to override it for that platform. To publish NOW, use publish_post. Pass spaceId to MOVE the card to another space; without a stage it lands in the target space's stage whose slug matches its current one, or that space's first stage. Pass cardIds to update several cards at once, which crossed with spaceId is how a selection moves in one call; fields that describe ONE card (title, notes, script, cover) still need exactly one. Requires the planner:write scope.",
2055
+ description: "Update a card: its fields (title, description, script, notes, platform, cover, stage), its POSTS (one per platform, which is how it publishes), its ASSETS (the media on it, in order), and its SCHEDULE. posts and assets are DECLARATIVE: pass the WHOLE set, because anything you leave out is removed. Posts key on platform. Assets key on id, and THE ARRAY ORDER IS THE carousel ORDER, so reordering is just sending the same ids in a different order; keep an existing asset by id, add a new one by assetUrl or outputId. scheduledAt sets the time on the card AND every post (pass null to clear); give a post its own scheduledAt to override it for that platform. To publish NOW, use publish_post. Pass spaceId to MOVE the card to another space; without a stage it lands in the target space's stage whose slug matches its current one, or that space's first stage. Pass cardIds to update several cards at once, which crossed with spaceId is how a selection moves in one call; fields that describe ONE card (title, notes, script, cover) still need exactly one. Requires the planner:write scope.",
1916
2056
  inputSchema: {
1917
2057
  cardId: z.string().describe('The card id.'),
1918
2058
  title: z.string().optional(),
1919
2059
  platform: z.enum(POST_PLATFORMS).optional(),
1920
- status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
1921
2060
  stage: z.string().optional().describe('Move the post to this stage (id, slug, or name).'),
1922
2061
  spaceId: z
1923
2062
  .string()
@@ -2256,7 +2395,7 @@ export function registerTools(server, opts) {
2256
2395
  server.registerTool('archive', {
2257
2396
  title: 'Archive',
2258
2397
  annotations: WRITE,
2259
- description: "Archive or UNarchive an asset: pass archived:false to restore it (default true). ContentHero never hard-deletes, so this is always reversible. For a top-level asset, pass assetType + id (card, brand_kit, brand_kit_section, project, space). To archive a single studio media variation, pass the output id + variationIndex (1-based) and omit assetType. Archiving a card sets its status to 'archived'; restoring returns it to 'draft'. Requires the favorites:write scope. Idempotent in both directions.",
2398
+ description: "Archive or UNarchive an asset: pass archived:false to restore it (default true). ContentHero never hard-deletes, so this is always reversible. For a top-level asset, pass assetType + id (card, brand_kit, brand_kit_section, project, space). To archive a single studio media variation, pass the output id + variationIndex (1-based) and omit assetType. Archiving is a timestamp and nothing else is touched, so a scheduled card restores as scheduled. Requires the favorites:write scope. Idempotent in both directions.",
2260
2399
  inputSchema: {
2261
2400
  assetType: z
2262
2401
  .enum(['card', 'brand_kit', 'brand_kit_section', 'project', 'space'])
@@ -2518,12 +2657,12 @@ export function registerTools(server, opts) {
2518
2657
  server.registerTool('export_project', {
2519
2658
  title: 'Export Project',
2520
2659
  annotations: WRITE,
2521
- 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.",
2660
+ description: "Export (render) a project's saved composition to a downloadable file the user KEEPS: a permanent deliverable that counts against the user's storage. To preview or verify a frame or slide while editing, do NOT export; use get_context with render (ephemeral, stored nowhere). format 'mp4' works for both editor and canvas (a video render; may take a while). 'png' / 'jpg' work for both surfaces too: a canvas project renders one image per slide (multiple slides come back as a zip), while an editor project renders a single composited frame of the timeline (pick which frame with `frame`; defaults to frame 0). Canvas projects additionally support 'pdf' and 'pptx'. Resolution and watermark apply to EVERY format, not just mp4: a free account never exports above 720p and never removes the watermark, on any format or surface. `quality` is mp4 only. Returns the download URL when the render finishes in time, otherwise an exportId to poll with get_export. Requires the editor:write scope.",
2522
2661
  inputSchema: {
2523
2662
  projectId: z.string().describe('The project to export.'),
2524
2663
  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."),
2525
- resolution: z.enum(['480p', '720p', '1080p', '2k', '4k']).optional().describe("mp4 video resolution. Defaults '720p'. 1080p+ is plan-gated."),
2526
- quality: z.enum(['low', 'recommended', 'high']).optional().describe('mp4 video quality. Defaults recommended.'),
2664
+ resolution: z.enum(['480p', '720p', '1080p', '2k', '4k']).optional().describe("Output resolution, for EVERY format including stills. Defaults '720p' for an editor mp4 and the project's NATIVE size for a still or canvas mp4, in both cases clamped to your plan. 1080p and above are plan-gated: NAMING one above your plan is a 403, omitting one clamps instead."),
2665
+ quality: z.enum(['low', 'recommended', 'high']).optional().describe('mp4 ONLY: video bitrate. Meaningless for a still, which has no duration to spread bits over. Defaults recommended.'),
2527
2666
  watermark: z.boolean().optional().describe('Keep the watermark. Defaults true; removing it is plan-gated.'),
2528
2667
  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.'),
2529
2668
  },