hermoso 0.1.1 → 0.1.2
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/hermoso-mcp.mjs +1 -1
- package/mcp/tools.mjs +196 -5
- package/package.json +24 -6
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -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,11 +155,16 @@ 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.
|
|
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
|
}));
|
|
@@ -171,7 +177,7 @@ export function registerTools(server) {
|
|
|
171
177
|
durationSeconds: z.number().optional().describe('clip length in seconds'),
|
|
172
178
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
173
179
|
model: z.string().optional(),
|
|
174
|
-
resolution: z.
|
|
180
|
+
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
|
|
175
181
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
176
182
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
177
183
|
musicMood: z.string().optional(),
|
|
@@ -332,6 +338,191 @@ export function registerTools(server) {
|
|
|
332
338
|
return ok(`${d.reply || ''}\n\n(${(d.results || []).length} ads found)`, { reply: d.reply, results: d.results, actions: d.actions });
|
|
333
339
|
}));
|
|
334
340
|
|
|
341
|
+
// ---------- structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop) ----------
|
|
342
|
+
// For when the agent KNOWS what to pull (one brand / keyword / platform): a single API call returning compact
|
|
343
|
+
// JSON — cheaper + faster than research_ads, which stays the right tool for open-ended cross-platform judgment.
|
|
344
|
+
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
|
|
345
|
+
const trunc = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
346
|
+
const nAds = (n) => Math.min(25, Math.max(1, Math.round(+n) || 8));
|
|
347
|
+
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
|
|
348
|
+
|
|
349
|
+
server.registerTool('search_meta_ads', {
|
|
350
|
+
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).",
|
|
351
|
+
inputSchema: {
|
|
352
|
+
query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
|
|
353
|
+
companyName: z.string().optional().describe('one advertiser’s ads by brand name'),
|
|
354
|
+
pageId: z.string().optional().describe('one advertiser’s ads by Facebook page id (most precise)'),
|
|
355
|
+
country: z.string().optional().describe("2-letter code or 'ALL' (default ALL)"),
|
|
356
|
+
status: z.enum(['ACTIVE', 'INACTIVE', 'ALL']).optional().describe("ACTIVE = currently running; default ALL (includes proven past winners)"),
|
|
357
|
+
mediaType: z.enum(['ALL', 'IMAGE', 'VIDEO', 'MEME', 'IMAGE_AND_MEME', 'NONE']).optional(),
|
|
358
|
+
limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
|
|
359
|
+
},
|
|
360
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
361
|
+
}, wrap(async (a) => {
|
|
362
|
+
if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
|
|
363
|
+
const common = qp({ country: a.country, status: a.status, media_type: a.mediaType });
|
|
364
|
+
const d = a.query
|
|
365
|
+
? await apiGet('/api/fb/search', { query: a.query, ...common })
|
|
366
|
+
: await apiGet('/api/fb/company-ads', qp({ companyName: a.companyName, pageId: a.pageId, ...common }));
|
|
367
|
+
const raw = d.results || d.searchResults || []; // company-ads → results[], keyword search → searchResults[]
|
|
368
|
+
const ads = raw.slice(0, nAds(a.limit)).map((x) => {
|
|
369
|
+
const s = x.snapshot || {};
|
|
370
|
+
return qp({
|
|
371
|
+
page_name: x.page_name, body: trunc(typeof s.body === 'string' ? s.body : s.body?.text), cta: s.cta_text, link: s.link_url,
|
|
372
|
+
dates: [x.start_date_string, x.end_date_string].filter(Boolean).join(' → '),
|
|
373
|
+
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,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
return adsOut('ads', d.searchResultsCount ?? raw.length, ads);
|
|
377
|
+
}));
|
|
378
|
+
|
|
379
|
+
server.registerTool('search_google_ads', {
|
|
380
|
+
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.",
|
|
381
|
+
inputSchema: {
|
|
382
|
+
domain: z.string().optional().describe("the advertiser's domain, e.g. nike.com"),
|
|
383
|
+
advertiserId: z.string().optional().describe('Google advertiser id (AR…) when the domain is ambiguous'),
|
|
384
|
+
region: z.string().optional().describe('2-letter region, default US'),
|
|
385
|
+
limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
|
|
386
|
+
},
|
|
387
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
388
|
+
}, wrap(async (a) => {
|
|
389
|
+
if (!a.domain && !a.advertiserId) throw new Error('Pass domain or advertiserId.');
|
|
390
|
+
const d = await apiGet('/api/google/company-ads', qp({ domain: a.domain, advertiser_id: a.advertiserId, region: a.region, get_ad_details: 'false' }));
|
|
391
|
+
const raw = d.ads || [];
|
|
392
|
+
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 }));
|
|
393
|
+
return adsOut('ads', d.number_of_ads_estimate ?? raw.length, ads);
|
|
394
|
+
}));
|
|
395
|
+
|
|
396
|
+
server.registerTool('search_linkedin_ads', {
|
|
397
|
+
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).",
|
|
398
|
+
inputSchema: {
|
|
399
|
+
company: z.string().optional().describe('advertiser company name'),
|
|
400
|
+
keyword: z.string().optional().describe('keyword across all advertisers'),
|
|
401
|
+
companyId: z.string().optional(),
|
|
402
|
+
countries: z.string().optional().describe("CSV of 2-letter codes like 'US,CA'; omit or 'ALL' = worldwide"),
|
|
403
|
+
limit: z.number().int().optional().describe('max ads returned (1–25, default 8)'),
|
|
404
|
+
},
|
|
405
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
406
|
+
}, wrap(async (a) => {
|
|
407
|
+
if (!a.company && !a.keyword && !a.companyId) throw new Error('Pass company, keyword, or companyId.');
|
|
408
|
+
const d = await apiGet('/api/linkedin/search', qp({ company: a.company, keyword: a.keyword, companyId: a.companyId, countries: a.countries }));
|
|
409
|
+
const raw = d.ads || [];
|
|
410
|
+
const ads = raw.slice(0, nAds(a.limit)).map((x) => qp({
|
|
411
|
+
advertiser: x.advertiser, headline: trunc(x.headline, 120), description: trunc(x.description), cta: x.cta,
|
|
412
|
+
link: x.destinationUrl, media: x.video || x.image, dates: [x.startDate, x.endDate].filter(Boolean).join(' → '), impressions: x.totalImpressions,
|
|
413
|
+
}));
|
|
414
|
+
return adsOut('ads', d.totalAds ?? raw.length, ads);
|
|
415
|
+
}));
|
|
416
|
+
|
|
417
|
+
server.registerTool('search_tiktok', {
|
|
418
|
+
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).",
|
|
419
|
+
inputSchema: {
|
|
420
|
+
query: z.string().describe('keyword or hashtag (no # needed)'),
|
|
421
|
+
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
422
|
+
},
|
|
423
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
424
|
+
}, wrap(async ({ query, limit }) => {
|
|
425
|
+
const d = await apiGet('/api/sc/run', { __path: '/v1/tiktok/search/keyword', query });
|
|
426
|
+
const all = (d.search_item_list || []).map((x) => x.aweme_info).filter(Boolean).map((v) => {
|
|
427
|
+
const au = v.author || {}, st = v.statistics || {}, vid = v.video || {};
|
|
428
|
+
return qp({
|
|
429
|
+
desc: trunc(v.desc), author: au.nickname || au.unique_id, handle: au.unique_id, plays: st.play_count, likes: st.digg_count,
|
|
430
|
+
link: au.unique_id && v.aweme_id ? `https://www.tiktok.com/@${au.unique_id}/video/${v.aweme_id}` : '',
|
|
431
|
+
cover: vid.cover?.url_list?.[0] || vid.origin_cover?.url_list?.[0],
|
|
432
|
+
});
|
|
433
|
+
}).sort((a, b) => (b.plays || b.likes || 0) - (a.plays || a.likes || 0)); // TOP by plays — "top-performing" means ranked, not API order
|
|
434
|
+
return adsOut('videos', all.length, all.slice(0, nAds(limit)));
|
|
435
|
+
}));
|
|
436
|
+
|
|
437
|
+
server.registerTool('search_instagram', {
|
|
438
|
+
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).",
|
|
439
|
+
inputSchema: {
|
|
440
|
+
query: z.string().describe('keyword to search reels for'),
|
|
441
|
+
limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
|
|
442
|
+
},
|
|
443
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
444
|
+
}, wrap(async ({ query, limit }) => {
|
|
445
|
+
const d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query });
|
|
446
|
+
const all = (d.reels || d.items || []).map((r) => {
|
|
447
|
+
const o = r.owner || r.user || {};
|
|
448
|
+
return qp({
|
|
449
|
+
desc: trunc(typeof r.caption === 'string' ? r.caption : (r.caption?.text || r.accessibility_caption)),
|
|
450
|
+
author: o.full_name || o.username, handle: o.username,
|
|
451
|
+
plays: r.video_play_count || r.video_view_count, likes: r.like_count || r.edge_liked_by?.count,
|
|
452
|
+
link: r.url || (r.shortcode ? `https://www.instagram.com/reel/${r.shortcode}/` : ''),
|
|
453
|
+
cover: r.thumbnail_src || r.display_url,
|
|
454
|
+
});
|
|
455
|
+
}).filter((x) => x.cover || x.link).sort((a, b) => (b.plays || b.likes || 0) - (a.plays || a.likes || 0));
|
|
456
|
+
return adsOut('reels', all.length, all.slice(0, nAds(limit)));
|
|
457
|
+
}));
|
|
458
|
+
|
|
459
|
+
server.registerTool('search_youtube', {
|
|
460
|
+
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).",
|
|
461
|
+
inputSchema: {
|
|
462
|
+
query: z.string().describe('keyword to search videos for'),
|
|
463
|
+
limit: z.number().int().optional().describe('max videos returned (1–25, default 8)'),
|
|
464
|
+
},
|
|
465
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
466
|
+
}, wrap(async ({ query, limit }) => {
|
|
467
|
+
const d = await apiGet('/api/sc/run', { __path: '/v1/youtube/search', query });
|
|
468
|
+
const all = (d.videos || []).filter((v) => (v.type || 'video') === 'video').map((v) => {
|
|
469
|
+
const ch = v.channel || {};
|
|
470
|
+
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 });
|
|
471
|
+
}).filter((x) => x.cover || x.link).sort((a, b) => (b.plays || 0) - (a.plays || 0));
|
|
472
|
+
return adsOut('videos', all.length, all.slice(0, nAds(limit)));
|
|
473
|
+
}));
|
|
474
|
+
|
|
475
|
+
server.registerTool('search_reddit', {
|
|
476
|
+
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).",
|
|
477
|
+
inputSchema: {
|
|
478
|
+
query: z.string().describe('what to search Reddit for'),
|
|
479
|
+
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
480
|
+
},
|
|
481
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
482
|
+
}, wrap(async ({ query, limit }) => {
|
|
483
|
+
const d = await apiGet('/api/sc/run', { __path: '/v1/reddit/search', query, sort: 'top' });
|
|
484
|
+
const all = (d.posts || d.results || []).map((p) => qp({
|
|
485
|
+
desc: trunc([p.title, p.selftext].filter(Boolean).join(' — '), 260),
|
|
486
|
+
subreddit: p.subreddit ? `r/${p.subreddit}` : '', upvotes: p.ups ?? p.score, comments: p.num_comments,
|
|
487
|
+
link: p.permalink ? (/^https?:/.test(p.permalink) ? p.permalink : `https://www.reddit.com${p.permalink}`) : p.url,
|
|
488
|
+
})).filter((x) => x.desc || x.link);
|
|
489
|
+
return adsOut('posts', all.length, all.slice(0, nAds(limit)));
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
server.registerTool('search_threads', {
|
|
493
|
+
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).",
|
|
494
|
+
inputSchema: {
|
|
495
|
+
query: z.string().describe('keyword to search Threads for'),
|
|
496
|
+
limit: z.number().int().optional().describe('max posts returned (1–25, default 8)'),
|
|
497
|
+
},
|
|
498
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
499
|
+
}, wrap(async ({ query, limit }) => {
|
|
500
|
+
const d = await apiGet('/api/sc/run', { __path: '/v1/threads/search', query });
|
|
501
|
+
const all = (d.posts || d.results || []).map((p) => {
|
|
502
|
+
const u = p.user || {};
|
|
503
|
+
return qp({
|
|
504
|
+
desc: trunc((p.caption && (p.caption.text || (typeof p.caption === 'string' ? p.caption : ''))) || p.accessibility_caption),
|
|
505
|
+
author: u.full_name || u.username, handle: u.username, likes: p.like_count,
|
|
506
|
+
link: p.code && u.username ? `https://www.threads.net/@${u.username}/post/${p.code}` : '',
|
|
507
|
+
cover: p.image_versions2?.candidates?.[0]?.url,
|
|
508
|
+
});
|
|
509
|
+
}).filter((x) => x.desc || x.link || x.cover);
|
|
510
|
+
return adsOut('posts', all.length, all.slice(0, nAds(limit)));
|
|
511
|
+
}));
|
|
512
|
+
|
|
513
|
+
server.registerTool('scrapecreators_fetch', {
|
|
514
|
+
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.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
|
|
517
|
+
params: z.object({}).passthrough().optional().describe("endpoint query params, e.g. {handle:'nike'}"),
|
|
518
|
+
},
|
|
519
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
520
|
+
}, wrap(async ({ path, params }) => {
|
|
521
|
+
const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
|
|
522
|
+
const raw = JSON.stringify(d);
|
|
523
|
+
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
|
|
524
|
+
}));
|
|
525
|
+
|
|
335
526
|
// ---------- brand onboarding ----------
|
|
336
527
|
server.registerTool('get_brand', {
|
|
337
528
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
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": {
|
|
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
|
}
|