hermoso 0.1.10 → 0.1.11
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 +149 -9
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -486,6 +486,106 @@ export function registerTools(server) {
|
|
|
486
486
|
return ok(`Now acting on ${hit.name} (${hit.id}) — brand, memory, renders and Library all scope to it.`, { ok: true, brand: hit });
|
|
487
487
|
}));
|
|
488
488
|
|
|
489
|
+
// ---------- META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta) ----------
|
|
490
|
+
server.registerTool('list_meta_pages', {
|
|
491
|
+
title: 'List Meta pages & ad accounts',
|
|
492
|
+
description: 'List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Settings ▸ Connectors ▸ Meta); returns a connect hint if not.',
|
|
493
|
+
inputSchema: {},
|
|
494
|
+
outputSchema: { pages: z.array(z.any()).optional(), adAccounts: z.array(z.any()).optional() },
|
|
495
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
496
|
+
}, wrap(async () => {
|
|
497
|
+
const [pg, aa] = await Promise.all([apiGet('/api/meta/pages').catch((e) => ({ __err: e.message })), apiGet('/api/meta/adaccounts').catch((e) => ({ __err: e.message }))]);
|
|
498
|
+
if (pg.__err && /connect/i.test(pg.__err)) return { content: [{ type: 'text', text: 'No Meta account connected yet — connect it in Settings ▸ Connectors ▸ Meta, then try again.' }], isError: true };
|
|
499
|
+
const pages = pg.pages || [], adAccounts = aa.adAccounts || [];
|
|
500
|
+
return ok(`Pages: ${pages.map(p => p.name + (p.instagram ? ` (IG @${p.instagram.username})` : '')).join(', ') || 'none'}\nAd accounts: ${adAccounts.map(a => `${a.name} (act_${a.accountId}, ${a.currency}${a.active ? '' : ', inactive'})`).join(', ') || 'none'}`, { pages, adAccounts });
|
|
501
|
+
}));
|
|
502
|
+
server.registerTool('post_to_meta', {
|
|
503
|
+
title: 'Post to Facebook or Instagram',
|
|
504
|
+
description: 'Publish to a connected Facebook Page OR its linked Instagram — text/link/image/VIDEO (public https URLs). target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video). Perfect for shipping a finished Hermoso ad straight to the brand’s socials. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings ▸ Connectors ▸ Meta) with posting permission.',
|
|
505
|
+
inputSchema: {
|
|
506
|
+
message: z.string().optional().describe('post text / caption'),
|
|
507
|
+
imageUrl: z.string().optional().describe('public https:// image URL'),
|
|
508
|
+
videoUrl: z.string().optional().describe('public https:// video URL (FB video post / IG Reel)'),
|
|
509
|
+
link: z.string().optional().describe('a URL to attach (FB text post only)'),
|
|
510
|
+
target: z.enum(['facebook', 'instagram']).optional().describe('default facebook; instagram publishes to the Page’s linked IG account'),
|
|
511
|
+
pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
|
|
512
|
+
},
|
|
513
|
+
outputSchema: { ok: z.boolean().optional(), postId: z.string().optional(), url: z.string().optional(), target: z.string().optional(), page: z.string().optional(), account: z.string().optional() },
|
|
514
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
515
|
+
}, wrap(async (a) => {
|
|
516
|
+
const d = await apiPost('/api/meta/post', a);
|
|
517
|
+
return ok(`Published to ${d.account || d.page || d.target}${d.url ? ` — ${d.url}` : ''} (post ${d.postId}).`, d);
|
|
518
|
+
}));
|
|
519
|
+
server.registerTool('upload_meta_asset', {
|
|
520
|
+
title: 'Upload an asset to a Meta ad account',
|
|
521
|
+
description: 'Upload a finished creative (image or video, public https URL) into a connected ad account’s ASSET LIBRARY so the user — or a later ad-build step — can use it in their OWN campaigns. Great when the user just wants Hermoso to hand off the creative into Meta, not run the campaign. Image returns an image hash; video returns a video id (reference these when building an ad). Pass adAccountId from list_meta_pages.',
|
|
522
|
+
inputSchema: {
|
|
523
|
+
adAccountId: z.string().describe('ad account id (digits or act_… — from list_meta_pages)'),
|
|
524
|
+
url: z.string().describe('public https:// image or video URL'),
|
|
525
|
+
kind: z.enum(['image', 'video']).optional().describe('inferred from the URL if omitted'),
|
|
526
|
+
name: z.string().optional().describe('a label for the asset'),
|
|
527
|
+
},
|
|
528
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), hash: z.string().optional(), videoId: z.string().optional() },
|
|
529
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
530
|
+
}, wrap(async (a) => {
|
|
531
|
+
const d = await apiPost('/api/meta/upload-asset', a);
|
|
532
|
+
return ok(`Uploaded ${d.kind} to the ad account library${d.hash ? ` (image hash ${d.hash})` : d.videoId ? ` (video id ${d.videoId})` : ''}. ${d.note || ''}`, d);
|
|
533
|
+
}));
|
|
534
|
+
server.registerTool('create_meta_campaign', {
|
|
535
|
+
title: 'Create a Meta ad campaign (paused)',
|
|
536
|
+
description: 'Create a campaign on a connected Meta ad account. Always created PAUSED — it spends NOTHING until you activate it with set_meta_campaign_status(confirm:true). Optionally set a dailyBudgetUsd. Pass adAccountId (from list_meta_pages) + an objective. Needs ads-management permission on the connected account.',
|
|
537
|
+
inputSchema: {
|
|
538
|
+
name: z.string().describe('campaign name'),
|
|
539
|
+
adAccountId: z.string().describe('ad account id (digits or act_… — from list_meta_pages)'),
|
|
540
|
+
objective: z.enum(['OUTCOME_TRAFFIC', 'OUTCOME_AWARENESS', 'OUTCOME_ENGAGEMENT', 'OUTCOME_LEADS', 'OUTCOME_SALES', 'OUTCOME_APP_PROMOTION']).optional().describe('default OUTCOME_TRAFFIC'),
|
|
541
|
+
dailyBudgetUsd: z.number().optional().describe('optional campaign daily budget in USD (1–10000); real spend once ACTIVE'),
|
|
542
|
+
},
|
|
543
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), status: z.string().optional(), dailyBudgetUsd: z.number().optional() },
|
|
544
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
545
|
+
}, wrap(async (a) => {
|
|
546
|
+
const d = await apiPost('/api/meta/campaign', a);
|
|
547
|
+
return ok(`Created campaign ${d.campaignId} (PAUSED${d.dailyBudgetUsd ? `, $${d.dailyBudgetUsd}/day` : ''}). ${d.note || ''}`, d);
|
|
548
|
+
}));
|
|
549
|
+
server.registerTool('set_meta_campaign_status', {
|
|
550
|
+
title: 'Activate or pause a Meta campaign',
|
|
551
|
+
description: 'Turn a campaign ON (ACTIVE) or OFF (PAUSED). ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"ACTIVE" and confirm:true. Pausing is always safe. Needs ads-management permission.',
|
|
552
|
+
inputSchema: {
|
|
553
|
+
campaignId: z.string().describe('the campaign id (from create_meta_campaign)'),
|
|
554
|
+
status: z.enum(['ACTIVE', 'PAUSED']).describe('ACTIVE = start spending; PAUSED = stop'),
|
|
555
|
+
confirm: z.boolean().optional().describe('REQUIRED true to activate (real spend) — set only after the user explicitly approved the budget'),
|
|
556
|
+
},
|
|
557
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), status: z.string().optional() },
|
|
558
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
559
|
+
}, wrap(async (a) => {
|
|
560
|
+
const d = await apiPost('/api/meta/campaign/status', a);
|
|
561
|
+
return ok(d.note || `Campaign ${a.campaignId} → ${a.status}.`, d);
|
|
562
|
+
}));
|
|
563
|
+
server.registerTool('create_meta_ad', {
|
|
564
|
+
title: 'Build a full Meta ad (campaign → ad set → ad, paused)',
|
|
565
|
+
description: 'Build a complete, ready-to-run Meta ad from image creative(s): campaign → ad set (targeting + daily budget) → creative → ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. Pass adAccountId (from list_meta_pages), an imageUrl (or imageUrls for one ad each), the primary message, and a destination link. IMAGE ads only for now. Needs ads-management on the connected account.',
|
|
566
|
+
inputSchema: {
|
|
567
|
+
adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
|
|
568
|
+
imageUrl: z.string().optional().describe('public https image URL for the ad creative'),
|
|
569
|
+
imageUrls: z.array(z.string()).optional().describe('several image URLs → one ad each'),
|
|
570
|
+
message: z.string().optional().describe('primary ad text'),
|
|
571
|
+
headline: z.string().optional().describe('optional headline'),
|
|
572
|
+
link: z.string().optional().describe('destination URL (defaults to the brand domain)'),
|
|
573
|
+
cta: z.string().optional().describe('call-to-action, e.g. SHOP_NOW / LEARN_MORE / SIGN_UP (default LEARN_MORE)'),
|
|
574
|
+
objective: z.enum(['OUTCOME_TRAFFIC', 'OUTCOME_AWARENESS', 'OUTCOME_ENGAGEMENT', 'OUTCOME_LEADS', 'OUTCOME_SALES']).optional().describe('default OUTCOME_TRAFFIC'),
|
|
575
|
+
dailyBudgetUsd: z.number().optional().describe('ad-set daily budget USD (1–10000, default 10) — spends only once ACTIVE'),
|
|
576
|
+
country: z.string().optional().describe('2-letter targeting country (default US)'),
|
|
577
|
+
name: z.string().optional().describe('base name for the campaign/ad set/ads'),
|
|
578
|
+
campaignId: z.string().optional().describe('attach to an existing campaign instead of creating one'),
|
|
579
|
+
pageId: z.string().optional().describe('Page id from list_meta_pages; omit = first Page'),
|
|
580
|
+
},
|
|
581
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), adSetId: z.string().optional(), count: z.number().optional(), status: z.string().optional(), dailyBudgetUsd: z.number().optional() },
|
|
582
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
583
|
+
}, wrap(async (a) => {
|
|
584
|
+
const { imageUrls, ...rest } = a;
|
|
585
|
+
const d = await apiPost('/api/meta/ad', imageUrls?.length ? { ...rest, urls: imageUrls } : rest);
|
|
586
|
+
return ok(`Built a PAUSED campaign with ${d.count} ad(s) — campaign ${d.campaignId}, ad set ${d.adSetId}, $${d.dailyBudgetUsd}/day, optimizing for ${d.optimization}. It spends NOTHING until you activate it with set_meta_campaign_status(confirm:true). ${d.note || ''}`, d);
|
|
587
|
+
}));
|
|
588
|
+
|
|
489
589
|
// ---------- planning (LLM, 0 SC credits) ----------
|
|
490
590
|
server.registerTool('plan_ad', {
|
|
491
591
|
title: 'Plan an ad concept',
|
|
@@ -914,8 +1014,30 @@ export function registerTools(server) {
|
|
|
914
1014
|
},
|
|
915
1015
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
916
1016
|
}, wrap(async (a) => {
|
|
917
|
-
const d = await apiPost('/api/inspire/fanout', { platforms: ['facebook'], country: 'US', limit:
|
|
918
|
-
|
|
1017
|
+
const d = await apiPost('/api/inspire/fanout', { platforms: ['facebook'], country: 'US', limit: Math.min(12, a.limit || 8), sort: 'longest_running', ...a });
|
|
1018
|
+
// SURFACE THE ACTUAL ADS (Dave 2026-07-21: ChatGPT got only "Pulled ads for X" — the structured data never
|
|
1019
|
+
// reached the user). Flatten each platform's ads into compact rows + image blocks, like the search_* tools.
|
|
1020
|
+
const platforms = ['facebook', 'google', 'linkedin'];
|
|
1021
|
+
const rows = [], urls = [];
|
|
1022
|
+
for (const p of platforms) {
|
|
1023
|
+
const pd = d[p]; const ads = (pd && Array.isArray(pd.ads) ? pd.ads : []).slice(0, 8);
|
|
1024
|
+
for (const ad of ads) {
|
|
1025
|
+
const s = ad.snapshot || {};
|
|
1026
|
+
const img = ad.image || s.images?.[0]?.resized_image_url || s.videos?.[0]?.video_preview_image_url || s.cards?.[0]?.resized_image_url || ad.imageUrl || null;
|
|
1027
|
+
const media = s.videos?.[0]?.video_sd_url || img || ad.adUrl || s.link_url || ad.destinationUrl || null;
|
|
1028
|
+
const body = ad.copy || (typeof s.body === 'string' ? s.body : s.body?.text) || ad.headline || '';
|
|
1029
|
+
rows.push(qp({ platform: p, advertiser: ad.page_name || ad.advertiserName || ad.advertiser || (a.companyName || a.domain), body: trunc(body), media }));
|
|
1030
|
+
if (img && /^https?:\/\//.test(img)) urls.push(img);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (!rows.length) {
|
|
1034
|
+
const errs = platforms.map(p => d[p]?.error).filter(Boolean);
|
|
1035
|
+
return ok(`No ads found for "${a.companyName || a.domain}". ${errs.length ? 'Notes: ' + errs.join('; ') + '. ' : ''}Product lines often advertise under their PARENT brand — try the parent company name or its domain, or use research_ads (open cross-platform search).`, d);
|
|
1036
|
+
}
|
|
1037
|
+
const blocks = (await Promise.all([...new Set(urls)].slice(0, 4).map((u) => imageBlock(u).catch(() => null)))).filter(Boolean);
|
|
1038
|
+
const links = rows.filter(r => r.media).slice(0, 6).map((r, i) => `ad ${i + 1} (${r.platform}): ${r.media}`);
|
|
1039
|
+
const text = JSON.stringify({ advertiser: a.companyName || a.domain, showing: rows.length, ads: rows }) + (links.length ? '\n\nCreative URLs (share as clickable links):\n' + links.join('\n') : '');
|
|
1040
|
+
return { content: [{ type: 'text', text }, ...blocks], structuredContent: d };
|
|
919
1041
|
}));
|
|
920
1042
|
|
|
921
1043
|
server.registerTool('research_ads', {
|
|
@@ -934,7 +1056,15 @@ export function registerTools(server) {
|
|
|
934
1056
|
}, wrap(async ({ query, brand }) => {
|
|
935
1057
|
const brandObj = typeof brand === 'string' ? { name: brand } : brand || null;
|
|
936
1058
|
const d = await apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: query }], brand: brandObj });
|
|
937
|
-
|
|
1059
|
+
const res = d.results || [];
|
|
1060
|
+
// pull a still image URL out of each normalized card (ad OR tiktok/social shapes) so ChatGPT/Claude SHOW the
|
|
1061
|
+
// creatives inline (Dave 2026-07-21: research_ads was returning text only, no images)
|
|
1062
|
+
const imgUrl = (r) => { const a = r?.ad?.snapshot || {}; return r?.image || r?.thumb || r?.cover || r?.tiktok?.cover || r?.social?.image || a.images?.[0]?.resized_image_url || a.videos?.[0]?.video_preview_image_url || a.cards?.[0]?.resized_image_url || r?.ad?.imageUrl || null; };
|
|
1063
|
+
const urls = [...new Set(res.map(imgUrl).filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)))].slice(0, 4);
|
|
1064
|
+
const blocks = (await Promise.all(urls.map((u) => imageBlock(u).catch(() => null)))).filter(Boolean);
|
|
1065
|
+
const links = res.slice(0, 6).map((r, i) => { const u = r?.media || r?.video || r?.ad?.adUrl || r?.ad?.snapshot?.link_url || imgUrl(r) || r?.link; return u ? `ad ${i + 1}: ${u}` : null; }).filter(Boolean);
|
|
1066
|
+
const text = `${d.reply || ''}\n\n(${res.length} ads found)` + (links.length ? '\n\nCreative URLs (share as clickable links):\n' + links.join('\n') : '');
|
|
1067
|
+
return { content: [{ type: 'text', text }, ...blocks], structuredContent: { reply: d.reply, results: res, actions: d.actions } };
|
|
938
1068
|
}));
|
|
939
1069
|
|
|
940
1070
|
// ---------- structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop) ----------
|
|
@@ -948,12 +1078,12 @@ export function registerTools(server) {
|
|
|
948
1078
|
// both ChatGPT and Claude). Plus an explicit creative-URL list so the model can hand the user clickable links
|
|
949
1079
|
// (videos especially), and a parent-brand nudge on zero results (SuperBelly is advertised by Blume — a name
|
|
950
1080
|
// miss must trigger resolution, not a shrug).
|
|
951
|
-
const adsOut = async (label, total, items) => {
|
|
1081
|
+
const adsOut = async (label, total, items, note = '') => {
|
|
952
1082
|
const thumbs = items.map((x) => x && (x.thumb || x.image || x.cover || x.media)).filter((u) => typeof u === 'string' && /^https?:\/\//.test(u) && !/\.(mp4|webm|mov)([?#]|$)/i.test(u)).slice(0, 3);
|
|
953
1083
|
const blocks = (await Promise.all(thumbs.map((u) => imageBlock(u).catch(() => null)))).filter(Boolean);
|
|
954
1084
|
const links = items.slice(0, 6).map((x, i) => (x && (x.media || x.image || x.cover)) ? `ad ${i + 1}: ${x.media || x.image || x.cover}` : null).filter(Boolean);
|
|
955
1085
|
const guide = items.length ? '' : '\n\nNo advertiser matched that name. Product LINES are usually advertised by their PARENT brand\u2019s page \u2014 resolve the parent company first (the product\u2019s website footer, or your web search) and retry with that companyName; also try `query` (keyword search across ALL advertisers\u2019 ad copy) and status \u201cALL\u201d (includes past ads). Never conclude a brand runs no ads from a single name miss.';
|
|
956
|
-
const text = JSON.stringify({ found: total, showing: items.length, [label]: items }) + (links.length ? '\n\nTop creative URLs (give the user these as clickable links):\n' + links.join('\n') : '') + guide;
|
|
1086
|
+
const text = JSON.stringify({ found: total, showing: items.length, [label]: items }) + (links.length ? '\n\nTop creative URLs (give the user these as clickable links):\n' + links.join('\n') : '') + note + guide;
|
|
957
1087
|
return { content: [{ type: 'text', text }, ...blocks], structuredContent: { found: total, [label]: items } };
|
|
958
1088
|
};
|
|
959
1089
|
|
|
@@ -977,9 +1107,19 @@ export function registerTools(server) {
|
|
|
977
1107
|
}, wrap(async (a) => {
|
|
978
1108
|
if (!a.query && !a.companyName && !a.pageId) throw new Error('Pass query (keyword) OR companyName/pageId (one advertiser).');
|
|
979
1109
|
const common = qp({ country: a.country, status: a.status, media_type: a.mediaType });
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1110
|
+
// ADVERTISER-MISS AUTO-FALLBACK (2026-07-21, the "Flourish Pancakes" case): the page resolver demands a
|
|
1111
|
+
// high-confidence match and refuses ambiguous names — but the ads are usually findable by KEYWORD search
|
|
1112
|
+
// across ad copy. A name miss now retries as query automatically instead of dead-ending the agent.
|
|
1113
|
+
let d = null, note = '';
|
|
1114
|
+
if (a.query) d = await apiGet('/api/fb/search', { query: a.query, ...common });
|
|
1115
|
+
else {
|
|
1116
|
+
try { d = await apiGet('/api/fb/company-ads', qp({ companyName: a.companyName, pageId: a.pageId, ...common })); } catch (e) { d = null; }
|
|
1117
|
+
if (!((d && (d.results || d.searchResults)) || []).length && a.companyName) {
|
|
1118
|
+
d = await apiGet('/api/fb/search', { query: a.companyName, ...common });
|
|
1119
|
+
if (((d && d.searchResults) || []).length) note = '\n\nNote: no advertiser PAGE matched that name confidently, so these are KEYWORD-search results across all advertisers (verify the page_name matches the brand you meant; a product line often advertises under its parent brand).';
|
|
1120
|
+
}
|
|
1121
|
+
if (!d) d = {};
|
|
1122
|
+
}
|
|
983
1123
|
const raw = d.results || d.searchResults || []; // company-ads → results[], keyword search → searchResults[]
|
|
984
1124
|
const ads = raw.slice(0, nAds(a.limit)).map((x) => {
|
|
985
1125
|
const s = x.snapshot || {};
|
|
@@ -990,7 +1130,7 @@ export function registerTools(server) {
|
|
|
990
1130
|
thumb: s.videos?.[0]?.video_preview_image_url || s.images?.[0]?.resized_image_url || s.cards?.[0]?.resized_image_url, // always an IMAGE url when one exists — feeds the markdown gallery (a video url can't render inline)
|
|
991
1131
|
});
|
|
992
1132
|
});
|
|
993
|
-
return adsOut('ads', d.searchResultsCount ?? raw.length, ads);
|
|
1133
|
+
return adsOut('ads', d.searchResultsCount ?? raw.length, ads, note);
|
|
994
1134
|
}));
|
|
995
1135
|
|
|
996
1136
|
server.registerTool('search_google_ads', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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 — 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",
|