hermoso 0.1.16 → 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 +216 -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;
|
|
@@ -406,6 +406,41 @@ export function registerTools(server) {
|
|
|
406
406
|
|
|
407
407
|
// BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
|
|
408
408
|
// billing_status (full picture + your role) → upgrade_plan / set_auto_reload (admin-only, pay-on-Stripe / in-app).
|
|
409
|
+
|
|
410
|
+
// ── FEEDBACK: let the AGENT report a bug or ask for a capability we don't have ────────────────────────────────
|
|
411
|
+
// Dave 2026-07-26: someone driving Hermoso from OpenClaw/Claude/Cursor hits a bug or a missing capability mid-task.
|
|
412
|
+
// Today that feedback dies in their terminal. These two tools turn the agent itself into the reporter — it already
|
|
413
|
+
// has the exact context (what it tried, what came back), which is better than anything a human would retype later.
|
|
414
|
+
// Both just email the team. Free, no credits.
|
|
415
|
+
server.registerTool('report_bug', {
|
|
416
|
+
title: 'Report a bug',
|
|
417
|
+
description: "Report a bug in Hermoso to the team. Use this when something in Hermoso genuinely misbehaves — a tool errors unexpectedly, returns a wrong or malformed result, a render comes back broken, or documented behaviour doesn't match what happened. Include what you were trying to do, the exact tool call and arguments, and what came back. Do NOT use it for out-of-credits, a policy refusal, or a missing capability (use request_feature for that). Free, no credits.",
|
|
418
|
+
inputSchema: {
|
|
419
|
+
summary: z.string().describe('one-line summary of the bug'),
|
|
420
|
+
details: z.string().describe('what you were doing, the tool + arguments you called, what you expected, and what actually happened (paste the exact error)'),
|
|
421
|
+
severity: z.enum(['low', 'medium', 'high']).optional().describe('high = blocks the task or loses paid work; medium = wrong output but workable; low = cosmetic'),
|
|
422
|
+
},
|
|
423
|
+
outputSchema: { ok: z.boolean().optional(), message: z.string().optional() },
|
|
424
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
425
|
+
}, wrap(async ({ summary, details, severity }) => {
|
|
426
|
+
const d = await apiPost('/api/feedback/report', { kind: 'bug', summary, details, severity: severity || 'medium' });
|
|
427
|
+
return ok(d.message || 'Reported — thanks. The team gets this by email with your account attached.', d);
|
|
428
|
+
}));
|
|
429
|
+
|
|
430
|
+
server.registerTool('request_feature', {
|
|
431
|
+
title: 'Request a feature',
|
|
432
|
+
description: "Ask the Hermoso team for a capability that doesn't exist yet. Use this when you need something Hermoso genuinely can't do — an unsupported platform or channel, a missing model, an export format, a tool that would have completed the user's task but isn't available. Say what the user was trying to achieve, not just the feature name — the use case is what gets built. Free, no credits.",
|
|
433
|
+
inputSchema: {
|
|
434
|
+
summary: z.string().describe('one line: the capability you need'),
|
|
435
|
+
details: z.string().describe("what the user was actually trying to achieve, why the current tools couldn't do it, and what you'd expect the capability to do"),
|
|
436
|
+
},
|
|
437
|
+
outputSchema: { ok: z.boolean().optional(), message: z.string().optional() },
|
|
438
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
439
|
+
}, wrap(async ({ summary, details }) => {
|
|
440
|
+
const d = await apiPost('/api/feedback/report', { kind: 'feature', summary, details });
|
|
441
|
+
return ok(d.message || 'Sent — thanks. The team reads every one of these.', d);
|
|
442
|
+
}));
|
|
443
|
+
|
|
409
444
|
server.registerTool('billing_status', {
|
|
410
445
|
title: 'Billing status',
|
|
411
446
|
description: "Show this account's billing at a glance: current plan (id + label + monthly price), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing.",
|
|
@@ -521,6 +556,184 @@ export function registerTools(server) {
|
|
|
521
556
|
return ok(`Now acting on ${hit.name} (${hit.id}) — brand, memory, renders and Library all scope to it.`, { ok: true, brand: hit });
|
|
522
557
|
}));
|
|
523
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
|
+
|
|
524
737
|
// ---------- META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta) ----------
|
|
525
738
|
server.registerTool('list_meta_pages', {
|
|
526
739
|
title: 'List Meta pages & ad accounts',
|
|
@@ -780,6 +993,7 @@ export function registerTools(server) {
|
|
|
780
993
|
budgetResourceName: z.string().optional().describe('reuse an existing budget instead of creating one'),
|
|
781
994
|
channelType: z.enum(['SEARCH', 'DISPLAY']).optional().describe('default SEARCH'),
|
|
782
995
|
searchPartners: z.boolean().optional().describe('SEARCH only — also serve on Google search partners (default false)'),
|
|
996
|
+
dryRun: z.boolean().optional().describe('validate the config against Google without creating anything — proves the account accepts writes and that the fields are valid. Nothing is written and no budget is consumed'),
|
|
783
997
|
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
784
998
|
},
|
|
785
999
|
outputSchema: { ok: z.boolean().optional(), campaignResourceName: z.string().optional(), campaignId: z.string().optional(), status: z.string().optional(), budgetResourceName: z.string().optional() },
|
|
@@ -2164,7 +2378,7 @@ export function registerTools(server) {
|
|
|
2164
2378
|
try {
|
|
2165
2379
|
const cur = save === true ? null : await apiGet('/api/brand/current').catch(() => null);
|
|
2166
2380
|
if (save === true || !cur?.hasBrand) {
|
|
2167
|
-
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
|
|
2168
2382
|
await apiPut(`/api/store/${encodeURIComponent(bk)}`, { value: JSON.stringify(p) });
|
|
2169
2383
|
saved = true;
|
|
2170
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",
|