hermoso 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@ import { registerTools } from './tools.mjs';
12
12
  import { API_BASE } from './client.mjs';
13
13
 
14
14
  const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
15
- instructions: 'Hermoso generates copyable, on-brand ad creative. Typical flow: hermoso_capabilities (learn valid model ids + costs) → optionally draft_brand → plan_ad (concept + copy) → generate_image / generate_video (returns a served URL). Use find_competitors / pull_competitor_ads / research_ads to gather proven ads to remix first. Always report the final media URL to the user.',
15
+ instructions: 'Hermoso generates copyable, on-brand ad creative. Typical flow: hermoso_capabilities (learn valid model ids + costs) → optionally draft_brand → plan_ad (concept + copy) → generate_image / generate_video (returns a served URL). Use find_competitors / pull_competitor_ads / research_ads to gather proven ads to remix first. Structured ad-spy when you know exactly what to pull: search_meta_ads / search_google_ads / search_linkedin_ads (ad libraries), search_tiktok / search_instagram / search_youtube / search_reddit / search_threads (organic), scrapecreators_fetch (any allowlisted endpoint). Always report the final media URL to the user.',
16
16
  });
17
17
 
18
18
  registerTools(server);
package/mcp/tools.mjs CHANGED
@@ -134,14 +134,15 @@ export function registerTools(server) {
134
134
  inputSchema: {
135
135
  prompt: z.string().describe('the full image prompt — subject, composition, lighting, and any on-image ad text'),
136
136
  refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in'),
137
+ 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'),
137
138
  aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
138
139
  model: z.string().optional().describe('image model id from hermoso_capabilities'),
139
140
  imageSize: z.string().optional(),
140
141
  },
141
142
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
142
- }, wrap(async ({ prompt, refImages, aspectRatio, model, imageSize }) => {
143
+ }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
143
144
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
144
- const d = await apiPost('/api/generate/image', { prompt, refImages: refs, aspectRatio, model, imageSize });
145
+ const d = await apiPost('/api/generate/image', { prompt, refImages: refs, useBrand: useBrand !== false, aspectRatio, model, imageSize }); // explicit boolean so the server's saved-brand hydration default is unambiguous
145
146
  const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
146
147
  return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
147
148
  }));
@@ -154,15 +155,66 @@ export function registerTools(server) {
154
155
  model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick)'),
155
156
  durationSeconds: z.number().optional(),
156
157
  aspectRatio: z.string().optional(),
157
- resolution: z.string().optional().describe("'720p' (default) or '1080p'"),
158
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
159
+ captions: z.boolean().optional().describe('composited caption pills on/off (default: the recipe decides)'),
160
+ endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
161
+ music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
162
+ lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
163
+ ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
158
164
  },
