hermoso 0.1.17 → 0.1.20
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/client.mjs +12 -3
- package/mcp/tools.mjs +180 -2
- package/package.json +1 -1
package/mcp/client.mjs
CHANGED
|
@@ -13,11 +13,15 @@ export const mcpCtx = new AsyncLocalStorage();
|
|
|
13
13
|
|
|
14
14
|
export const API_BASE = (process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai').replace(/\/+$/, '');
|
|
15
15
|
const TOKEN = process.env.HERMOSO_TOKEN || '';
|
|
16
|
-
|
|
16
|
+
// PINNED profile, or '' when unpinned. MUST stay unset by default: the server resolves an API key's profile as
|
|
17
|
+
// header > key.keyProfileId > 'default' (adapters/auth/middleware.js), so always sending the header permanently
|
|
18
|
+
// masks the brand `use_brand` saved against the key — connectors on any non-default brand then look disconnected.
|
|
19
|
+
export const PROFILE = process.env.HERMOSO_PROFILE || '';
|
|
17
20
|
|
|
18
21
|
function headers(extra = {}) {
|
|
19
22
|
const ctx = mcpCtx.getStore();
|
|
20
|
-
const
|
|
23
|
+
const prof = ctx?.profile || PROFILE; // omitted when unpinned so the key's saved brand wins server-side
|
|
24
|
+
const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-hermoso-user': prof } : {}), ...extra };
|
|
21
25
|
const tok = ctx?.token || TOKEN;
|
|
22
26
|
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
23
27
|
return h;
|
|
@@ -35,7 +39,12 @@ async function unwrap(res) {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export async function apiGet(p, query) {
|
|
38
|
-
|
|
42
|
+
// URLSearchParams stringifies undefined/null as the LITERAL "undefined"/"null" — so an omitted optional param
|
|
43
|
+
// arrives as a truthy string and silently changes server behaviour. Live 2026-07-27: list_google_ads_campaigns
|
|
44
|
+
// sent since=undefined&until=undefined, the server saw two truthy values, took the BETWEEN branch, and its
|
|
45
|
+
// digit-strip reduced them to '' → GAQL "segments.date BETWEEN '' and ''". Drop empties before building the qs.
|
|
46
|
+
const clean = query && Object.fromEntries(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
47
|
+
const qs = clean && Object.keys(clean).length ? '?' + new URLSearchParams(clean).toString() : '';
|
|
39
48
|
const res = await fetch(`${API_BASE}${p}${qs}`, { headers: headers() });
|
|
40
49
|
return unwrap(res);
|
|
41
50
|
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -272,7 +272,7 @@ function registerAppResources(server) {
|
|
|
272
272
|
// the whole {key:{value}} map, so a per-key READ resolves the value out of it. WRITES go through `PUT /api/store/:key`,
|
|
273
273
|
// which union-merges the sync stores server-side (adapters/sync-merge.js) so a snapshot never clobbers another device's
|
|
274
274
|
// concurrent work. Keys are per-profile namespaced exactly like the webapp's pk() (bare for default, `<base>.<id>` else).
|
|
275
|
-
const pk = (base) => (PROFILE !== 'default' ? `${base}.${PROFILE}` : base);
|
|
275
|
+
const pk = (base) => (PROFILE && PROFILE !== 'default' ? `${base}.${PROFILE}` : base); // '' (unpinned) behaves like default
|
|
276
276
|
async function readStore(base) {
|
|
277
277
|
let dump; try { dump = await apiGet('/api/store/bootstrap'); } catch { return null; }
|
|
278
278
|
const raw = dump && dump[pk(base)] && dump[pk(base)].value;
|
|
@@ -556,6 +556,184 @@ export function registerTools(server) {
|
|
|
556
556
|
return ok(`Now acting on ${hit.name} (${hit.id}) — brand, memory, renders and Library all scope to it.`, { ok: true, brand: hit });
|
|
557
557
|
}));
|
|
558
558
|
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
// ---------- META engagement + insights (organic performance and the comment thread under a post) ----------
|
|
562
|
+
server.registerTool('meta_page_insights', {
|
|
563
|
+
title: 'Facebook Page + Instagram insights',
|
|
564
|
+
description: 'Organic performance for the brand’s connected Facebook Page and its linked Instagram account — impressions, reach, engagement, follower/fan counts. This is ORGANIC reach; use meta_insights for paid ad performance.',
|
|
565
|
+
inputSchema: {
|
|
566
|
+
pageId: z.string().optional().describe('Page id — omit when the brand has exactly one Page connected'),
|
|
567
|
+
period: z.enum(['day', 'week', 'days_28']).optional().describe('window (default week)'),
|
|
568
|
+
},
|
|
569
|
+
outputSchema: { pageName: z.string().optional(), page: z.array(z.any()).optional(), instagram: z.array(z.any()).optional() },
|
|
570
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
571
|
+
}, wrap(async (a) => {
|
|
572
|
+
const d = await apiGet('/api/meta/page-insights', { pageId: a.pageId, period: a.period });
|
|
573
|
+
const fmt = (arr) => (arr || []).map(m => ` • ${m.name}: ${m.value ?? '—'}`).join('\n');
|
|
574
|
+
return ok(`${d.pageName} (${d.period})\nFacebook:\n${fmt(d.page) || ' (none)'}${d.instagram ? `\nInstagram:\n${fmt(d.instagram)}` : ''}${d.pageError ? `\n(page: ${d.pageError})` : ''}${d.instagramError ? `\n(instagram: ${d.instagramError})` : ''}`, d);
|
|
575
|
+
}));
|
|
576
|
+
|
|
577
|
+
server.registerTool('meta_post_insights', {
|
|
578
|
+
title: 'Insights for one Facebook/Instagram post',
|
|
579
|
+
description: 'Performance for a single organic post — impressions/reach, engagement and clicks on Facebook; reach, likes, comments, saves and shares on Instagram. Use it to find which organic posts earned their reach before turning one into a paid ad.',
|
|
580
|
+
inputSchema: {
|
|
581
|
+
postId: z.string().describe('post/media id returned by post_to_meta'),
|
|
582
|
+
target: z.enum(['facebook', 'instagram']).optional().describe('which metric set to ask for (default facebook)'),
|
|
583
|
+
pageId: z.string().optional().describe('Page id — omit when only one Page is connected'),
|
|
584
|
+
},
|
|
585
|
+
outputSchema: { postId: z.string().optional(), metrics: z.array(z.any()).optional() },
|
|
586
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
587
|
+
}, wrap(async (a) => {
|
|
588
|
+
const d = await apiGet('/api/meta/post-insights', { postId: a.postId, target: a.target, pageId: a.pageId });
|
|
589
|
+
return ok(`${d.target} post ${d.postId}:\n${(d.metrics || []).map(m => `• ${m.name}: ${m.value ?? '—'}`).join('\n') || '(no metrics)'}`, d);
|
|
590
|
+
}));
|
|
591
|
+
|
|
592
|
+
server.registerTool('list_meta_comments', {
|
|
593
|
+
title: 'Read comments on a Meta post',
|
|
594
|
+
description: 'Read the comments under a Facebook Page post or Instagram media object — customer questions, objections and the exact language real people use about the product. Good raw material for ad copy, and the first step before replying or moderating.',
|
|
595
|
+
inputSchema: {
|
|
596
|
+
postId: z.string().describe('post/media id'),
|
|
597
|
+
pageId: z.string().optional().describe('Page id — omit when only one Page is connected'),
|
|
598
|
+
limit: z.number().optional().describe('how many comments (1–50, default 25)'),
|
|
599
|
+
},
|
|
600
|
+
outputSchema: { count: z.number().optional(), comments: z.array(z.any()).optional() },
|
|
601
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
602
|
+
}, wrap(async (a) => {
|
|
603
|
+
const d = await apiGet('/api/meta/comments', { postId: a.postId, pageId: a.pageId, limit: a.limit });
|
|
604
|
+
const lines = (d.comments || []).map(c => `• ${c.author || '(unknown)'}: ${String(c.text).replace(/\s+/g, ' ').slice(0, 90)} — ${c.id}${c.hidden ? ' [hidden]' : ''}${c.likes ? ` · ${c.likes} likes` : ''}`);
|
|
605
|
+
return ok(`${d.count} comment(s) on ${d.postId}:\n${lines.join('\n') || '(none)'}`, d);
|
|
606
|
+
}));
|
|
607
|
+
|
|
608
|
+
server.registerTool('reply_to_meta_comment', {
|
|
609
|
+
title: 'Reply to a Facebook/Instagram comment',
|
|
610
|
+
description: 'Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first.',
|
|
611
|
+
inputSchema: {
|
|
612
|
+
commentId: z.string().describe('comment id from list_meta_comments'),
|
|
613
|
+
message: z.string().describe('reply text'),
|
|
614
|
+
pageId: z.string().optional().describe('Page id — omit when only one Page is connected'),
|
|
615
|
+
},
|
|
616
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
617
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
618
|
+
}, wrap(async (a) => {
|
|
619
|
+
const d = await apiPost('/api/meta/comment/reply', { commentId: a.commentId, message: a.message, pageId: a.pageId });
|
|
620
|
+
return ok(`Replied to comment ${a.commentId} (${d.id}).`, d);
|
|
621
|
+
}));
|
|
622
|
+
|
|
623
|
+
server.registerTool('moderate_meta_comment', {
|
|
624
|
+
title: 'Hide, unhide or delete a Meta comment',
|
|
625
|
+
description: 'Moderate a comment on the brand’s Facebook or Instagram post. Prefer hide over delete — hiding is reversible and invisible to the commenter. Deleting is PERMANENT and requires confirm:true after the user has agreed.',
|
|
626
|
+
inputSchema: {
|
|
627
|
+
commentId: z.string().describe('comment id from list_meta_comments'),
|
|
628
|
+
action: z.enum(['hide', 'unhide', 'delete']).optional().describe('default hide'),
|
|
629
|
+
confirm: z.boolean().optional().describe('required (true) only for delete'),
|
|
630
|
+
pageId: z.string().optional().describe('Page id — omit when only one Page is connected'),
|
|
631
|
+
},
|
|
632
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional() },
|
|
633
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
634
|
+
}, wrap(async (a) => {
|
|
635
|
+
const d = await apiPost('/api/meta/comment/moderate', { commentId: a.commentId, action: a.action, confirm: a.confirm, pageId: a.pageId });
|
|
636
|
+
return ok(`Comment ${d.commentId}: ${d.action}d.`, d);
|
|
637
|
+
}));
|
|
638
|
+
|
|
639
|
+
// ---------- THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review) ----
|
|
640
|
+
server.registerTool('list_threads_posts', {
|
|
641
|
+
title: 'List your Threads posts',
|
|
642
|
+
description: 'List recent posts on the brand’s connected Threads account (id, text, media, permalink, timestamp). Use it to find a post id for threads_insights, list_threads_replies, reply_to_thread or delete_thread.',
|
|
643
|
+
inputSchema: { limit: z.number().optional().describe('how many posts (1–50, default 15)') },
|
|
644
|
+
outputSchema: { username: z.string().optional(), count: z.number().optional(), posts: z.array(z.any()).optional() },
|
|
645
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
646
|
+
}, wrap(async (a) => {
|
|
647
|
+
const d = await apiGet('/api/threads/posts', { limit: a.limit });
|
|
648
|
+
const lines = (d.posts || []).map(p => `• ${String(p.text || '(no text)').replace(/\s+/g, ' ').slice(0, 80)} — ${p.id} · ${String(p.timestamp || '').slice(0, 10)} · ${p.permalink || ''}`);
|
|
649
|
+
return ok(`@${d.username} — ${d.count} post(s):\n${lines.join('\n') || '(none)'}`, d);
|
|
650
|
+
}));
|
|
651
|
+
|
|
652
|
+
server.registerTool('threads_insights', {
|
|
653
|
+
title: 'Threads insights',
|
|
654
|
+
description: 'Performance for ONE Threads post (views, likes, replies, reposts, quotes, shares) when postId is given, or for the whole account (plus follower count) when it is omitted. Use it to report results or to learn which posts worked before writing more.',
|
|
655
|
+
inputSchema: { postId: z.string().optional().describe('post id from list_threads_posts — omit for account-level insights') },
|
|
656
|
+
outputSchema: { scope: z.string().optional(), metrics: z.array(z.any()).optional() },
|
|
657
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
658
|
+
}, wrap(async (a) => {
|
|
659
|
+
const d = await apiGet('/api/threads/insights', { postId: a.postId });
|
|
660
|
+
const lines = (d.metrics || []).map(m => `• ${m.name}: ${m.values?.[0]?.value ?? m.total_value?.value ?? '—'}`);
|
|
661
|
+
return ok(`${d.scope === 'post' ? `Post ${d.postId}` : `@${d.username} (account)`}\n${lines.join('\n') || '(no metrics returned)'}`, d);
|
|
662
|
+
}));
|
|
663
|
+
|
|
664
|
+
server.registerTool('list_threads_replies', {
|
|
665
|
+
title: 'List replies on a Threads post',
|
|
666
|
+
description: 'Read the replies on a Threads post. Set conversation:true to walk the entire thread rather than only direct replies. Use before reply_to_thread so you answer with the actual conversation in view.',
|
|
667
|
+
inputSchema: {
|
|
668
|
+
postId: z.string().describe('post id from list_threads_posts'),
|
|
669
|
+
conversation: z.boolean().optional().describe('true = the whole thread, not just direct replies'),
|
|
670
|
+
limit: z.number().optional().describe('how many replies (1–50, default 25)'),
|
|
671
|
+
},
|
|
672
|
+
outputSchema: { count: z.number().optional(), replies: z.array(z.any()).optional() },
|
|
673
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
674
|
+
}, wrap(async (a) => {
|
|
675
|
+
const d = await apiGet('/api/threads/replies', { postId: a.postId, conversation: a.conversation ? 'true' : undefined, limit: a.limit });
|
|
676
|
+
const lines = (d.replies || []).map(r => `• @${r.username}: ${String(r.text || '').replace(/\s+/g, ' ').slice(0, 90)} — ${r.id}${r.hide_status === 'HIDDEN' ? ' [hidden]' : ''}`);
|
|
677
|
+
return ok(`${d.count} repl(ies) on ${d.postId}:\n${lines.join('\n') || '(none)'}`, d);
|
|
678
|
+
}));
|
|
679
|
+
|
|
680
|
+
server.registerTool('reply_to_thread', {
|
|
681
|
+
title: 'Reply on Threads',
|
|
682
|
+
description: 'Post a reply to a Threads post — the brand’s own or someone else’s. This PUBLISHES publicly under the brand’s account, so show the user the exact wording and get their go-ahead first.',
|
|
683
|
+
inputSchema: {
|
|
684
|
+
replyToId: z.string().describe('the post id being replied to'),
|
|
685
|
+
text: z.string().describe('reply text (max 500 characters)'),
|
|
686
|
+
},
|
|
687
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
|
|
688
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
689
|
+
}, wrap(async (a) => {
|
|
690
|
+
const d = await apiPost('/api/threads/reply', { replyToId: a.replyToId, text: a.text });
|
|
691
|
+
return ok(`Replied on Threads (${d.id}).`, d);
|
|
692
|
+
}));
|
|
693
|
+
|
|
694
|
+
server.registerTool('hide_thread_reply', {
|
|
695
|
+
title: 'Hide or unhide a Threads reply',
|
|
696
|
+
description: 'Hide a reply on the brand’s Threads post (or unhide it with hide:false) — for spam and abuse moderation.',
|
|
697
|
+
inputSchema: {
|
|
698
|
+
replyId: z.string().describe('reply id from list_threads_replies'),
|
|
699
|
+
hide: z.boolean().optional().describe('false to UNHIDE (default true)'),
|
|
700
|
+
},
|
|
701
|
+
outputSchema: { ok: z.boolean().optional(), hidden: z.boolean().optional() },
|
|
702
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
703
|
+
}, wrap(async (a) => {
|
|
704
|
+
const d = await apiPost('/api/threads/reply/hide', { replyId: a.replyId, hide: a.hide });
|
|
705
|
+
return ok(`Reply ${a.replyId} ${d.hidden ? 'hidden' : 'unhidden'}.`, d);
|
|
706
|
+
}));
|
|
707
|
+
|
|
708
|
+
server.registerTool('delete_thread', {
|
|
709
|
+
title: 'Delete a Threads post',
|
|
710
|
+
description: 'Permanently delete one of the brand’s Threads posts. IRREVERSIBLE — you must confirm with the user first, then pass confirm:true.',
|
|
711
|
+
inputSchema: {
|
|
712
|
+
postId: z.string().describe('post id from list_threads_posts'),
|
|
713
|
+
confirm: z.boolean().describe('must be true; only set it after the user has explicitly agreed to the deletion'),
|
|
714
|
+
},
|
|
715
|
+
outputSchema: { ok: z.boolean().optional(), deleted: z.string().optional() },
|
|
716
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
717
|
+
}, wrap(async (a) => {
|
|
718
|
+
const d = await apiPost('/api/threads/delete', { postId: a.postId, confirm: a.confirm });
|
|
719
|
+
return ok(`Deleted Threads post ${d.deleted}.`, d);
|
|
720
|
+
}));
|
|
721
|
+
|
|
722
|
+
server.registerTool('search_threads_keyword', {
|
|
723
|
+
title: 'Search Threads by keyword',
|
|
724
|
+
description: 'Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.',
|
|
725
|
+
inputSchema: {
|
|
726
|
+
q: z.string().describe('keyword or phrase'),
|
|
727
|
+
searchType: z.enum(['TOP', 'RECENT']).optional().describe('TOP (default) or RECENT'),
|
|
728
|
+
},
|
|
729
|
+
outputSchema: { count: z.number().optional(), posts: z.array(z.any()).optional() },
|
|
730
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
731
|
+
}, wrap(async (a) => {
|
|
732
|
+
const d = await apiGet('/api/threads/search', { q: a.q, searchType: a.searchType });
|
|
733
|
+
const lines = (d.posts || []).map(p => `• @${p.username}: ${String(p.text || '').replace(/\s+/g, ' ').slice(0, 90)} — ${p.permalink || p.id}`);
|
|
734
|
+
return ok(`${d.count} result(s) for "${d.q}":\n${lines.join('\n') || '(none)'}`, d);
|
|
735
|
+
}));
|
|
736
|
+
|
|
559
737
|
// ---------- META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta) ----------
|
|
560
738
|
server.registerTool('list_meta_pages', {
|
|
561
739
|
title: 'List Meta pages & ad accounts',
|
|
@@ -2200,7 +2378,7 @@ export function registerTools(server) {
|
|
|
2200
2378
|
try {
|
|
2201
2379
|
const cur = save === true ? null : await apiGet('/api/brand/current').catch(() => null);
|
|
2202
2380
|
if (save === true || !cur?.hasBrand) {
|
|
2203
|
-
const bk = PROFILE !== 'default' ? `heist.brand.v1.${PROFILE}` : 'heist.brand.v1'; // mirror the webapp's per-profile key namespacing
|
|
2381
|
+
const bk = PROFILE && PROFILE !== 'default' ? `heist.brand.v1.${PROFILE}` : 'heist.brand.v1'; // mirror the webapp's per-profile key namespacing
|
|
2204
2382
|
await apiPut(`/api/store/${encodeURIComponent(bk)}`, { value: JSON.stringify(p) });
|
|
2205
2383
|
saved = true;
|
|
2206
2384
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
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",
|