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.
- package/mcp/tools.mjs +306 -42
- 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 ??
|
|
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 ??
|
|
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
|
|
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
|
|
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: {},
|
|
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: {},
|
|
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
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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?
|
|
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:
|
|
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
|
|
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 —
|
|
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: {},
|
|
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: {},
|
|
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 ??
|
|
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 ??
|
|
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: {},
|
|
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: {},
|
|
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.
|
|
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",
|