159
165
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
160
166
  }, wrap(async (a) => {
161
- const { input, notes } = await apiPost('/api/render/assemble', a);
167
+ const { input, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
162
168
  const r = await renderJob('video', input, 'MCP ad render');
163
169
  return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]\n${notes || ''}`, r);
164
170
  }));
165
171
 
172
+
173
+ server.registerTool('make_template_ad', {
174
+ description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook, claims: string[] (3-5, ≤4 words each), productImages: string[], palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
175
+ inputSchema: {
176
+ config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
177
+ },
178
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
179
+ }, wrap(async (a) => {
180
+ const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
181
+ 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 }; }
182
+ return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
183
+ }));
184
+
185
+ server.registerTool('finish_video', {
186
+ description: "Post-process an EXISTING rendered video (its served mp4 URL) with the proven direct-response 'reviewer' finish and/or a film-grain pass — no AI model, ~30s, a couple of credits. pills=true composites a header pill (e.g. '10/10 would buy again'), a brand-accent sub-pill, and 3-4 green-check proof pills cascading in on the beat (YOU author the copy: header ≤40 chars, sub ≤34, each point ≤44 — concrete real benefits, never fabricated stats). grain=true applies a subtle camera-grain finish that makes photoreal AI renders look phone-shot ('less AI') — works alone or with pills. Returns a NEW video; the original is untouched.",
187
+ inputSchema: {
188
+ videoUrl: z.string().describe('the served URL of the video to finish (from a previous render/job)'),
189
+ header: z.string().optional().describe('header pill copy, ≤40 chars (required when pills is on)'),
190
+ sub: z.string().optional().describe('accent sub-pill copy, ≤34 chars (usually the product/brand)'),
191
+ points: z.array(z.string()).optional().describe('3-4 proof points, ≤44 chars each'),
192
+ accent: z.string().optional().describe('brand accent hex for the sub-pill'),
193
+ pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
194
+ grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
195
+ },
196
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
197
+ }, wrap(async (a) => {
198
+ 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');
199
+ return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
200
+ }));
201
+
202
+ server.registerTool('fix_beat', {
203
+ description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.",
204
+ inputSchema: {
205
+ videoUrl: z.string().describe('the served URL of the master video to fix'),
206
+ startSeconds: z.number().describe('window start in seconds'),
207
+ endSeconds: z.number().describe('window end in seconds (window 1.5-8s)'),
208
+ prompt: z.string().describe('what the replacement footage should show — describe the shot, matching the master\'s style'),
209
+ refImage: z.string().optional().describe('optional product/style anchor image URL'),
210
+ speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
211
+ },
212
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
213
+ }, wrap(async (a) => {
214
+ 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');
215
+ return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
216
+ }));
217
+
166
218
  server.registerTool('generate_video', {
167
219
  description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Spends credits (Starter plan is video-blocked server-side).',
168
220
  inputSchema: {
@@ -171,7 +223,7 @@ export function registerTools(server) {
171
223
  durationSeconds: z.number().optional().describe('clip length in seconds'),
172
224
  aspectRatio: z.string().optional().describe("default '9:16'"),
173
225
  model: z.string().optional(),
174
- resolution: z.string().optional().describe("'720p' (default) or '1080p'"),
226
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
175
227
  ttsScript: z.string().optional().describe('voiceover script to speak'),
176
228
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
177
229
  musicMood: z.string().optional(),
@@ -332,6 +384,191 @@ export function registerTools(server) {
332
384
  return ok(`${d.reply || ''}\n\n(${(d.results || []).length} ads found)`, { reply: d.reply, results: d.results, actions: d.actions });
333
385
  }));
334
386
 
387
+ // ---------- structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop) ----------
388
+ // For when the agent KNOWS what to pull (one brand / keyword / platform): a single API call returning compact
389
+ // JSON — cheaper + faster than research_ads, which stays the right tool for open-ended cross-platform judgment.
390
+ const qp = (o) => Object.fromEntries(Object.entries(o || {}).filter(([, v]) => v != null && v !== '')); // URLSearchParams renders undefined as the literal string "undefined" — strip empties before they hit the API
391
+ const trunc = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
392
+ const nAds = (n) => Math.min(25, Math.max(1, Math.round(+n) || 8));
393
+ const adsOut = (label, total, items) => ok(JSON.stringify({ found: total, showing: items.length, [label]: items }), { found: total, [label]: items }); // compact JSON summary, never the raw firehose
394
+
395
+ server.registerTool('search_meta_ads', {
396
+ description: "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends ScrapeCreators credits (~1–2).",
397
+ inputSchema: {
398
+ query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
399
+ companyName: z.string().optional().describe('one advertiser’s ads by brand name'),
400
+ pageId: z.string().optional().describe('one advertiser’s ads by Facebook page id (most precise)'),
401
+ country: z.string().optional().describe("2-letter code or 'ALL' (default ALL)"),
402
+ status: z.enum(['ACTIVE', 'INACTIVE', 'ALL']).optional().describe("ACTIVE = currently running; default ALL (includes proven past winners)"),
403
+ mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional(),
404
+ limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
405
+ },
406
+ annotations: { readOnlyHint: true, openWorldHint: true },
407
+ }, wrap(async (a) => {
408
+ if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
409
+ const common = qp({ country: a.country, status: a.status, media_type: a.mediaType });
410
+ const d = a.query
411
+ ? await apiGet('/api/fb/search', { query: a.query, ...common })
412
+ : await apiGet('/api/fb/company-ads', qp({ companyName: a.companyName, pageId: a.pageId, ...common }));
413
+ const raw = d.results || d.searchResults || []; // company-ads → results[], keyword search → searchResults[]
414
+ const ads = raw.slice(0, nAds(a.limit)).map((x) => {
415
+ const s = x.snapshot || {};
416
+ return qp({
417
+ page_name: x.page_name, body: trunc(typeof s.body === 'string' ? s.body : s.body?.text), cta: s.cta_text, link: s.link_url,
418
+ dates: [x.start_date_string, x.end_date_string].filter(Boolean).join(' → '),
419
+ media: s.videos?.[0]?.video_sd_url || s.images?.[0]?.resized_image_url || s.cards?.[0]?.resized_image_url || s.cards?.[0]?.video_sd_url || s.videos?.[0]?.video_preview_image_url,
420
+ });
421
+ });
422
+ return adsOut('ads', d.searchResultsCount ?? raw.length, ads);
423
+ }));
424
+
425
+ server.registerTool('search_google_ads', {
426
+ description: "Structured Google Ads Transparency pull for ONE advertiser (by domain or advertiserId) — use when you know the brand; use research_ads for open-ended research. Deliberately fetches the cheap BASIC listing (get_ad_details=false, ~1 credit — the detailed variant with per-ad headlines costs 25 credits/call and is not exposed here). Returns compact JSON {advertiser, format, adUrl, image, firstShown, lastShown} per ad.",
427
+ inputSchema: {
428
+ domain: z.string().optional().describe("the advertiser's domain, e.g. nike.com"),
429
+ advertiserId: z.string().optional().describe('Google advertiser id (AR…) when the domain is ambiguous'),
430
+ region: z.string().optional().describe('2-letter region, default US'),
431
+ limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
432
+ },
433
+ annotations: { readOnlyHint: true, openWorldHint: true },
434
+ }, wrap(async (a) => {
435
+ if (!a.domain && !a.advertiserId) throw new Error('Pass domain or advertiserId.');
436
+ const d = await apiGet('/api/google/company-ads', qp({ domain: a.domain, advertiser_id: a.advertiserId, region: a.region, get_ad_details: 'false' }));
437
+ const raw = d.ads || [];
438
+ const ads = raw.slice(0, nAds(a.limit)).map((g) => qp({ advertiser: g.advertiserName, format: g.format, adUrl: g.adUrl, image: g.imageUrl, firstShown: g.firstShown, lastShown: g.lastShown }));
439
+ return adsOut('ads', d.number_of_ads_estimate ?? raw.length, ads);
440
+ }));
441
+
442
+ server.registerTool('search_linkedin_ads', {
443
+ description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends ScrapeCreators credits (~1).",
444
+ inputSchema: {
445
+ company: z.string().optional().describe('advertiser company name'),
446
+ keyword: z.string().optional().describe('keyword across all advertisers'),
447
+ companyId: z.string().optional(),
448
+ countries: z.string().optional().describe("CSV of 2-letter codes like 'US,CA'; omit or 'ALL' = worldwide"),
449
+ limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
450
+ },
451
+ annotations: { readOnlyHint: true, openWorldHint: true },
452
+ }, wrap(async (a) => {
453
+ if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
454
+ const d = await apiGet('/api/linkedin/search', qp({ company: a.company, keyword: a.keyword, companyId: a.companyId, countries: a.countries }));
455
+ const raw = d.ads || [];
456
+ const ads = raw.slice(0, nAds(a.limit)).map((x) => qp({
457
+ advertiser: x.advertiser, headline: trunc(x.headline, 120), description: trunc(x.description), cta: x.cta,
458
+ link: x.destinationUrl, media: x.video || x.image, dates: [x.startDate, x.endDate].filter(Boolean).join(' → '), impressions: x.totalImpressions,
459
+ }));
460
+ return adsOut('ads', d.totalAds ?? raw.length, ads);
461
+ }));
462
+
463
+ server.registerTool('search_tiktok', {
464
+ description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends ScrapeCreators credits (~1).",
465
+ inputSchema: {
466
+ query: z.string().describe('keyword or hashtag (no # needed)'),
467
+ limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
468
+ },
469
+ annotations: { readOnlyHint: true, openWorldHint: true },
470
+ }, wrap(async ({ query, limit }) => {
471
+ const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
472
+ const all = (d.search_item_list || []).map((x) => x.aweme_info).filter(Boolean).map((v) => {
473
+ const au = v.author || {}, st = v.statistics || {}, vid = v.video || {};
474
+ return qp({
475
+ desc: trunc(v.desc), author: au.nickname || au.unique_id, handle: au.unique_id, plays: st.play_count, likes: st.digg_count,
476
+ link: au.unique_id && v.aweme_id ? `https://www.tiktok.com/@${au.unique_id}/video/${v.aweme_id}` : '',
477
+ cover: vid.cover?.url_list?.[0] || vid.origin_cover?.url_list?.[0],
478
+ });
479
+ }).sort((a, b) => (b.plays || b.likes || 0) - (a.plays || a.likes || 0)); // TOP by plays — "top-performing" means ranked, not API order
480
+ return adsOut('videos', all.length, all.slice(0, nAds(limit)));
481
+ }));
482
+
483
+ server.registerTool('search_instagram', {
484
+ description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — ScrapeCreators' only IG keyword surface; profile/hashtag pulls go through scrapecreators_fetch with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends ScrapeCreators credits (~1).",
485
+ inputSchema: {
486
+ query: z.string().describe('keyword to search reels for'),
487
+ limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
488
+ },
489
+ annotations: { readOnlyHint: true, openWorldHint: true },
490
+ }, wrap(async ({ query, limit }) => {
491
+ const d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query });
492
+ const all = (d.reels || d.items || []).map((r) => {
493
+ const o = r.owner || r.user || {};
494
+ return qp({
495
+ desc: trunc(typeof r.caption === 'string' ? r.caption : (r.caption?.text || r.accessibility_caption)),
496
+ author: o.full_name || o.username, handle: o.username,
497
+ plays: r.video_play_count || r.video_view_count, likes: r.like_count || r.edge_liked_by?.count,
498
+ link: r.url || (r.shortcode ? `https://www.instagram.com/reel/${r.shortcode}/` : ''),
499
+ cover: r.thumbnail_src || r.display_url,
500
+ });
501
+ }).filter((x) => x.cover || x.link).sort((a, b) => (b.plays || b.likes || 0) - (a.plays || a.likes || 0));
502
+ return adsOut('reels', all.length, all.slice(0, nAds(limit)));
503
+ }));
504
+
505
+ server.registerTool('search_youtube', {
506
+ description: "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends ScrapeCreators credits (~1).",
507
+ inputSchema: {
508
+ query: z.string().describe('keyword to search videos for'),
509
+ limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
510
+ },
511
+ annotations: { readOnlyHint: true, openWorldHint: true },
512
+ }, wrap(async ({ query, limit }) => {
513
+ const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
514
+ const all = (d.videos || []).filter((v) => (v.type || 'video') === 'video').map((v) => {
515
+ const ch = v.channel || {};
516
+ return qp({ desc: trunc(v.title, 120), author: ch.title || ch.handle, handle: ch.handle, plays: v.viewCountInt, link: v.url || (v.id ? `https://www.youtube.com/watch?v=${v.id}` : ''), cover: v.thumbnail });
517
+ }).filter((x) => x.cover || x.link).sort((a, b) => (b.plays || 0) - (a.plays || 0));
518
+ return adsOut('videos', all.length, all.slice(0, nAds(limit)));
519
+ }));
520
+
521
+ server.registerTool('search_reddit', {
522
+ description: "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends ScrapeCreators credits (~1).",
523
+ inputSchema: {
524
+ query: z.string().describe('what to search Reddit for'),
525
+ limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
526
+ },
527
+ annotations: { readOnlyHint: true, openWorldHint: true },
528
+ }, wrap(async ({ query, limit }) => {
529
+ const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'top' });
530
+ const all = (d.posts || d.results || []).map((p) => qp({
531
+ desc: trunc([p.title, p.selftext].filter(Boolean).join(' — '), 260),
532
+ subreddit: p.subreddit ? `r/${p.subreddit}` : '', upvotes: p.ups ?? p.score, comments: p.num_comments,
533
+ link: p.permalink ? (/^https?:/.test(p.permalink) ? p.permalink : `https://www.reddit.com${p.permalink}`) : p.url,
534
+ })).filter((x) => x.desc || x.link);
535
+ return adsOut('posts', all.length, all.slice(0, nAds(limit)));
536
+ }));
537
+
538
+ server.registerTool('search_threads', {
539
+ description: "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends ScrapeCreators credits (~1).",
540
+ inputSchema: {
541
+ query: z.string().describe('keyword to search Threads for'),
542
+ limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
543
+ },
544
+ annotations: { readOnlyHint: true, openWorldHint: true },
545
+ }, wrap(async ({ query, limit }) => {
546
+ const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
547
+ const all = (d.posts || d.results || []).map((p) => {
548
+ const u = p.user || {};
549
+ return qp({
550
+ desc: trunc((p.caption && (p.caption.text || (typeof p.caption === 'string' ? p.caption : ''))) || p.accessibility_caption),
551
+ author: u.full_name || u.username, handle: u.username, likes: p.like_count,
552
+ link: p.code && u.username ? `https://www.threads.net/@${u.username}/post/${p.code}` : '',
553
+ cover: p.image_versions2?.candidates?.[0]?.url,
554
+ });
555
+ }).filter((x) => x.desc || x.link || x.cover);
556
+ return adsOut('posts', all.length, all.slice(0, nAds(limit)));
557
+ }));
558
+
559
+ server.registerTool('scrapecreators_fetch', {
560
+ description: "Generic ScrapeCreators escape hatch for any ALLOWLISTED long-tail endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends ScrapeCreators credits.",
561
+ inputSchema: {
562
+ path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
563
+ params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
564
+ },
565
+ annotations: { readOnlyHint: true, openWorldHint: true },
566
+ }, wrap(async ({ path, params }) => {
567
+ const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
568
+ const raw = JSON.stringify(d);
569
+ return ok(raw.length > 24000 ? raw.slice(0, 24000) + '\n… (truncated — narrow the query or use a dedicated search_* tool)' : raw); // no structuredContent: raw payloads can be huge, the text IS the result
570
+ }));
571
+
335
572
  // ---------- brand onboarding ----------
