hermoso 0.1.242 → 0.1.243
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/README.md +2 -2
- package/mcp/http.mjs +27 -0
- package/mcp/roster-scope.mjs +14 -0
- package/mcp/tools.mjs +63 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**838 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
171
171
|
|
|
172
172
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
173
173
|
|
|
174
|
-
### What the
|
|
174
|
+
### What the 838 tools cover
|
|
175
175
|
|
|
176
176
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
177
177
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/http.mjs
CHANGED
|
@@ -94,6 +94,21 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
94
94
|
const SESSION_MAX = Math.max(2, Number(process.env.MCP_SESSION_MAX || 16));
|
|
95
95
|
const SESSION_IDLE_MS = Math.max(1000, Number(process.env.MCP_SESSION_IDLE_MS || 30 * 60e3)); // 1s floor so the expiry is TESTABLE; a short TTL is merely wasteful now that eviction is recoverable
|
|
96
96
|
const sessions = new Map(); // mcp-session-id -> { transport, server, user, lastSeen } (insertion-ordered = LRU)
|
|
97
|
+
// ── A STANDALONE SSE STREAM HOLDS A CLOUD RUN REQUEST SLOT FOR AS LONG AS IT LIVES (2026-09-15) ──────────────
|
|
98
|
+
// OUTAGE, 13:00–13:22 UTC: every request to the app answered 429 "no available instance" for 22 minutes with the
|
|
99
|
+
// instance at 1% CPU. Nothing was down and nobody was busy — the instance's 200 concurrent-request slots were all
|
|
100
|
+
// held by `GET /mcp` notification streams (99 of them from ChatGPT's connector that morning, each held until the
|
|
101
|
+
// 30-minute session idle), and Cloud Run refuses a request it has no slot for BEFORE the container sees it, so
|
|
102
|
+
// the app could not even log it. maxScale=1 turns "one host holds too many streams" into a total outage.
|
|
103
|
+
// The GET stream is optional in the spec (a server may answer 405 or close it at any time; clients reconnect),
|
|
104
|
+
// and this server sends nothing on it but a one-shot tools/list_changed nudge. So: a stream lives at most
|
|
105
|
+
// MCP_GET_STREAM_MS (5 min, env-tunable) and the instance holds at most MCP_GET_STREAM_MAX of them at once —
|
|
106
|
+
// past that a GET is answered 503 + Retry-After, which costs the client a reconnect and costs tool calls nothing
|
|
107
|
+
// (they are POSTs and never touch this counter). Pinned by tools/mcp-get-stream-cap-check.mjs.
|
|
108
|
+
const GET_STREAM_MS = Math.max(1000, Number(process.env.MCP_GET_STREAM_MS || 5 * 60e3));
|
|
109
|
+
const GET_STREAM_MAX = Math.max(1, Number(process.env.MCP_GET_STREAM_MAX || 64));
|
|
110
|
+
let openGetStreams = 0;
|
|
111
|
+
const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS });
|
|
97
112
|
// THE 401 IS THE ONLY THING A STUCK AGENT EVER READS, so it carries the way out (2026-09-04). It used to be the
|
|
98
113
|
// three words `Authentication required`, and that is exactly how far a real user got: a Cursor-based client
|
|
99
114
|
// (Grok Bot) registered fine, listed all 160 tools, and then every call 401'd while its own consent card never
|
|
@@ -273,6 +288,17 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
273
288
|
return challenge(res);
|
|
274
289
|
}
|
|
275
290
|
|
|
291
|
+
// The stream cap, BEFORE any session work: a reconnect storm must be refused at the door, not after allocating.
|
|
292
|
+
if (req.method === 'GET') {
|
|
293
|
+
if (openGetStreams >= GET_STREAM_MAX) {
|
|
294
|
+
res.set('Retry-After', '30');
|
|
295
|
+
return res.status(503).json({ error: `This instance already holds ${openGetStreams} open notification streams — retry the stream in 30s. Tool calls (POST) are unaffected.` });
|
|
296
|
+
}
|
|
297
|
+
openGetStreams++;
|
|
298
|
+
const t = setTimeout(() => { try { res.end(); } catch {} }, GET_STREAM_MS); if (t && typeof t.unref === 'function') t.unref();
|
|
299
|
+
res.once('close', () => { openGetStreams = Math.max(0, openGetStreams - 1); clearTimeout(t); });
|
|
300
|
+
}
|
|
301
|
+
|
|
276
302
|
const sid = req.headers['mcp-session-id'];
|
|
277
303
|
let entry = sid ? sessions.get(sid) : null;
|
|
278
304
|
|
|
@@ -364,6 +390,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
364
390
|
});
|
|
365
391
|
|
|
366
392
|
console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
|
|
393
|
+
app.locals.mcpStreamStats = streamStats; // for the check and the admin read; the return value stays `true` as every caller asserts
|
|
367
394
|
return true;
|
|
368
395
|
}
|
|
369
396
|
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -143,11 +143,25 @@ export const INSTAGRAM_LOGIN_TOOLS = new Set([
|
|
|
143
143
|
'list_meta_conversations', 'read_meta_conversation', 'reply_to_meta_message',
|
|
144
144
|
'list_instagram_collab_invites', 'list_instagram_collab_media', 'respond_instagram_collab_invite', 'search_instagram_audio',
|
|
145
145
|
]);
|
|
146
|
+
// THE SAME SHAPE FOR WHATSAPP (2026-09-15, Dave: "do we properly explain to users when they need the meta connector vs
|
|
147
|
+
// individuals like instagram or whatsapp? and when they need both?"). Every whatsapp tool maps to 'meta' above because a
|
|
148
|
+
// WhatsApp Business Account the business already administers is a Meta ASSET, ticked on Meta's assets step and reached
|
|
149
|
+
// through the Meta user token. But a brand that onboarded its OWN number through Embedded Signup holds a 'whatsapp'
|
|
150
|
+
// row whose business token `waToken` resolves FIRST, with no Meta connection at all — holding its WhatsApp tools back
|
|
151
|
+
// as "needs meta" would refuse a working connection. Nobody needs BOTH for one account, on either channel.
|
|
152
|
+
export const isWhatsAppTool = (name) => /whatsapp/.test(String(name || ''));
|
|
153
|
+
// The ONE sentence every surface appends when a 'meta'-mapped tool is held and an alternative connection exists.
|
|
154
|
+
export const metaAlternativeNote = (name) => INSTAGRAM_LOGIN_TOOLS.has(String(name || ''))
|
|
155
|
+
? ' For an Instagram account with no Facebook Page, the "instagram" connection alone is enough for this tool; "meta" covers an Instagram account linked to a Page (and ads). One account never needs both.'
|
|
156
|
+
: isWhatsAppTool(name)
|
|
157
|
+
? ' A WhatsApp Business Account the business already manages is ticked on the "meta" connection\'s assets step; the "whatsapp" connection sets up a number the business does not have yet. Either one is enough for this tool.'
|
|
158
|
+
: '';
|
|
146
159
|
export function toolHeldBackByConnectors(name, conn) {
|
|
147
160
|
if (!conn || !conn.readOk) return false; // property 1 — fail OPEN on an unreadable store
|
|
148
161
|
const p = toolProvider(name);
|
|
149
162
|
if (p === null) return false; // property 2 — unmapped is never held back
|
|
150
163
|
const on = conn.connected instanceof Set ? conn.connected : new Set(conn.connected || []);
|
|
151
164
|
if (p === 'meta' && on.has('instagram') && INSTAGRAM_LOGIN_TOOLS.has(name)) return false;
|
|
165
|
+
if (p === 'meta' && on.has('whatsapp') && isWhatsAppTool(name)) return false;
|
|
152
166
|
return !on.has(p);
|
|
153
167
|
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { wellFormedValue, wellFormedString } from './well-formed.mjs';
|
|
|
14
14
|
// WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
|
|
15
15
|
// re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
|
|
16
16
|
// twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
|
|
17
|
-
import { toolHeldBackByConnectors, toolProvider, toolUnoffered } from './roster-scope.mjs';
|
|
17
|
+
import { toolHeldBackByConnectors, toolProvider, toolUnoffered, metaAlternativeNote } from './roster-scope.mjs';
|
|
18
18
|
|
|
19
19
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
20
20
|
const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
|
|
@@ -2076,10 +2076,10 @@ let TOOL_CANON = null; // [{ name, group, def, handler, factory }] — the one c
|
|
|
2076
2076
|
// readOk:false, which toolHeldBackByConnectors treats as "hold back nothing" ([[failed-read-is-not-empty]]).
|
|
2077
2077
|
// It also DISABLES, not just enables — moving to a workspace with fewer connectors must narrow the roster too,
|
|
2078
2078
|
// or a tool that can only answer 401 stays listed.
|
|
2079
|
-
async function regateForWorkspace(ctx) {
|
|
2079
|
+
async function regateForWorkspace(ctx, pre = null) {
|
|
2080
2080
|
if (!ctx || !ctx.handleOf) return null;
|
|
2081
|
-
let conn =
|
|
2082
|
-
try { conn = await connectedProviders(); } catch { conn = null; }
|
|
2081
|
+
let conn = pre;
|
|
2082
|
+
if (!conn) { try { conn = await connectedProviders(); } catch { conn = null; } }
|
|
2083
2083
|
ctx.conn = conn;
|
|
2084
2084
|
let enabled = 0, disabled = 0;
|
|
2085
2085
|
for (const [name, grp] of Object.entries(ctx.groupOf)) {
|
|
@@ -2124,7 +2124,7 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2124
2124
|
const prov = toolProvider(name);
|
|
2125
2125
|
const isKey = Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, prov);
|
|
2126
2126
|
const tpl = typeof ctx?.conn?.connectLink === 'string' && ctx.conn.connectLink.includes('{provider}') ? ctx.conn.connectLink : 'https://app.hermoso.ai/?connect={provider}';
|
|
2127
|
-
return `${name} needs the "${prov}" connection and this workspace has not made it
|
|
2127
|
+
return `${name} needs the "${prov}" connection and this workspace has not made it.${metaAlternativeNote(name)} Connect it under Settings ▸ Connectors in the Hermoso app${isKey ? ', or right here with connect_connector if the user prefers' : `, or hand the user this one-click link: ${tpl.replace('{provider}', prov)} (it opens Hermoso on this brand and goes straight to the sign-in)`}, then call again.`;
|
|
2128
2128
|
}
|
|
2129
2129
|
if (why === 'directory') return `${name} is outside what this Claude directory connection may run. Use the Hermoso app, or connect the unscoped server URL.`;
|
|
2130
2130
|
return null;
|
|
@@ -2140,6 +2140,24 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2140
2140
|
// real gate holds. Everything else falls through to the SDK untouched. Installed on both the build and the replay
|
|
2141
2141
|
// path from ONE function, because a gate answered on one path and not the other is the drift replayTools was
|
|
2142
2142
|
// written to prevent.
|
|
2143
|
+
// A CONNECTION MADE AFTER THIS SESSION WAS BUILT IS INVISIBLE TO ctx.conn (2026-09-14, measured on prod: Meta was
|
|
2144
|
+
// reconnected in the app at 22:41 and call_tool on the already-open claude.ai session still answered "this workspace
|
|
2145
|
+
// has not made it" for send_messenger_marketing_message, while list_connector_accounts — a live read — listed five
|
|
2146
|
+
// ticked accounts on the same brand). The gate is a snapshot; the refusal must not be. Before saying "not connected",
|
|
2147
|
+
// re-read the connection set ONCE and re-gate (the same regateForWorkspace use_brand runs); a failed read fails open
|
|
2148
|
+
// as everywhere else, so the worst case is the old answer, never a wrong refusal of a connector that exists.
|
|
2149
|
+
async function holdReasonRechecked(name, ctx) {
|
|
2150
|
+
let why = holdReasonFor(name, ctx);
|
|
2151
|
+
if (why !== 'not_connected') return why;
|
|
2152
|
+
// The re-read must be a GOOD read before it replaces the snapshot: regateForWorkspace fails OPEN on a failed read
|
|
2153
|
+
// (right for use_brand, wrong here — it would turn "not connected" into "run it and 401"), so a read that did not
|
|
2154
|
+
// succeed keeps the snapshot's answer and the refusal it already earned.
|
|
2155
|
+
let fresh = null;
|
|
2156
|
+
try { fresh = await connectedProviders(); } catch { fresh = null; }
|
|
2157
|
+
if (!fresh || !fresh.readOk) return why;
|
|
2158
|
+
try { await regateForWorkspace(ctx, fresh); } catch { return why; }
|
|
2159
|
+
return holdReasonFor(name, ctx);
|
|
2160
|
+
}
|
|
2143
2161
|
export function installHeldToolCalls(mcp, ctx) {
|
|
2144
2162
|
try {
|
|
2145
2163
|
const low = mcp && mcp.server;
|
|
@@ -2152,7 +2170,7 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2152
2170
|
const h = name && ctx.handleOf[name];
|
|
2153
2171
|
if (!h && LEGACY_TOOL_NAMES[name]) return legacyToolAnswer(name, request, extra, ctx); // a name only an old snapshot still holds
|
|
2154
2172
|
if (h && h.enabled === false) {
|
|
2155
|
-
const why =
|
|
2173
|
+
const why = await holdReasonRechecked(name, ctx);
|
|
2156
2174
|
if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return { content: [{ type: 'text', text: t }], isError: true }; }
|
|
2157
2175
|
// The call itself is the evidence: this host's tool list still names a tool the session holds out on size,
|
|
2158
2176
|
// i.e. the host is serving a stale roster. Run it (that is the point) and record that it happened.
|
|
@@ -2605,6 +2623,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2605
2623
|
remove: ['delete'], trash: ['delete'], cancel: ['delete', 'cancel'], pause: ['status'], unpause: ['status'], resume: ['status'], activate: ['status'], deactivate: ['status'], stop: ['status', 'cancel'], start: ['status'], turn: ['status'],
|
|
2606
2624
|
edit: ['update', 'edit'], change: ['update', 'set'], modify: ['update'], rename: ['update'], adjust: ['update', 'set'],
|
|
2607
2625
|
campaign: ['campaign', 'ads'], adset: ['adset'], advert: ['ad', 'ads'], advertising: ['ads'], advertise: ['ads', 'campaign'], ppc: ['ads', 'google'], sem: ['google', 'ads'], promote: ['ads', 'campaign', 'promoted'],
|
|
2626
|
+
network: ['network', 'networks'], networks: ['networks', 'network'], display: ['networks', 'display'], partners: ['networks'], partner: ['networks'],
|
|
2627
|
+
targetcontentnetwork: ['networks'], targetsearchnetwork: ['networks'], searchpartners: ['networks'], contentnetwork: ['networks'], gdn: ['networks', 'display'],
|
|
2608
2628
|
audience: ['audience', 'targeting'], targeting: ['targeting', 'audience'], retarget: ['audience'], lookalike: ['audience'], demographic: ['targeting', 'insights'],
|
|
2609
2629
|
clip: ['video', 'clip'], reel: ['video', 'instagram'], short: ['video', 'youtube'], film: ['video'], movie: ['video'], footage: ['video'], thumb: ['thumbnail'], cover: ['thumbnail'],
|
|
2610
2630
|
picture: ['image'], photo: ['image', 'product'], pic: ['image'], creative: ['image', 'video', 'ad', 'render'], banner: ['image'], visual: ['image'], graphic: ['image'], packshot: ['product', 'image'],
|
|
@@ -2760,7 +2780,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2760
2780
|
return { content: [{ type: 'text', text: `No tool named "${n}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} find_tools({query}) searches every tool by name or task.` }], isError: true };
|
|
2761
2781
|
}
|
|
2762
2782
|
if (n === 'call_tool' || n === 'find_tools' || n === 'enable_tools') return { content: [{ type: 'text', text: `${n} is a roster tool; call it directly.` }], isError: true };
|
|
2763
|
-
const why =
|
|
2783
|
+
const why = await holdReasonRechecked(n, ctx);
|
|
2764
2784
|
// ONE sentence per hold, from holdReasonText. call_tool used to spell its own copies, so the connect link added there
|
|
2765
2785
|
// never reached claude.ai or ChatGPT, the two hosts that run held tools through here.
|
|
2766
2786
|
if (why) {
|
|
@@ -3100,7 +3120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3100
3120
|
const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
|
|
3101
3121
|
// durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
|
|
3102
3122
|
// prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
|
|
3103
|
-
const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${m.best ? ', best' : ''})`).join('; ');
|
|
3123
|
+
const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${Array.isArray(m.cameraMoves) && m.cameraMoves.length ? `, camera moves ${m.cameraMoves.map(c => c.id).join('/')} (generate_video cameraMove, or your own cameraTrajectory keyframes)` : ''}${m.best ? ', best' : ''})`).join('; ');
|
|
3104
3124
|
// voice engines (generate_voice) + writing models (generate_text) — so the RAW PLAYGROUND is usable from one probe
|
|
3105
3125
|
const voice = d.options?.voice ? (d.options.voice.engines || []).map(e => `${e.id} (${e.label}: ${(e.voices || []).slice(0, 6).join('/')}${(e.voices || []).length > 6 ? '…' : ''}, ${e.creditsPer1k}cr/1k chars)`).join('; ') : 'unavailable';
|
|
3106
3126
|
const llm = d.options?.llm ? (d.options.llm.models || []).map(m => `${m.id} (${m.label})`).join('; ') : 'unavailable';
|
|
@@ -3741,10 +3761,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3741
3761
|
}));
|
|
3742
3762
|
|
|
3743
3763
|
// ── INSTAGRAM LIKE / UNLIKE (2026-09-05): Meta's Like Media and Comments API (changelog 2026-04-22). Facebook-Login
|
|
3744
|
-
// family only; App
|
|
3764
|
+
// family only; instagram_manage_engagement was granted Full Access by App Review 2026-09-06. Scoped by description to the brand's own surface.
|
|
3745
3765
|
server.registerTool('like_instagram', {
|
|
3746
3766
|
title: 'Like or unlike on Instagram as the brand',
|
|
3747
|
-
description: 'Like (or unlike) an Instagram post, Reel, comment or reply AS the brand’s Instagram account — Meta’s Like Media and Comments API (April 2026). The cheapest engagement a brand does: like the good comments on your own posts and the posts you are tagged in. Pass exactly one of mediaId or commentId; undo:true unlikes. Stories and private accounts cannot be liked. Needs the Meta (Facebook Login) connector — a direct Instagram login has no likes edge.
|
|
3767
|
+
description: 'Like (or unlike) an Instagram post, Reel, comment or reply AS the brand’s Instagram account — Meta’s Like Media and Comments API (April 2026). The cheapest engagement a brand does: like the good comments on your own posts and the posts you are tagged in. Pass exactly one of mediaId or commentId; undo:true unlikes. Stories and private accounts cannot be liked. Needs the Meta (Facebook Login) connector — a direct Instagram login has no likes edge. Roughly 200 likes per account per hour. Free.',
|
|
3748
3768
|
inputSchema: {
|
|
3749
3769
|
mediaId: z.string().optional().describe('an IG media id (post or Reel), from list_instagram_media'),
|
|
3750
3770
|
commentId: z.string().optional().describe('an IG comment or reply id, from list_meta_comments'),
|
|
@@ -4997,7 +5017,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4997
5017
|
}, publishWrap(async (a) => {
|
|
4998
5018
|
const d = await apiPost('/api/pinterest/pin', a);
|
|
4999
5019
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was pinned a second time.)`, d);
|
|
5000
|
-
return ok(`Pinned to Pinterest${d.carousel ? ` as a ${d.slides}-slide carousel` : ''}${d.url ? ` — ${d.url}` : '.'}`, d);
|
|
5020
|
+
return ok(`Pinned to Pinterest${d.carousel ? ` as a ${d.slides}-slide carousel` : ''}${d.url ? ` — ${d.url}` : '.'}${d.note ? ` ${d.note}` : ''}`, d);
|
|
5001
5021
|
}));
|
|
5002
5022
|
// ── OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either
|
|
5003
5023
|
// again. Everything here is a WIRING gap, not a scope gap — boards:read/write and pins:read/write are all already
|
|
@@ -6999,7 +7019,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6999
7019
|
}));
|
|
7000
7020
|
server.registerTool('create_messenger_marketing_campaign', {
|
|
7001
7021
|
title: 'Create a Messenger marketing-message campaign',
|
|
7002
|
-
description: 'The container a paid Messenger marketing message is sent from (Meta act_<AD>/message_campaign). Budgets in USD; Meta bills per DELIVERED message against the campaign budget, and creating it sends and charges nothing. dailyBudgetUsd OR lifetimeBudgetUsd (omit both and Meta sets an estimated daily cap; Meta may spend up to 175% of a daily budget on one day, never more than 7× per week). REGION LAW: Meta lets us serve businesses in 20 countries only — US, Mexico, Brazil, India, Australia, Singapore, UAE, Saudi Arabia, Hong Kong, Taiwan, Thailand, Malaysia, Indonesia, Philippines, Vietnam, New Zealand, Chile, Colombia, Peru, Israel — NOT Canada, the EU or the UK — and cannot deliver to people in the EU, UK, Japan, South Korea or Australia. Political Pages are excluded. dryRun:true shows the exact body.',
|
|
7022
|
+
description: 'The container a paid Messenger marketing message is sent from (Meta act_<AD>/message_campaign). Budgets in USD; Meta bills per DELIVERED message against the campaign budget, and creating it sends and charges nothing. A NEW campaign cannot send for about an hour (61 minutes measured 2026-09-14) while Meta populates and prepares it (2300012 then 2300041, each with remaining_seconds), so create it ahead of time or reuse one from list_messenger_marketing_campaigns. dailyBudgetUsd OR lifetimeBudgetUsd, and Meta refuses either under USD 30 (code 1885272, measured 2026-09-14) (omit both and Meta sets an estimated daily cap; Meta may spend up to 175% of a daily budget on one day, never more than 7× per week). REGION LAW: Meta lets us serve businesses in 20 countries only — US, Mexico, Brazil, India, Australia, Singapore, UAE, Saudi Arabia, Hong Kong, Taiwan, Thailand, Malaysia, Indonesia, Philippines, Vietnam, New Zealand, Chile, Colombia, Peru, Israel — NOT Canada, the EU or the UK — and cannot deliver to people in the EU, UK, Japan, South Korea or Australia. Political Pages are excluded. dryRun:true shows the exact body.',
|
|
7003
7023
|
inputSchema: {
|
|
7004
7024
|
adAccountId: z.string().describe('ad account id (act_… or digits)'),
|
|
7005
7025
|
pageId: z.string().optional().describe('the sending Page; defaults to the brand’s one shared Page'),
|
|
@@ -7030,10 +7050,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7030
7050
|
}, wrap(async (a) => { const d = await apiGet('/api/meta/marketing-messages/estimate', a); return ok(d.note, d); }));
|
|
7031
7051
|
server.registerTool('send_messenger_marketing_message', {
|
|
7032
7052
|
title: 'Send a paid Messenger marketing message',
|
|
7033
|
-
description: 'Send a PAID marketing message on Messenger to opted-in subscribers (Meta act_<AD>/messages). One message per subscriber per 12 hours — Meta’s rule, enforced before dispatch and by Meta. message.type: text | button (text + up to 3 web_url buttons) | generic (a card: title, subtitle, image, tap-through url, up to 3 buttons) | media (imageUrl or videoId + buttons). Give subscriptionTokens (≤200 per call, from list_messenger_subscribers) OR customAudienceId of a MESSENGER_SUBSCRIBER_LIST audience of 100+ people for a bulk send. Meta bills the ad account per delivered message; delivery, read and click events arrive on the Page webhook. dryRun:true previews the wire body and sends nothing. Meta’s frequency caps are silent: a refusal saying the person is capped is Meta protecting them, not a broken send.',
|
|
7053
|
+
description: 'Send a PAID marketing message on Messenger to opted-in subscribers (Meta act_<AD>/messages). A campaign created in the last ~hour answers 2300012 then 2300041 (Meta still preparing it) with the seconds left; prefer an existing campaign from list_messenger_marketing_campaigns. One message per subscriber per 12 hours — Meta’s rule, enforced before dispatch and by Meta. message.type: text | button (text + up to 3 web_url buttons) | generic (a card: title, subtitle, image, tap-through url, up to 3 buttons) | media (imageUrl or videoId + buttons). Give subscriptionTokens (≤200 per call, from list_messenger_subscribers) OR customAudienceId of a MESSENGER_SUBSCRIBER_LIST audience of 100+ people for a bulk send. Meta bills the ad account per delivered message; delivery, read and click events arrive on the Page webhook. dryRun:true previews the wire body and sends nothing. Meta’s frequency caps are silent: a refusal saying the person is capped is Meta protecting them, not a broken send.',
|
|
7034
7054
|
inputSchema: {
|
|
7035
7055
|
adAccountId: z.string(),
|
|
7036
|
-
campaignId: z.string().describe('from create_messenger_marketing_campaign'),
|
|
7056
|
+
campaignId: z.string().describe('the message campaign id (from create_messenger_marketing_campaign or list_messenger_marketing_campaigns) — OR the campaign NAME as the user said it: a non-numeric value is resolved against the account\'s own campaigns, so you never need to ask for an id'),
|
|
7057
|
+
campaignName: z.string().optional().describe('the campaign by NAME instead of id (case-insensitive, exactly one match wins; none or several is refused naming the campaigns that exist)'),
|
|
7037
7058
|
subscriptionTokens: z.array(z.string()).optional(),
|
|
7038
7059
|
customAudienceId: z.string().optional(),
|
|
7039
7060
|
message: z.object({
|
|
@@ -7537,7 +7558,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7537
7558
|
}));
|
|
7538
7559
|
server.registerTool('google_ads_report', {
|
|
7539
7560
|
title: 'Google Ads GAQL report',
|
|
7540
|
-
description: 'Run a GAQL (Google Ads Query Language) report for detailed performance breakdowns — ad groups, ads, keywords, search terms, demographics, geo. Pass customerId + a GAQL query (SELECT … FROM <resource> WHERE segments.date DURING LAST_30_DAYS). Allowed FROM resources: campaign, ad_group, ad_group_ad, keyword_view, campaign_budget, age_range_view, gender_view, geographic_view,
|
|
7561
|
+
description: 'Run a GAQL (Google Ads Query Language) report for detailed performance breakdowns — ad groups, ads, keywords, search terms, demographics, geo. Pass customerId + a GAQL query (SELECT … FROM <resource> WHERE segments.date DURING LAST_30_DAYS). Allowed FROM resources: campaign, ad_group, ad_group_ad, keyword_view, campaign_budget, campaign_criterion, ad_group_criterion, conversion_action, campaign_conversion_goal, search_term_view, age_range_view, gender_view, geographic_view, asset, change_event and every other documented GAQL report resource (the refusal names the full list if one is missing). Campaign network settings (Search partners / Display) are readable here as campaign.network_settings.* and changed with set_google_ads_networks. cost_micros is micros — divide by 1,000,000 for the account currency. Read-only, free.',
|
|
7541
7562
|
inputSchema: {
|
|
7542
7563
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
|
|
7543
7564
|
query: z.string().describe('GAQL, e.g. "SELECT ad_group.name, metrics.clicks, metrics.cost_micros FROM ad_group WHERE segments.date DURING LAST_7_DAYS"'),
|
|
@@ -7781,6 +7802,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7781
7802
|
// for — print it rather than re-asserting `a.status`, which would be a claim about the request, not the account.
|
|
7782
7803
|
return ok(d.note || `${d.level || 'campaign'} → ${d.verifiedStatus || a.status}.`, d);
|
|
7783
7804
|
}));
|
|
7805
|
+
server.registerTool('set_google_ads_networks', {
|
|
7806
|
+
title: 'Change where a Google Ads campaign serves (Search partners / Display)',
|
|
7807
|
+
description: 'Change WHERE an existing Google Ads campaign serves: Google Search, Search partners (target_search_network) and the Display Network (target_content_network), each true/false. The classic use is turning Search partners OFF on a Search campaign, or Display off. Current settings are read first and a no-op says so. On a LIVE (ENABLED) campaign this moves real spend on the next auction — show the user the before → after, get an explicit yes, then call again with confirm:true. dryRun:true validates with Google and writes nothing. The result is READ BACK from Google before you are told it took; Google’s own rules (a Display campaign cannot take Google Search on, a Search campaign keeps Google Search on) are relayed by name.',
|
|
7808
|
+
inputSchema: {
|
|
7809
|
+
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
|
|
7810
|
+
campaignId: z.string().describe('the campaign to change'),
|
|
7811
|
+
googleSearch: z.boolean().optional().describe('serve on Google Search (target_google_search)'),
|
|
7812
|
+
searchPartners: z.boolean().optional().describe('serve on Google search partner sites (target_search_network)'),
|
|
7813
|
+
display: z.boolean().optional().describe('serve on the Google Display Network (target_content_network)'),
|
|
7814
|
+
confirm: z.boolean().optional().describe('REQUIRED true to change a LIVE (ENABLED) campaign'),
|
|
7815
|
+
dryRun: z.boolean().optional().describe('validate with Google, write nothing'),
|
|
7816
|
+
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
7817
|
+
},
|
|
7818
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), changed: z.boolean().optional(), networks: z.object({}).passthrough().optional(), note: z.string().optional() },
|
|
7819
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
7820
|
+
}, wrap(async (a) => {
|
|
7821
|
+
const d = await apiPost('/api/google-ads/networks', a);
|
|
7822
|
+
return ok(d.note || `campaign ${a.campaignId} networks updated.`, d);
|
|
7823
|
+
}));
|
|
7784
7824
|
server.registerTool('delete_google_ads_object', {
|
|
7785
7825
|
title: 'Remove a Google Ads campaign / ad group / ad / keyword / asset link / conversion action',
|
|
7786
7826
|
description: 'PERMANENTLY remove a Google Ads object. Google has no HTTP delete — removal is a `remove` operation that puts the object in the terminal REMOVED state, which cannot be undone or re-enabled, so treat it as a delete. Levels: "campaign" + campaignId · "adGroup" + adGroupId · "ad" + adGroupId AND adId · "keyword" + adGroupId AND keywordId · "conversionAction" + conversionActionId · "campaignAsset"/"adGroupAsset" + the LINK’s full resourceName (get it from google_ads_report over campaign_asset / ad_group_asset — an asset id alone does not identify a link). THERE IS DELIBERATELY NO "asset" LEVEL: Google publishes no operation that deletes an Asset, only its links, so removing a link unlinks the asset and leaves it in the library. CALL IT WITHOUT confirm FIRST — nothing is removed and you get the object’s real name, status, LIFETIME SPEND and child counts read live from Google; show the user exactly that. A target with children, live delivery or real spend additionally needs confirmName (its exact name) and confirmChildren (the child count from that read-back). Removing a CAMPAIGN also removes its campaign-owned budget, and the note says whether it did. Removing the last ENABLED conversion action makes every smart-bidding campaign on the account undeliverable — the refusal says so. To stop delivery without removing, use set_google_ads_status(status:"PAUSED").',
|
|
@@ -16519,7 +16559,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16519
16559
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
16520
16560
|
model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick'),
|
|
16521
16561
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render. Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
|
|
16522
|
-
cameraMove: z.enum(['orbit', 'orbit_half', 'orbit_full', 'rise', 'push_in', 'pull_back']).optional().describe('
|
|
16562
|
+
cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage — orbit (quarter turn, the default), orbit_left, orbit_half, orbit_full (turntable), rise, crane_up, push_in, pull_back, reveal. Only the camera-controls model renders one (minimax-h3-max-camera, listed with its moves in hermoso_capabilities): pass it with model omitted and that model is picked, or with that model named; any other named model is refused by name with nothing charged. Needs refImage.'),
|
|
16563
|
+
cameraTrajectory: z.array(z.object({
|
|
16564
|
+
time: z.number().min(0).max(1).describe('when this pose is reached, 0 = start of the clip, 1 = end'),
|
|
16565
|
+
azimuth: z.number().describe('horizontal angle around the subject in degrees (0 = where the still was taken; the sign turns the camera the other way; at most 32 full turns of total travel)'),
|
|
16566
|
+
elevation: z.number().min(-90).max(90).describe('vertical angle in degrees, -90 (below) to 90 (straight above)'),
|
|
16567
|
+
distance: z.number().positive().describe('distance from the subject in scene units, 1 = the distance of the still; smaller is closer'),
|
|
16568
|
+
})).min(2).max(12).optional().describe('Your own ordered camera path, 2 to 12 keyframes, for the camera-controls model only (same rule as cameraMove; overrides it). The first pose is held until its time and the last pose is held to the end. A value outside these bounds is refused by name, nothing charged.'),
|
|
16523
16569
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
16524
16570
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
16525
16571
|
musicMood: z.string().optional().describe('WHICH mood the music bed is composed in (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic). It does NOT decide WHETHER there is one: a clip that comes back with no audio track — every model hermoso_capabilities lists as "silent", plus any audio model that returned mute — gets a bed composed and CHARGED automatically, at the flat per-track fee hermoso_capabilities reports as explainerMusicCredits, and omitting this field only means the mood defaults to "warm". Pass audio:false for a genuinely silent clip with no bed and no bed charge.'),
|
|
@@ -16568,7 +16614,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16568
16614
|
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
16569
16615
|
// an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
|
|
16570
16616
|
// treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
|
|
16571
|
-
const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model, ...(a.cameraMove ? { cameraTrajectory: a.cameraMove } : {}) }, 'MCP video');
|
|
16617
|
+
const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model, ...(a.cameraTrajectory ? { cameraTrajectory: a.cameraTrajectory } : a.cameraMove ? { cameraTrajectory: a.cameraMove } : {}) }, 'MCP video');
|
|
16572
16618
|
return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${switchNote(r)}`, r);
|
|
16573
16619
|
}));
|
|
16574
16620
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.243",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio and marketing MCP server with
|
|
5
|
+
"description": "AI ad studio and marketing MCP server with 838 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|