hermoso 0.1.6 → 0.1.7

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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +306 -42
  2. package/package.json +1 -1
package/mcp/tools.mjs CHANGED
@@ -8,12 +8,12 @@ import { apiGet, apiPost, apiPut, apiSSE, submitJob, getJob, jobResult, pollJob,
8
8
 
9
9
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || 10 * 60 * 1000);
10
10
  const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
11
- const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredContent: data ?? undefined });
11
+ const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredContent: data ?? {} });
12
12
  // Video-return variant: attaches the clip's first frame as an inline image block (Claude can't play mp4 in chat,
13
13
  // but a poster makes the result VISIBLE, mirroring generate_image). Falls back to plain ok() when frames fail.
14
14
  const stillMsg = (r) => `Still rendering — job ${r.jobId}. This is NORMAL: video renders take 1–3 minutes and each get_job call waits up to ~45s, so it can take several calls. Keep calling get_job with this id until status is done or error — do NOT ask the user whether to keep waiting, and do NOT re-fire the render on another model (that double-charges). Only surface a problem after ~6 minutes of polling.`;
15
15
  const okVideo = async (text, r) => {
16
- if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; return { content: [{ type: 'text', text: p ? text + '\n(first frame attached — open the URL for the full video)' : text }, ...(p ? [p] : [])], structuredContent: r ?? undefined }; };
16
+ if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; return { content: [{ type: 'text', text: p ? text + '\n(first frame attached — open the URL for the full video)' : text }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
17
17
 
18
18
  // ── CAPABILITY MAP — the FULL agent surface, four categories. Appended to hermoso_capabilities so an agent that
19
19
  // probes once learns everything Hermoso does (not just the models): ad spy, create, raw playground, account. Keep
@@ -23,7 +23,7 @@ const CAPABILITY_MAP = [
23
23
  'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · scrapecreators_fetch (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
24
24
  'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / use_brand · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · make_template_ad (native HTML ad formats) · remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
25
25
  'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
26
- 'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (top-up checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders).',
26
+ 'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders).',
27
27
  ].join('\n');
28
28
 
29
29
  // Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
@@ -35,8 +35,8 @@ export const MCP_INSTRUCTIONS = [
35
35
  '• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; scrapecreators_fetch; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
36
36
  '• CREATE (finished ads): draft_brand → plan_ad → render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar; make_template_ad (native HTML formats); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
37
37
  '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
38
- '• ACCOUNT: hermoso_credits, billing_status, buy_credits (top-up link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job.',
39
- 'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits mints a Stripe link your human pays; agents never spend money directly. Always report the final media URL to the user.',
38
+ '• ACCOUNT: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job.',
39
+ 'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits: with a saved card + admin rights it one-click charges after an explicit confirm:true (state the exact price first); the FIRST purchase is a Stripe link your human pays, which saves the card. Always report the final media URL to the user.',
40
40
  ].join('\n');
41
41
  // Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
42
42
  // Skipped silently for huge files / fetch errors — the URL in the text always works.
@@ -65,7 +65,7 @@ const wrap = (fn) => async (args, extra) => {
65
65
  catch (e) {
66
66
  let msg = `Error: ${e?.message || e}`;
67
67
  // credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
68
- if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to get a ready-to-pay checkout link (credit packs; your human pays on Stripe's secure page nothing was charged here). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
68
+ if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes then one-click charges on confirm:true; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
69
69
  return { content: [{ type: 'text', text: msg }], isError: true };
70
70
  }
71
71
  };
@@ -86,12 +86,32 @@ async function renderJob(type, input, label) {
86
86
  }
87
87
  }
88
88
 
89
+ // Shared outputSchema fields for the job-based render tools (the renderJob result that becomes structuredContent).
90
+ // Every field is optional so validation can never fail on a sparse or still-rendering result.
91
+ const JOB_OUT = {
92
+ jobId: z.string().optional().describe('the render job id — poll get_job with this id to resume or inspect'),
93
+ url: z.string().nullable().optional().describe('the served URL of the finished media (absent/null while still rendering)'),
94
+ model: z.string().nullable().optional().describe('the product-facing label of the model that rendered it'),
95
+ raw: z.any().optional().describe('the raw job result payload (e.g. images[] for carousel template ads)'),
96
+ stillRendering: z.boolean().optional().describe('true when the render is still in progress — keep polling get_job with jobId'),
97
+ };
98
+
89
99
  export function registerTools(server) {
90
100
  // ---------- read-only / discovery ----------
91
101
  server.registerTool('hermoso_capabilities', {
92
102
  title: 'Hermoso capabilities',
93
103
  description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar/canPublish flags. Call this FIRST so you generate with valid model ids and known costs. Read-only, free.',
94
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
104
+ inputSchema: {}, outputSchema: {
105
+ image: z.any().optional().describe('the default image provider label, or null when image generation is unavailable'),
106
+ video: z.any().optional().describe('the default video provider label, or null when video generation is unavailable'),
107
+ canEdit: z.boolean().optional().describe('whether image editing is enabled on this account'),
108
+ canAvatar: z.boolean().optional().describe('whether talking-avatar generation is enabled'),
109
+ canPublish: z.boolean().optional().describe('whether ad publishing is enabled'),
110
+ editCredits: z.number().optional().describe('credit cost of one image edit'),
111
+ options: z.any().optional().describe('the live model catalog — image/video/voice/llm model lists with per-model credit costs'),
112
+ recipes: z.array(z.any()).optional().describe('the creative recipe catalog (id + label per recipe)'),
113
+ },
114
+ annotations: { readOnlyHint: true, openWorldHint: false },
95
115
  }, wrap(async () => {
96
116
  const d = await apiGet('/api/generate/status');
97
117
  const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
@@ -108,34 +128,60 @@ export function registerTools(server) {
108
128
  server.registerTool('hermoso_credits', {
109
129
  title: 'Credit balance',
110
130
  description: 'Return the account credit balance, credits used this session, and recent priced calls. Check before kicking off paid generation.',
111
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
131
+ inputSchema: {}, outputSchema: {
132
+ accountBalance: z.number().nullable().optional().describe('the account’s Hermoso credit balance (authoritative when authed)'),
133
+ balance: z.number().optional().describe('raw vendor meter balance (operator/local-dev surface)'),
134
+ sessionStart: z.number().nullable().optional().describe('vendor balance at session start (operator surface)'),
135
+ sessionUsed: z.number().optional().describe('credits used this session'),
136
+ recentCalls: z.array(z.any()).optional().describe('recent priced calls with their credit deltas'),
137
+ },
138
+ annotations: { readOnlyHint: true, openWorldHint: false },
112
139
  }, wrap(async () => {
113
140
  const d = await apiGet('/api/credits');
114
141
  const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
115
142
  return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
116
143
  }));
117
144
 
118
- // AGENT BILLING HANDOFF: out of credits → mint a ready-to-pay Stripe checkout link for a credit PACK and hand the
119
- // URL to the human. The human pays on Stripe's hosted page (agents never spend money directly); credits post to
120
- // this account automatically once payment completes. Packs only subscriptions are managed by a person in-app.
145
+ // AGENT BILLING: out of credits → top up. With a saved card + billing-admin rights this is the SAME one-click
146
+ // off-session charge the web app's Add-credits button uses (explicit confirm:true required an agent states the
147
+ // exact charge before any money moves). First-ever purchase (no card on file) goes through a Stripe checkout link
148
+ // the human pays once — that card then saves for one-click forever. Packs only — subscriptions are in-app.
121
149
  server.registerTool('buy_credits', {
122
150
  title: 'Buy credits',
123
- description: "Out of credits? Get a ready-to-pay checkout link for a credit PACK. Call with no argument to list the available packs (id · credits · price); call again with `pack` set to a pack id to get a Stripe checkout URL. Hand that URL to your humanTHEY pay on Stripe's secure hosted page (agents never spend money directly), and the credits land on this account the moment payment completes. Packs only; subscriptions are managed by a person in Settings → Billing. Nothing is charged until your human pays.",
151
+ description: "Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true charges the saved card instantly (same one-click top-up as the app no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings → Billing.",
124
152
  inputSchema: {
125
153
  pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
154
+ confirm: z.boolean().optional().describe('set true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path)'),
155
+ },
156
+ outputSchema: {
157
+ packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing'),
158
+ quote: z.any().optional().describe('the one-click charge quote ({packId, credits, priceUsd, card}) awaiting confirm:true'),
159
+ ok: z.boolean().optional().describe('true when a one-click top-up charge succeeded'),
160
+ credits: z.number().optional().describe('credits added by a completed top-up (or bought by the checkout link)'),
161
+ url: z.string().optional().describe('Stripe checkout URL for a first purchase (no saved card yet)'),
162
+ amountUsd: z.number().optional().describe('USD amount of the checkout link'),
163
+ packId: z.string().optional().describe('the pack id the checkout link buys'),
126
164
  },
127
- annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe's page
128
- }, wrap(async ({ pack }) => {
165
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, // confirm:true charges the saved card (one-click top-up); link path charges nothing
166
+ }, wrap(async ({ pack, confirm }) => {
129
167
  const cfg = await apiGet('/api/billing/config');
130
168
  const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
131
169
  if (!pack) {
132
170
  const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
133
- return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>" to get a checkout link for your human to pay.`, { packs });
171
+ return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>". With a saved card it's a one-click charge (you'll be asked to confirm); otherwise you get a checkout link for your human.`, { packs });
134
172
  }
135
173
  const match = packs.find(p => p.id === pack);
136
174
  if (!match) return ok(`No pack "${pack}". Available: ${packs.map(p => p.id).join(', ') || '(none)'}. Call buy_credits with no argument to see details.`, { packs });
175
+ let st = null;
176
+ try { st = await apiGet('/api/billing/status'); } catch {}
177
+ if (st?.paymentMethodOnFile && st?.isAdmin) {
178
+ const card = st.card ? `${st.card.brand} ····${st.card.last4}` : 'the saved card';
179
+ if (!confirm) return ok(`Ready to charge ${card} $${match.priceUsd} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}" and confirm:true.`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null } });
180
+ const d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: (globalThis.crypto?.randomUUID?.() || String(Date.now())) });
181
+ return ok(`Done — charged ${card} $${match.priceUsd}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Settings → Billing → invoice history.)`, d);
182
+ }
137
183
  const d = await apiPost('/api/billing/checkout-link', { packId: pack });
138
- return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — the credits post to this account automatically once payment completes. Nothing is charged until they pay.`, d);
184
+ return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay.`, d);
139
185
  }));
140
186
 
141
187
  // BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
@@ -143,7 +189,16 @@ export function registerTools(server) {
143
189
  server.registerTool('billing_status', {
144
190
  title: 'Billing status',
145
191
  description: "Show this account's billing at a glance: current plan (id + label + monthly price), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing.",
146
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
192
+ inputSchema: {}, outputSchema: {
193
+ plan: z.any().optional().describe('the current plan ({id, label, monthlyUsd})'),
194
+ balanceCredits: z.number().optional().describe('the current credit balance'),
195
+ autoReload: z.any().optional().describe('auto-reload config ({enabled, thresholdCredits, reloadCredits, available})'),
196
+ paymentMethodOnFile: z.boolean().optional().describe('whether a card is saved for one-click charges'),
197
+ card: z.any().optional().describe('the saved card ({brand, last4}) when present'),
198
+ role: z.string().optional().describe('this key’s billing role (admin/member)'),
199
+ isAdmin: z.boolean().optional().describe('whether this key can change billing'),
200
+ },
201
+ annotations: { readOnlyHint: true, openWorldHint: false },
147
202
  }, wrap(async () => {
148
203
  const d = await apiGet('/api/billing/status');
149
204
  const ar = d.autoReload || {};
@@ -161,6 +216,18 @@ export function registerTools(server) {
161
216
  plan: z.string().optional().describe('the plan id to move to (e.g. pro) — omit to list the available plans first'),
162
217
  period: z.enum(['mo', 'yr']).optional().describe('billing cadence — monthly (default) or yearly (2 months free)'),
163
218
  },
219
+ outputSchema: {
220
+ plans: z.array(z.any()).optional().describe('available paid plans ({id, name, priceUsd, credits}) when listing'),
221
+ mode: z.string().optional().describe("'checkout' (a Stripe URL was minted) or 'in_app' (a person makes the change in the app)"),
222
+ url: z.string().optional().describe('the ready-to-pay Stripe Checkout URL (checkout mode)'),
223
+ plan: z.string().optional().describe('the target plan id'),
224
+ planLabel: z.string().optional().describe('the target plan display name'),
225
+ monthlyUsd: z.number().optional().describe('the plan’s monthly price in USD'),
226
+ chargeUsd: z.number().optional().describe('the actual charge amount (yearly billing charges the annual total)'),
227
+ period: z.string().optional().describe("billing cadence of the link — 'mo' or 'yr'"),
228
+ action: z.string().optional().describe("the in-app action required ('upgrade' or 'downgrade')"),
229
+ guidance: z.string().optional().describe('exact instructions when the change must be made in the app'),
230
+ },
164
231
  annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe / in-app
165
232
  }, wrap(async ({ plan, period }) => {
166
233
  const cfg = await apiGet('/api/billing/config');
@@ -184,6 +251,17 @@ export function registerTools(server) {
184
251
  thresholdCredits: z.number().int().optional().describe('reload when the balance drops below this many credits'),
185
252
  reloadCredits: z.number().int().optional().describe('how many credits to add each reload — must match a credit pack size (see buy_credits)'),
186
253
  },
254
+ outputSchema: {
255
+ applied: z.boolean().optional().describe('whether the auto-reload config was applied'),
256
+ needsCard: z.boolean().optional().describe('true when there is no saved card yet (add one in the app first)'),
257
+ enabled: z.boolean().optional().describe('the resulting auto-reload state'),
258
+ thresholdCredits: z.number().nullable().optional().describe('reload triggers below this balance'),
259
+ reloadCredits: z.number().nullable().optional().describe('credits added per reload'),
260
+ reloadPack: z.any().optional().describe('the pack charged on each reload'),
261
+ capUsd: z.any().optional().describe('monthly auto-reload spend cap in USD, if set'),
262
+ status: z.string().optional().describe('auto-reload status detail'),
263
+ guidance: z.string().optional().describe('instructions when the change must be made in the app'),
264
+ },
187
265
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
188
266
  }, wrap(async ({ enabled, thresholdCredits, reloadCredits }) => {
189
267
  const d = await apiPost('/api/billing/autoreload-config', { enabled, thresholdCredits, reloadCredits });
@@ -195,7 +273,10 @@ export function registerTools(server) {
195
273
  server.registerTool('list_brands', {
196
274
  title: 'List brands',
197
275
  description: "List every brand on this account (id + name) and which one this connection currently acts on. Multi-brand accounts: call this, then use_brand to switch. Read-only, free.",
198
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
276
+ inputSchema: {}, outputSchema: {
277
+ brands: z.array(z.any()).optional().describe('every brand on the account ({id, name, active})'),
278
+ },
279
+ annotations: { readOnlyHint: true, openWorldHint: false },
199
280
  }, wrap(async () => {
200
281
  const d = await apiGet('/api/brands');
201
282
  const lines = (d.brands || []).map(b => `• ${b.name} (id: ${b.id})${b.active ? ' ← active' : ''}`).join('\n');
@@ -206,6 +287,10 @@ export function registerTools(server) {
206
287
  title: 'Switch brand',
207
288
  description: "Pin which brand this connection generates for (multi-brand accounts). Pass the brand id or exact name from list_brands. Persists for this API key until changed.",
208
289
  inputSchema: { brand: z.string().describe('brand id (e.g. default / p_xxx) or its exact name from list_brands') },
290
+ outputSchema: {
291
+ ok: z.boolean().optional().describe('true when the brand switch persisted'),
292
+ brand: z.any().optional().describe('the now-active brand ({id, name})'),
293
+ },
209
294
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
210
295
  }, wrap(async ({ brand }) => {
211
296
  const d = await apiGet('/api/brands');
@@ -226,7 +311,20 @@ export function registerTools(server) {
226
311
  format: z.enum(['auto', 'image', 'video']).optional().describe("'image', 'video', or 'auto' when unspecified"),
227
312
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
228
313
  reference: z.string().optional().describe('a reference ad URL to remix the angle from — Facebook Ad Library, LinkedIn Ad Library or Google Ads Transparency links (the real ad’s copy/advertiser are fetched and fed into the concept)'),
229
- language: z.string().optional(),
314
+ language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
315
+ },
316
+ outputSchema: {
317
+ format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
318
+ concept: z.string().optional().describe('the one-line creative concept'),
319
+ recipe: z.string().optional().describe('the resolved recipe id'),
320
+ recipe_label: z.string().optional().describe('the resolved recipe display name'),
321
+ copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
322
+ image_concept: z.any().optional().describe('the render-ready image concept (prompt etc.) when format is image'),
323
+ video_storyboard: z.any().optional().describe('the timed storyboard (scenes, cta, music) when format is video'),
324
+ render_plan: z.any().optional().describe('the routing plan (structure/duration) render_ad honors'),
325
+ imodel: z.string().optional().describe('the image model id to render with'),
326
+ vmodel: z.string().optional().describe('the video model id to render with'),
327
+ brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
230
328
  },
231
329
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
232
330
  }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
@@ -251,7 +349,11 @@ export function registerTools(server) {
251
349
  useBrand: z.boolean().optional().describe('default true: with no refImages, the server hydrates the SAVED brand’s product/logo references so the output lands on-brand; pass false for a pure prompt-only render'),
252
350
  aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
253
351
  model: z.string().optional().describe('image model id from hermoso_capabilities'),
254
- imageSize: z.string().optional(),
352
+ imageSize: z.string().optional().describe('pixel-size preset for models that support it (e.g. 1K/2K) — omit for the default'),
353
+ },
354
+ outputSchema: {
355
+ image: z.string().optional().describe('the served absolute URL of the finished image'),
356
+ model: z.string().optional().describe('the product-facing label of the model that rendered it'),
255
357
  },
256
358
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
257
359
  }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
@@ -270,6 +372,12 @@ export function registerTools(server) {
270
372
  engine: z.string().optional().describe("voice-engine id: 'seed-audio' (default), 'eleven-v3', 'minimax-speech', or 'kokoro' — listed in hermoso_capabilities"),
271
373
  voice: z.string().optional().describe("a voice preset from the chosen engine (e.g. 'Aria'/'George' on eleven-v3, 'stokie_en' on seed-audio) — omit for the engine default"),
272
374
  },
375
+ outputSchema: {
376
+ audio: z.string().optional().describe('the served absolute URL of the MP3 voice clip'),
377
+ voice: z.string().optional().describe('the voice preset used'),
378
+ model: z.string().optional().describe('the voice engine label'),
379
+ creditsUsed: z.number().optional().describe('credits billed for this clip'),
380
+ },
273
381
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
274
382
  }, wrap(async ({ text, engine, voice }) => {
275
383
  const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}) });
@@ -283,6 +391,11 @@ export function registerTools(server) {
283
391
  prompt: z.string().describe('the writing task / question'),
284
392
  model: z.string().optional().describe('a writing-model id from hermoso_capabilities (a Claude / Gemini / GPT / Llama / DeepSeek id) — omit for the default'),
285
393
  },
394
+ outputSchema: {
395
+ text: z.string().optional().describe('the generated text'),
396
+ model: z.string().optional().describe('the writing model label'),
397
+ creditsUsed: z.number().optional().describe('credits billed for this generation'),
398
+ },
286
399
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
287
400
  }, wrap(async ({ prompt, model }) => {
288
401
  const d = await apiPost('/api/models/llm', { prompt, ...(model ? { model } : {}) });
@@ -296,8 +409,8 @@ export function registerTools(server) {
296
409
  inputSchema: {
297
410
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
298
411
  model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
299
- durationSeconds: z.number().optional(),
300
- aspectRatio: z.string().optional(),
412
+ durationSeconds: z.number().optional().describe('total ad length in seconds — omit to honor the plan’s own duration'),
413
+ aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
301
414
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
302
415
  captions: z.boolean().optional().describe('composited caption pills on/off (default: the recipe decides)'),
303
416
  endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
@@ -306,6 +419,12 @@ export function registerTools(server) {
306
419
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
307
420
  dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged'),
308
421
  },
422
+ outputSchema: {
423
+ ...JOB_OUT,
424
+ dryRun: z.boolean().optional().describe('true when this was a dry run (no job submitted, nothing charged)'),
425
+ jobType: z.string().optional().describe("the routing decision — 'video' (single pass) or 'stitch' (acts)"),
426
+ input: z.any().optional().describe('the assembled render input (dry run only — resolved model, duration, scenes)'),
427
+ },
309
428
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
310
429
  }, wrap(async (a) => {
311
430
  const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
@@ -324,15 +443,16 @@ export function registerTools(server) {
324
443
  inputSchema: {
325
444
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
326
445
  },
446
+ outputSchema: { ...JOB_OUT },
327
447
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
328
448
  }, wrap(async (a) => {
329
449
  const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
330
450
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
331
451
  const urls = r.raw.images.map((u) => abs(u));
332
452
  const first = await imageBlock(urls[0]).catch(() => null);
333
- return { content: [{ type: 'text', text: `Carousel ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? undefined };
453
+ return { content: [{ type: 'text', text: `Carousel ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? {} };
334
454
  }
335
- if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]` }, ...(img ? [img] : [])], structuredContent: r ?? undefined }; }
455
+ if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]` }, ...(img ? [img] : [])], structuredContent: r ?? {} }; }
336
456
  return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
337
457
  }));
338
458
 
@@ -348,6 +468,7 @@ export function registerTools(server) {
348
468
  pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
349
469
  grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
350
470
  },
471
+ outputSchema: { ...JOB_OUT },
351
472
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
352
473
  }, wrap(async (a) => {
353
474
  const r = await renderJob('videofinish', { videoUrl: a.videoUrl, header: a.header, sub: a.sub, points: a.points, accent: a.accent, pills: a.pills !== false, grain: !!a.grain }, 'MCP video finish');
@@ -365,6 +486,7 @@ export function registerTools(server) {
365
486
  refImage: z.string().optional().describe('optional product/style anchor image URL'),
366
487
  speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
367
488
  },
489
+ outputSchema: { ...JOB_OUT },
368
490
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
369
491
  }, wrap(async (a) => {
370
492
  const r = await renderJob('fixbeat', { videoUrl: a.videoUrl, startSeconds: a.startSeconds, endSeconds: a.endSeconds, prompt: a.prompt, refImage: a.refImage, speechWindows: a.speechWindows }, 'MCP fix beat');
@@ -384,8 +506,9 @@ export function registerTools(server) {
384
506
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
385
507
  ttsScript: z.string().optional().describe('voiceover script to speak'),
386
508
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
387
- musicMood: z.string().optional(),
509
+ musicMood: z.string().optional().describe('licensed music-bed mood (e.g. upbeat / cinematic) — omit for no music bed'),
388
510
  },
511
+ outputSchema: { ...JOB_OUT },
389
512
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
390
513
  }, wrap(async (a) => {
391
514
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
@@ -404,6 +527,7 @@ export function registerTools(server) {
404
527
  voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
405
528
  resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
406
529
  },
530
+ outputSchema: { ...JOB_OUT },
407
531
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
408
532
  }, wrap(async (a) => {
409
533
  const image = await toRef(a.image);
@@ -416,13 +540,14 @@ export function registerTools(server) {
416
540
  description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than one model clip (>15s). A ≤15s multi-beat ad renders better and cheaper as ONE single-pass generate_video/render_ad on seedance-2 (it handles the full hook→demo→payoff arc in one take) — never stitch those. Blocks until done. Spends credits.',
417
541
  inputSchema: {
418
542
  scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
419
- aspectRatio: z.string().optional(),
420
- voiceover: z.string().optional(),
421
- voice: z.string().optional(),
422
- resolution: z.string().optional(),
423
- model: z.string().optional(),
424
- durationSeconds: z.number().optional(),
543
+ aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
544
+ voiceover: z.string().optional().describe('full voiceover script spoken across the scenes'),
545
+ voice: z.string().optional().describe('voiceover voice name, e.g. Rachel / George'),
546
+ resolution: z.string().optional().describe('720p (default), 480p draft, or 1080p final'),
547
+ model: z.string().optional().describe('video model id from hermoso_capabilities — omit to let the router pick'),
548
+ durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
425
549
  },
550
+ outputSchema: { ...JOB_OUT },
426
551
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
427
552
  }, wrap(async (a) => {
428
553
  // HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
@@ -448,6 +573,15 @@ export function registerTools(server) {
448
573
  title: 'Get render job',
449
574
  description: 'Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall.',
450
575
  inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
576
+ outputSchema: {
577
+ id: z.string().optional().describe('the job id'),
578
+ status: z.string().optional().describe('queued | running | done | error'),
579
+ progress: z.number().optional().describe('0–1 progress when reported'),
580
+ error: z.string().nullable().optional().describe('the failure message when status is error'),
581
+ url: z.string().nullable().optional().describe('the served media URL once done'),
582
+ type: z.string().optional().describe('the job type (video / stitch / avatar / …)'),
583
+ result: z.any().optional().describe('the raw job result payload'),
584
+ },
451
585
  annotations: { readOnlyHint: true, openWorldHint: false },
452
586
  }, wrap(async ({ id }) => {
453
587
  const j = await getJob(id);
@@ -463,7 +597,11 @@ export function registerTools(server) {
463
597
  server.registerTool('list_skills', {
464
598
  title: 'List skills',
465
599
  description: 'List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.',
466
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
600
+ inputSchema: {}, outputSchema: {
601
+ bundles: z.array(z.any()).optional().describe('bundled skills ({name, description}) loadable via get_skill'),
602
+ inApp: z.array(z.any()).optional().describe('in-app strategy skills + creative recipes ({id, kind/group})'),
603
+ },
604
+ annotations: { readOnlyHint: true, openWorldHint: false },
467
605
  }, wrap(async () => {
468
606
  const { readdir, readFile } = await import('node:fs/promises');
469
607
  const dir = new URL('../skills/', import.meta.url);
@@ -488,6 +626,9 @@ export function registerTools(server) {
488
626
  title: 'Get skill',
489
627
  description: 'Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.',
490
628
  inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
629
+ outputSchema: {
630
+ name: z.string().optional().describe('the loaded skill bundle name'),
631
+ },
491
632
  annotations: { readOnlyHint: true, openWorldHint: false },
492
633
  }, wrap(async ({ name }) => {
493
634
  const safe = String(name).replace(/[^a-z0-9-]/gi, '');
@@ -500,7 +641,11 @@ export function registerTools(server) {
500
641
  server.registerTool('list_jobs', {
501
642
  title: 'List render jobs',
502
643
  description: 'List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.',
503
- inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
644
+ inputSchema: {}, outputSchema: {
645
+ running: z.number().optional().describe('how many jobs are currently running'),
646
+ jobs: z.array(z.any()).optional().describe('recent jobs ({id, type, status, …}), newest first'),
647
+ },
648
+ annotations: { readOnlyHint: true, openWorldHint: false },
504
649
  }, wrap(async () => {
505
650
  const d = await apiGet('/api/jobs');
506
651
  const lines = (d.jobs || []).slice(0, 12).map(j => `${j.id} ${j.type} ${j.status}`).join('\n');
@@ -513,7 +658,11 @@ export function registerTools(server) {
513
658
  description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 ScrapeCreators credits.",
514
659
  inputSchema: {
515
660
  domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
516
- mode: z.enum(['competitors', 'inspiration', 'company']).optional(),
661
+ mode: z.enum(['competitors', 'inspiration', 'company']).optional().describe("'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'"),
662
+ },
663
+ outputSchema: {
664
+ candidates: z.array(z.any()).optional().describe('discovered brands ({name, domain, kind, reason})'),
665
+ diagnostics: z.any().optional().describe('discovery diagnostics (LLM tokens, web grounding)'),
517
666
  },
518
667
  annotations: { readOnlyHint: true, openWorldHint: true },
519
668
  }, wrap(async ({ domain, mode = 'competitors' }) => {
@@ -530,9 +679,14 @@ export function registerTools(server) {
530
679
  domain: z.string().optional().describe('the advertiser domain'),
531
680
  platforms: z.array(z.string()).optional().describe("default ['facebook']; add 'google','linkedin'"),
532
681
  country: z.string().optional().describe("2-letter, default 'US'"),
533
- limit: z.number().optional(),
682
+ limit: z.number().optional().describe('max ads per platform (default 30)'),
534
683
  sort: z.string().optional().describe("'longest_running' (default) etc."),
535
684
  },
685
+ outputSchema: {
686
+ facebook: z.any().optional().describe('Meta results ({ads[], matched} or {error}; null when not requested)'),
687
+ google: z.any().optional().describe('Google results ({ads[], cursor} or {error}; null when not requested)'),
688
+ linkedin: z.any().optional().describe('LinkedIn results ({ads[], cursor} or {error}; null when not requested)'),
689
+ },
536
690
  annotations: { readOnlyHint: true, openWorldHint: true },
537
691
  }, wrap(async (a) => {
538
692
  const d = await apiPost('/api/inspire/fanout', { platforms: ['facebook'], country: 'US', limit: 30, sort: 'longest_running', ...a });
@@ -544,7 +698,12 @@ export function registerTools(server) {
544
698
  description: 'Natural-language ad research: a Claude tool-use loop over Meta/Google/LinkedIn ad libraries + organic TikTok. Returns a summary + the found ads (with their served URLs). Spends LLM tokens + ScrapeCreators credits.',
545
699
  inputSchema: {
546
700
  query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
547
- brand: z.union([z.string(), z.object({}).passthrough()]).optional(),
701
+ brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object to tailor the research to; omit to use the workspace’s saved brand'),
702
+ },
703
+ outputSchema: {
704
+ reply: z.string().optional().describe('the research summary'),
705
+ results: z.array(z.any()).optional().describe('the found ads/videos (normalized card objects with served URLs)'),
706
+ actions: z.any().optional().describe('follow-up actions the research loop suggested'),
548
707
  },
549
708
  annotations: { readOnlyHint: true, openWorldHint: true },
550
709
  }, wrap(async ({ query, brand }) => {
@@ -570,9 +729,13 @@ export function registerTools(server) {
570
729
  pageId: z.string().optional().describe('one advertiser’s ads by Facebook page id (most precise)'),
571
730
  country: z.string().optional().describe("2-letter code or 'ALL' (default ALL)"),
572
731
  status: z.enum(['ACTIVE', 'INACTIVE', 'ALL']).optional().describe("ACTIVE = currently running; default ALL (includes proven past winners)"),
573
- mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional(),
732
+ mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional().describe('filter by creative type (default ALL)'),
574
733
  limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
575
734
  },
735
+ outputSchema: {
736
+ found: z.number().optional().describe('total ads found upstream'),
737
+ ads: z.array(z.any()).optional().describe('the compact ad objects ({page_name, body, cta, link, dates, media})'),
738
+ },
576
739
  annotations: { readOnlyHint: true, openWorldHint: true },
577
740
  }, wrap(async (a) => {
578
741
  if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
@@ -601,6 +764,10 @@ export function registerTools(server) {
601
764
  region: z.string().optional().describe('2-letter region, default US'),
602
765
  limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
603
766
  },
767
+ outputSchema: {
768
+ found: z.number().optional().describe('total ads found upstream'),
769
+ ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, format, adUrl, image, firstShown, lastShown})'),
770
+ },
604
771
  annotations: { readOnlyHint: true, openWorldHint: true },
605
772
  }, wrap(async (a) => {
606
773
  if (!a.domain && !a.advertiserId) throw new Error('Pass domain or advertiserId.');
@@ -616,10 +783,14 @@ export function registerTools(server) {
616
783
  inputSchema: {
617
784
  company: z.string().optional().describe('advertiser company name'),
618
785
  keyword: z.string().optional().describe('keyword across all advertisers'),
619
- companyId: z.string().optional(),
786
+ companyId: z.string().optional().describe('LinkedIn company id (numeric) when the name is ambiguous'),
620
787
  countries: z.string().optional().describe("CSV of 2-letter codes like 'US,CA'; omit or 'ALL' = worldwide"),
621
788
  limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
622
789
  },
790
+ outputSchema: {
791
+ found: z.number().optional().describe('total ads found upstream'),
792
+ ads: z.array(z.any()).optional().describe('the compact ad objects ({advertiser, headline, description, cta, link, media, dates, impressions})'),
793
+ },
623
794
  annotations: { readOnlyHint: true, openWorldHint: true },
624
795
  }, wrap(async (a) => {
625
796
  if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
@@ -639,6 +810,10 @@ export function registerTools(server) {
639
810
  query: z.string().describe('keyword or hashtag (no # needed)'),
640
811
  limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
641
812
  },
813
+ outputSchema: {
814
+ found: z.number().optional().describe('total videos found'),
815
+ videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
816
+ },
642
817
  annotations: { readOnlyHint: true, openWorldHint: true },
643
818
  }, wrap(async ({ query, limit }) => {
644
819
  const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
@@ -660,6 +835,10 @@ export function registerTools(server) {
660
835
  query: z.string().describe('keyword to search reels for'),
661
836
  limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
662
837
  },
838
+ outputSchema: {
839
+ found: z.number().optional().describe('total reels found'),
840
+ reels: z.array(z.any()).optional().describe('the compact reel objects ({desc, author, handle, plays, likes, link, cover}), ranked by plays'),
841
+ },
663
842
  annotations: { readOnlyHint: true, openWorldHint: true },
664
843
  }, wrap(async ({ query, limit }) => {
665
844
  const d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query });
@@ -683,6 +862,10 @@ export function registerTools(server) {
683
862
  query: z.string().describe('keyword to search videos for'),
684
863
  limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
685
864
  },
865
+ outputSchema: {
866
+ found: z.number().optional().describe('total videos found'),
867
+ videos: z.array(z.any()).optional().describe('the compact video objects ({desc, author, handle, plays, link, cover}), ranked by views'),
868
+ },
686
869
  annotations: { readOnlyHint: true, openWorldHint: true },
687
870
  }, wrap(async ({ query, limit }) => {
688
871
  const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
@@ -700,6 +883,10 @@ export function registerTools(server) {
700
883
  query: z.string().describe('what to search Reddit for'),
701
884
  limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
702
885
  },
886
+ outputSchema: {
887
+ found: z.number().optional().describe('total posts found'),
888
+ posts: z.array(z.any()).optional().describe('the compact post objects ({desc, subreddit, upvotes, comments, link})'),
889
+ },
703
890
  annotations: { readOnlyHint: true, openWorldHint: true },
704
891
  }, wrap(async ({ query, limit }) => {
705
892
  const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'top' });
@@ -718,6 +905,10 @@ export function registerTools(server) {
718
905
  query: z.string().describe('keyword to search Threads for'),
719
906
  limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
720
907
  },
908
+ outputSchema: {
909
+ found: z.number().optional().describe('total posts found'),
910
+ posts: z.array(z.any()).optional().describe('the compact post objects ({desc, author, handle, likes, link, cover})'),
911
+ },
721
912
  annotations: { readOnlyHint: true, openWorldHint: true },
722
913
  }, wrap(async ({ query, limit }) => {
723
914
  const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
@@ -740,6 +931,7 @@ export function registerTools(server) {
740
931
  path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
741
932
  params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
742
933
  },
934
+ outputSchema: {}, // deliberately empty — the raw provider payload (any shape, can be huge) stays in the text
743
935
  annotations: { readOnlyHint: true, openWorldHint: true },
744
936
  }, wrap(async ({ path, params }) => {
745
937
  const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
@@ -752,6 +944,11 @@ export function registerTools(server) {
752
944
  title: 'Get saved brand',
753
945
  description: 'What Hermoso ALREADY KNOWS for this account/workspace — the same saved brand profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call this FIRST: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. 0 credits.',
754
946
  inputSchema: {},
947
+ outputSchema: {
948
+ hasBrand: z.boolean().optional().describe('whether a brand is saved for this workspace'),
949
+ brand: z.any().optional().describe('the saved brand profile (name, domain, category, products, palette, …) or null'),
950
+ memoryCount: z.number().optional().describe('how many learned memory notes the workspace holds'),
951
+ },
755
952
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
756
953
  }, wrap(async () => {
757
954
  const d = await apiGet('/api/brand/current');
@@ -767,10 +964,21 @@ export function registerTools(server) {
767
964
  inputSchema: {
768
965
  domain: z.string().optional().describe('a website to scrape'),
769
966
  description: z.string().optional().describe('a free-text brand description (no website)'),
770
- socialHandle: z.string().optional(),
967
+ socialHandle: z.string().optional().describe('a social handle to draft from (influencers/creators) — pair with platform'),
771
968
  platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
772
969
  save: z.boolean().optional().describe('save as the workspace’s brand (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to overwrite, false to never save'),
773
970
  },
971
+ outputSchema: {
972
+ name: z.string().optional().describe('the drafted brand name — VERIFY it matches the brand the user meant'),
973
+ domain: z.string().optional().describe('the brand website domain (empty for non-website drafts)'),
974
+ category: z.string().optional().describe('the detected category'),
975
+ summary: z.string().optional().describe('a short positioning summary'),
976
+ sells: z.any().optional().describe('what the brand sells'),
977
+ logo: z.string().optional().describe('the detected logo URL'),
978
+ palette: z.array(z.any()).optional().describe('the brand colors'),
979
+ products: z.any().optional().describe('the detected products'),
980
+ productImages: z.array(z.any()).optional().describe('product photo URLs'),
981
+ },
774
982
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
775
983
  }, wrap(async ({ save, ...a }) => {
776
984
  const d = await apiPost('/api/brand/draft', a);
@@ -793,7 +1001,11 @@ export function registerTools(server) {
793
1001
  server.registerTool('fetch_asset', {
794
1002
  title: 'Fetch asset',
795
1003
  description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
796
- inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional() },
1004
+ inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional().describe('optional filename for the download') },
1005
+ outputSchema: {
1006
+ url: z.string().optional().describe('the clickable absolute asset URL'),
1007
+ downloadUrl: z.string().optional().describe('a direct download URL for the asset'),
1008
+ },
797
1009
  annotations: { readOnlyHint: true, openWorldHint: false },
798
1010
  }, wrap(async ({ url, name }) => {
799
1011
  const absolute = abs(url);
@@ -806,6 +1018,11 @@ export function registerTools(server) {
806
1018
  title: 'Analyze video',
807
1019
  description: "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call; no ScrapeCreators credits.",
808
1020
  inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
1021
+ outputSchema: {
1022
+ durationSeconds: z.number().optional().describe('the video length in seconds'),
1023
+ frameTimes: z.array(z.number()).optional().describe('timestamps (seconds) of the sampled frames'),
1024
+ transcript: z.string().nullable().optional().describe('verbatim voiceover + on-screen text with a beat list (null when silent/unreachable)'),
1025
+ },
809
1026
  annotations: { readOnlyHint: true, openWorldHint: true },
810
1027
  }, wrap(async ({ url }) => {
811
1028
  const [fr, tr] = await Promise.all([
@@ -822,9 +1039,16 @@ export function registerTools(server) {
822
1039
  description: "Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.",
823
1040
  inputSchema: {
824
1041
  url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
825
- kind: z.enum(['image', 'video']).optional(),
1042
+ kind: z.enum(['image', 'video']).optional().describe("'image' (default) or 'video'"),
826
1043
  intent: z.string().optional().describe('what the ad is trying to achieve, for goal-fit scoring'),
827
1044
  },
1045
+ outputSchema: {
1046
+ overall: z.number().optional().describe('the overall score out of 100'),
1047
+ tier: z.string().optional().describe('the qualitative tier'),
1048
+ dimensions: z.array(z.any()).optional().describe('per-dimension breakdown ({name, score})'),
1049
+ top_fix: z.string().optional().describe('the single biggest improvement lever'),
1050
+ strengths: z.any().optional().describe('what the ad already does well'),
1051
+ },
828
1052
  annotations: { readOnlyHint: true, openWorldHint: true },
829
1053
  }, wrap(async ({ url, kind = 'image', intent = '' }) => {
830
1054
  const d = await apiPost('/api/score/ad', { url, kind, intent, format: kind });
@@ -837,6 +1061,7 @@ export function registerTools(server) {
837
1061
  title: 'Reframe video',
838
1062
  description: "Reframe a video to a different aspect ratio (e.g. 16:9 master → 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
839
1063
  inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
1064
+ outputSchema: { ...JOB_OUT },
840
1065
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
841
1066
  }, wrap(async ({ video, aspectRatio }) => {
842
1067
  const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
@@ -847,6 +1072,7 @@ export function registerTools(server) {
847
1072
  title: 'Upscale video',
848
1073
  description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
849
1074
  inputSchema: { video: z.string().describe('the source video URL') },
1075
+ outputSchema: { ...JOB_OUT },
850
1076
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
851
1077
  }, wrap(async ({ video }) => {
852
1078
  const r = await renderJob('upscale', { video, factor: 2 }, 'Upscale 2x');
@@ -861,6 +1087,7 @@ export function registerTools(server) {
861
1087
  language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
862
1088
  script: z.string().optional().describe('the original spoken script if known — improves translation fidelity'),
863
1089
  },
1090
+ outputSchema: { ...JOB_OUT },
864
1091
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
865
1092
  }, wrap(async ({ video, language, script }) => {
866
1093
  const r = await renderJob('dub', { video, language, script: script || '' }, `Dub → ${language}`);
@@ -874,6 +1101,7 @@ export function registerTools(server) {
874
1101
  video: z.string().describe('the source video URL'),
875
1102
  voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)"),
876
1103
  },
1104
+ outputSchema: { ...JOB_OUT },
877
1105
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
878
1106
  }, wrap(async ({ video, voice }) => {
879
1107
  const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
@@ -889,6 +1117,7 @@ export function registerTools(server) {
889
1117
  prompt: z.string().optional().describe('optional scene/style guidance'),
890
1118
  orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
891
1119
  },
1120
+ outputSchema: { ...JOB_OUT },
892
1121
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
893
1122
  }, wrap(async ({ image, video, prompt = '', orientation = 'video' }) => {
894
1123
  const r = await renderJob('motion', { image, video, prompt, orientation }, 'Motion recast');
@@ -902,7 +1131,11 @@ export function registerTools(server) {
902
1131
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
903
1132
  product: z.string().describe('what to advertise'),
904
1133
  count: z.number().int().min(2).max(8).optional().describe('how many distinct variants (default 6)'),
905
- language: z.string().optional(),
1134
+ language: z.string().optional().describe('output language for the variant copy (e.g. Spanish) — default English'),
1135
+ },
1136
+ outputSchema: {
1137
+ variants: z.array(z.any()).optional().describe('the distinct ad angles ({name, hook, headline, visual brief})'),
1138
+ angles: z.array(z.any()).optional().describe('alternate key the planner may return the variants under'),
906
1139
  },
907
1140
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
908
1141
  }, wrap(async ({ brand, product, count = 6, language }) => {
@@ -937,6 +1170,10 @@ export function registerTools(server) {
937
1170
  ads: z.array(z.object({}).passthrough()).optional().describe('ad objects to tear down (from pull_competitor_ads / search_meta_ads). Omit to auto-pull their Meta ads first.'),
938
1171
  language: z.string().optional().describe('output language (default English)'),
939
1172
  },
1173
+ outputSchema: {
1174
+ teardown: z.any().optional().describe('the playbook — hook_taxonomy, campaigns, white_space, counter_plays, not_saying'),
1175
+ adCount: z.number().optional().describe('how many ads were analyzed'),
1176
+ },
940
1177
  annotations: { readOnlyHint: true, openWorldHint: true },
941
1178
  }, wrap(async ({ competitor, ads, language }) => {
942
1179
  const name = String(competitor?.name || '').trim();
@@ -967,6 +1204,12 @@ export function registerTools(server) {
967
1204
  category: z.string().optional().describe('the product category — helps pick the relevant policy pages'),
968
1205
  imageDescription: z.string().optional().describe('a description of the creative / image when relevant'),
969
1206
  },
1207
+ outputSchema: {
1208
+ verdict: z.string().optional().describe('pass / fix / block'),
1209
+ summary: z.string().optional().describe('one-line verdict summary'),
1210
+ findings: z.array(z.any()).optional().describe('flagged issues ({severity, issue, policy_quote, fix_suggestion, where_in_ad})'),
1211
+ anchors: z.array(z.any()).optional().describe('the Meta policy pages consulted ({url, …})'),
1212
+ },
970
1213
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
971
1214
  }, wrap(async ({ copy, claims, category, imageDescription }) => {
972
1215
  const d = await apiPost('/api/policy/check', { copy, claims: claims || '', category: category || '', imageDescription: imageDescription || '' });
@@ -983,6 +1226,12 @@ export function registerTools(server) {
983
1226
  imageUrl: z.string().describe('the URL of the static ad image to remix'),
984
1227
  brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
985
1228
  },
1229
+ outputSchema: {
1230
+ image: z.string().optional().describe('the served absolute URL of the remixed ad image'),
1231
+ model: z.string().optional().describe('the model label that rendered it'),
1232
+ slots: z.any().optional().describe('the filled slot map (layout elements swapped to your brand)'),
1233
+ residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
1234
+ },
986
1235
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
987
1236
  }, wrap(async ({ imageUrl, brandId }) => {
988
1237
  const brand = await activeBrand(brandId);
@@ -1001,6 +1250,11 @@ export function registerTools(server) {
1001
1250
  inputSchema: {
1002
1251
  brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
1003
1252
  },
1253
+ outputSchema: {
1254
+ angles: z.array(z.any()).optional().describe('the ranked angle bank ({category, angle, score, hook_draft, proof_quotes})'),
1255
+ sourceCount: z.number().optional().describe('how many customer sources were mined'),
1256
+ note: z.string().optional().describe('why no angles were returned, when the bank is empty'),
1257
+ },
1004
1258
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
1005
1259
  }, wrap(async ({ brandId }) => {
1006
1260
  const brand = await activeBrand(brandId);
@@ -1019,6 +1273,10 @@ export function registerTools(server) {
1019
1273
  inputSchema: {
1020
1274
  brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
1021
1275
  },
1276
+ outputSchema: {
1277
+ summary: z.string().optional().describe('a readable rundown of the saved product photos'),
1278
+ photos: z.array(z.any()).optional().describe('the saved photos ({url, label, …})'),
1279
+ },
1022
1280
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1023
1281
  }, wrap(async ({ brandId }) => {
1024
1282
  const brand = await activeBrand(brandId);
@@ -1034,6 +1292,12 @@ export function registerTools(server) {
1034
1292
  source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
1035
1293
  brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
1036
1294
  },
1295
+ outputSchema: {
1296
+ attached: z.boolean().optional().describe('true when the image passed the product check and was locked'),
1297
+ summary: z.string().optional().describe('the check verdict — on rejection, why nothing was locked'),
1298
+ url: z.string().nullable().optional().describe('the durable served URL of the locked product photo'),
1299
+ source_note: z.string().nullable().optional().describe('where the photo came from'),
1300
+ },
1037
1301
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1038
1302
  }, wrap(async ({ imageUrl, source_note, brandId }) => {
1039
1303
  const brand = await activeBrand(brandId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
6
6
  "type": "module",