336
573
  server.registerTool('get_brand', {
337
574
  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.',
package/package.json CHANGED
@@ -1,14 +1,32 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Drive Hermoso — the AI ad studio — from any AI agent: MCP server, CLI, and Claude skills for researching winning ads and generating finished image & video ads.",
5
5
  "type": "module",
6
- "bin": { "hermoso": "bin/hermoso.mjs" },
7
- "scripts": { "mcp": "node mcp/hermoso-mcp.mjs" },
8
- "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0" },
9
- "keywords": ["mcp", "model-context-protocol", "ai-ads", "ad-generator", "ugc", "claude", "cli", "video-ads"],
6
+ "bin": {
7
+ "hermoso": "bin/hermoso.mjs"
8
+ },
9
+ "scripts": {
10
+ "mcp": "node mcp/hermoso-mcp.mjs"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.12.0"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "ai-ads",
19
+ "ad-generator",
20
+ "ugc",
21
+ "claude",
22
+ "cli",
23
+ "video-ads"
24
+ ],
10
25
  "homepage": "https://hermoso.ai",
11
- "repository": { "type": "git", "url": "https://github.com/hermoso-ai/hermoso.git" },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/hermoso-ai/hermoso.git"
29
+ },
12
30
  "license": "MIT",
13
31
  "author": "Hermoso <hello@hermoso.ai>"
14
32
  }