hermoso 0.1.241 → 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/bin/hermoso.mjs +29 -7
- package/mcp/http.mjs +33 -0
- package/mcp/roster-scope.mjs +14 -0
- package/mcp/tools.mjs +120 -20
- 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/bin/hermoso.mjs
CHANGED
|
@@ -391,22 +391,36 @@ async function browserLogin(apiBase) {
|
|
|
391
391
|
return await new Promise((resolve) => {
|
|
392
392
|
let done = false;
|
|
393
393
|
const finish = (val) => { if (done) return; done = true; try { server.close(); } catch {} resolve(val); };
|
|
394
|
+
// THE KEY NEVER RIDES A URL (2026-09-14). The app hands it back in the URL FRAGMENT, which a browser keeps out of
|
|
395
|
+
// history, logs and referers; this page reads the fragment and POSTs it to itself over the same loopback origin.
|
|
396
|
+
// An app that predates that still answers with ?key= on the query string, and that path is kept below.
|
|
397
|
+
const settle = (res, key, st) => {
|
|
398
|
+
const ok = st === state && /^hmk_[A-Za-z0-9_-]{20,}$/.test(key);
|
|
399
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
400
|
+
res.end(cliAuthPage(ok ? 'You’re signed in ✓' : 'Sign-in didn’t complete', ok ? 'Hermoso CLI is connected. You can close this tab and return to your terminal.' : 'Please close this tab and run the command again.'));
|
|
401
|
+
finish(ok ? key : null);
|
|
402
|
+
};
|
|
394
403
|
const server = http.createServer((req, res) => {
|
|
395
404
|
try {
|
|
396
405
|
const u = new URL(req.url, 'http://127.0.0.1');
|
|
397
406
|
if (u.pathname === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
407
|
+
if (u.pathname !== '/callback') { res.writeHead(404); return res.end(); }
|
|
408
|
+
if (req.method === 'POST') {
|
|
409
|
+
let body = '';
|
|
410
|
+
req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
|
|
411
|
+
req.on('end', () => { let j = {}; try { j = JSON.parse(body || '{}'); } catch {} settle(res, String(j.key || ''), String(j.state || '')); });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (u.searchParams.has('key') || u.searchParams.has('error')) return settle(res, u.searchParams.get('key') || '', u.searchParams.get('state') || ''); // legacy query form
|
|
415
|
+
// Fragment form: nothing on the query string, the key is in location.hash. Relay it with a same-origin POST.
|
|
416
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
417
|
+
res.end(cliAuthRelayPage());
|
|
404
418
|
} catch { try { res.writeHead(500); res.end('error'); } catch {} finish(null); }
|
|
405
419
|
});
|
|
406
420
|
server.on('error', () => finish(null));
|
|
407
421
|
server.listen(0, '127.0.0.1', () => {
|
|
408
422
|
const port = server.address().port;
|
|
409
|
-
const authUrl = `${apiBase}/?cliauth=1&port=${port}&state=${state}`;
|
|
423
|
+
const authUrl = `${apiBase}/?cliauth=1&recv=2&port=${port}&state=${state}`; // recv=2: hand the key back in the fragment
|
|
410
424
|
console.log(`\nOpening your browser to sign in…\nIf it doesn’t open, paste this into your browser:\n ${authUrl}\n`);
|
|
411
425
|
openBrowser(authUrl);
|
|
412
426
|
});
|
|
@@ -421,6 +435,14 @@ function openBrowser(url) {
|
|
|
421
435
|
try { const c = spawn(cmd, args, { stdio: 'ignore', detached: true }); c.on('error', () => {}); c.unref(); } catch {}
|
|
422
436
|
}).catch(() => {});
|
|
423
437
|
}
|
|
438
|
+
function cliAuthRelayPage() {
|
|
439
|
+
return `<!doctype html><meta charset=utf-8><title>Hermoso CLI</title><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#f6f4ef;color:#1d1e1c;display:grid;place-items:center;min-height:100vh;margin:0"><p id=m>Finishing sign-in…</p><script>
|
|
440
|
+
(function(){var h=new URLSearchParams(location.hash.slice(1));try{history.replaceState(null,'',location.pathname);}catch(e){}
|
|
441
|
+
fetch('/callback',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({key:h.get('key')||'',state:h.get('state')||''})})
|
|
442
|
+
.then(function(r){return r.text()}).then(function(t){document.open();document.write(t);document.close();})
|
|
443
|
+
.catch(function(){document.getElementById('m').textContent='Sign-in did not complete. Return to your terminal and run: hermoso auth login';});})();
|
|
444
|
+
</script>`;
|
|
445
|
+
}
|
|
424
446
|
function cliAuthPage(title, body) {
|
|
425
447
|
return `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Hermoso CLI</title><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#f4f1ea;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0"><div style="text-align:center;max-width:420px;padding:32px"><div style="font-family:Georgia,'Times New Roman',serif;font-size:24px;font-weight:700;letter-spacing:-.5px;margin-bottom:18px">hermoso<span style="color:#d9714e">.ai</span></div><h1 style="font-size:20px;margin:14px 0 8px;font-weight:600">${title}</h1><p style="color:#a7a29a;line-height:1.55;font-size:15px">${body}</p></div></body>`;
|
|
426
448
|
}
|
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
|
|
|
@@ -322,6 +348,12 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
322
348
|
// the predicate can be corrected from evidence instead of from a hunch.
|
|
323
349
|
if (entry.client) console.error(`[mcp-remote] client: ${entry.client}`);
|
|
324
350
|
await server.connect(transport);
|
|
351
|
+
// A ONE-SHOT NUDGE FOR A HOST THAT SNAPSHOTS THE ROSTER (2026-09-14). ChatGPT serves its users the tool list
|
|
352
|
+
// OpenAI scanned at review time; whether it honours a tools/list_changed notification is UNVERIFIED (the
|
|
353
|
+
// published app cannot be observed from here). It costs one frame after the handshake, it is only sent to
|
|
354
|
+
// widget hosts, and if the host does honour it the stale-snapshot problem heals itself. The real belt is
|
|
355
|
+
// LEGACY_TOOL_NAMES in tools.mjs, which keeps every name a snapshot could hold answering.
|
|
356
|
+
if (isWidgetHost(entry.client, req)) { const t = setTimeout(() => { try { server.sendToolListChanged(); } catch {} }, 2500); if (t && typeof t.unref === 'function') t.unref(); }
|
|
325
357
|
// If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
|
|
326
358
|
// objects are otherwise reachable only from this request's still-open response — close them explicitly
|
|
327
359
|
// rather than leaving a session pinned by a dead socket.
|
|
@@ -358,6 +390,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
358
390
|
});
|
|
359
391
|
|
|
360
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
|
|
361
394
|
return true;
|
|
362
395
|
}
|
|
363
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
|
|
@@ -1870,6 +1870,57 @@ export function parseToolScope(raw) {
|
|
|
1870
1870
|
// a HOST rule, not a capability we removed: every other surface still offers both.
|
|
1871
1871
|
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload']);
|
|
1872
1872
|
|
|
1873
|
+
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
1874
|
+
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
1875
|
+
// chatgpt-caches-the-connector). Their snapshot from August still names eight tools that no longer exist: seven
|
|
1876
|
+
// Reddit posting tools we withdrew (Reddit's Data API never approved us) and scrapecreators_fetch, renamed. A call
|
|
1877
|
+
// to any of them answered the SDK's bare "unknown tool", which the agent reads as "Hermoso is broken". So every name
|
|
1878
|
+
// that was ever published is kept here for as long as a host could hold it: a rename FORWARDS to its successor with
|
|
1879
|
+
// the same arguments; a withdrawn tool answers the same sentence the live roster would give for it. Neither is ever
|
|
1880
|
+
// listed — this table is reached only through tools/call, from a host whose list is stale, which is exactly the
|
|
1881
|
+
// evidence installHeldToolCalls records as `stale_roster`.
|
|
1882
|
+
//
|
|
1883
|
+
// The eight names are the diff of registerTool() calls between 2ba8e3a63 (2026-08-23, the last commit before the
|
|
1884
|
+
// August scan's descendants) and HEAD — tools/chatgpt-legacy-names-check.mjs re-derives the "gone from HEAD" half.
|
|
1885
|
+
export const LEGACY_TOOL_NAMES = Object.freeze({
|
|
1886
|
+
scrapecreators_fetch: { to: 'fetch_social_data' },
|
|
1887
|
+
post_to_reddit: { gone: 'reddit' }, edit_reddit_post: { gone: 'reddit' }, delete_reddit_post: { gone: 'reddit' },
|
|
1888
|
+
list_reddit_posts: { gone: 'reddit' }, list_reddit_comments: { gone: 'reddit' }, reply_to_reddit_comment: { gone: 'reddit' },
|
|
1889
|
+
reddit_post_stats: { gone: 'reddit' },
|
|
1890
|
+
});
|
|
1891
|
+
// Agent-facing, never user-facing: a host on an old snapshot cannot be refreshed from our side (only the host's own
|
|
1892
|
+
// publish does that), so the one useful thing to say is "carry on, nothing to tell the user".
|
|
1893
|
+
export const STALE_ROSTER_NOTE = 'Note for the assistant, not for the user: your Hermoso tool list is an older published snapshot. Hermoso has added tools since, including find_tools and call_tool, which reach every capability by name. Keep working with the tools you have and do not ask the user to reconnect; the list refreshes on its own when this host publishes the newer version.';
|
|
1894
|
+
export const withStaleNote = (out, ctx) => {
|
|
1895
|
+
if (!ctx?.widgetHost || !out || !Array.isArray(out.content)) return out;
|
|
1896
|
+
const last = [...out.content].reverse().find((c) => c && c.type === 'text' && typeof c.text === 'string');
|
|
1897
|
+
if (last) last.text = `${last.text}\n\n${STALE_ROSTER_NOTE}`; else out.content.push({ type: 'text', text: STALE_ROSTER_NOTE });
|
|
1898
|
+
return out;
|
|
1899
|
+
};
|
|
1900
|
+
export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
1901
|
+
const spec = LEGACY_TOOL_NAMES[name];
|
|
1902
|
+
reportDeadEnd('stale_roster', name, `${name} no longer exists — the host's tool list is an older published snapshot`);
|
|
1903
|
+
if (spec.gone) {
|
|
1904
|
+
const text = spec.gone === 'reddit'
|
|
1905
|
+
? `${name} is no longer offered: Hermoso does not publish to Reddit (Reddit's Data API has not approved it). Reddit ADS are available through the reddit_ads tools; organic Reddit posting is not. There is nothing the user can connect, so do not point them at Settings ▸ Connectors.`
|
|
1906
|
+
: `${name} is no longer offered by Hermoso.`;
|
|
1907
|
+
return withStaleNote({ content: [{ type: 'text', text }], isError: false }, ctx);
|
|
1908
|
+
}
|
|
1909
|
+
const h = ctx.handleOf[spec.to];
|
|
1910
|
+
const fn = h && (h.handler || h.callback);
|
|
1911
|
+
if (typeof fn !== 'function') return withStaleNote({ content: [{ type: 'text', text: `${name} was renamed to ${spec.to}, which is not part of this session's roster.` }], isError: true }, ctx);
|
|
1912
|
+
let input = request?.params?.arguments && typeof request.params.arguments === 'object' ? request.params.arguments : {};
|
|
1913
|
+
if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
|
|
1914
|
+
const parsed = h.inputSchema.safeParse(input);
|
|
1915
|
+
if (!parsed.success) {
|
|
1916
|
+
const issues = (parsed.error?.issues || []).slice(0, 8).map((i) => `${(i.path || []).join('.') || '(root)'}: ${i.message}`).join('; ');
|
|
1917
|
+
return withStaleNote({ content: [{ type: 'text', text: `Arguments for ${name} (now ${spec.to}) did not validate — ${issues}.` }], isError: true }, ctx);
|
|
1918
|
+
}
|
|
1919
|
+
input = parsed.data;
|
|
1920
|
+
}
|
|
1921
|
+
return withStaleNote(h.inputSchema ? await fn(input, extra) : await fn(extra), ctx);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1873
1924
|
// ── THE DIRECTORY ROSTER (2026-09-02) — `?tools=directory` ─────────────────────────────────────────────────────
|
|
1874
1925
|
// Anthropic's Software Directory Policy prohibits "software that uses AI models to generate images, video, or
|
|
1875
1926
|
// audio content" (design aids excepted) and software that "executes financial transactions on behalf of users".
|
|
@@ -2025,10 +2076,10 @@ let TOOL_CANON = null; // [{ name, group, def, handler, factory }] — the one c
|
|
|
2025
2076
|
// readOk:false, which toolHeldBackByConnectors treats as "hold back nothing" ([[failed-read-is-not-empty]]).
|
|
2026
2077
|
// It also DISABLES, not just enables — moving to a workspace with fewer connectors must narrow the roster too,
|
|
2027
2078
|
// or a tool that can only answer 401 stays listed.
|
|
2028
|
-
async function regateForWorkspace(ctx) {
|
|
2079
|
+
async function regateForWorkspace(ctx, pre = null) {
|
|
2029
2080
|
if (!ctx || !ctx.handleOf) return null;
|
|
2030
|
-
let conn =
|
|
2031
|
-
try { conn = await connectedProviders(); } catch { conn = null; }
|
|
2081
|
+
let conn = pre;
|
|
2082
|
+
if (!conn) { try { conn = await connectedProviders(); } catch { conn = null; } }
|
|
2032
2083
|
ctx.conn = conn;
|
|
2033
2084
|
let enabled = 0, disabled = 0;
|
|
2034
2085
|
for (const [name, grp] of Object.entries(ctx.groupOf)) {
|
|
@@ -2073,7 +2124,7 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2073
2124
|
const prov = toolProvider(name);
|
|
2074
2125
|
const isKey = Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, prov);
|
|
2075
2126
|
const tpl = typeof ctx?.conn?.connectLink === 'string' && ctx.conn.connectLink.includes('{provider}') ? ctx.conn.connectLink : 'https://app.hermoso.ai/?connect={provider}';
|
|
2076
|
-
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.`;
|
|
2077
2128
|
}
|
|
2078
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.`;
|
|
2079
2130
|
return null;
|
|
@@ -2089,6 +2140,24 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2089
2140
|
// real gate holds. Everything else falls through to the SDK untouched. Installed on both the build and the replay
|
|
2090
2141
|
// path from ONE function, because a gate answered on one path and not the other is the drift replayTools was
|
|
2091
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
|
+
}
|
|
2092
2161
|
export function installHeldToolCalls(mcp, ctx) {
|
|
2093
2162
|
try {
|
|
2094
2163
|
const low = mcp && mcp.server;
|
|
@@ -2099,8 +2168,9 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2099
2168
|
const wrapped = async (request, extra) => {
|
|
2100
2169
|
const name = String(request?.params?.name || '');
|
|
2101
2170
|
const h = name && ctx.handleOf[name];
|
|
2171
|
+
if (!h && LEGACY_TOOL_NAMES[name]) return legacyToolAnswer(name, request, extra, ctx); // a name only an old snapshot still holds
|
|
2102
2172
|
if (h && h.enabled === false) {
|
|
2103
|
-
const why =
|
|
2173
|
+
const why = await holdReasonRechecked(name, ctx);
|
|
2104
2174
|
if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return { content: [{ type: 'text', text: t }], isError: true }; }
|
|
2105
2175
|
// The call itself is the evidence: this host's tool list still names a tool the session holds out on size,
|
|
2106
2176
|
// i.e. the host is serving a stale roster. Run it (that is the point) and record that it happened.
|
|
@@ -2116,7 +2186,7 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2116
2186
|
}
|
|
2117
2187
|
input = parsed.data;
|
|
2118
2188
|
}
|
|
2119
|
-
return h.inputSchema ? await fn(input, extra) : await fn(extra);
|
|
2189
|
+
return withStaleNote(h.inputSchema ? await fn(input, extra) : await fn(extra), ctx); // the call proves the roster is stale; a widget host's agent is told so, its user is not
|
|
2120
2190
|
}
|
|
2121
2191
|
}
|
|
2122
2192
|
return orig(request, extra);
|
|
@@ -2553,6 +2623,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2553
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'],
|
|
2554
2624
|
edit: ['update', 'edit'], change: ['update', 'set'], modify: ['update'], rename: ['update'], adjust: ['update', 'set'],
|
|
2555
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'],
|
|
2556
2628
|
audience: ['audience', 'targeting'], targeting: ['targeting', 'audience'], retarget: ['audience'], lookalike: ['audience'], demographic: ['targeting', 'insights'],
|
|
2557
2629
|
clip: ['video', 'clip'], reel: ['video', 'instagram'], short: ['video', 'youtube'], film: ['video'], movie: ['video'], footage: ['video'], thumb: ['thumbnail'], cover: ['thumbnail'],
|
|
2558
2630
|
picture: ['image'], photo: ['image', 'product'], pic: ['image'], creative: ['image', 'video', 'ad', 'render'], banner: ['image'], visual: ['image'], graphic: ['image'], packshot: ['product', 'image'],
|
|
@@ -2708,7 +2780,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2708
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 };
|
|
2709
2781
|
}
|
|
2710
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 };
|
|
2711
|
-
const why =
|
|
2783
|
+
const why = await holdReasonRechecked(n, ctx);
|
|
2712
2784
|
// ONE sentence per hold, from holdReasonText. call_tool used to spell its own copies, so the connect link added there
|
|
2713
2785
|
// never reached claude.ai or ChatGPT, the two hosts that run held tools through here.
|
|
2714
2786
|
if (why) {
|
|
@@ -2791,7 +2863,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2791
2863
|
title: 'Delete a post from the connected Bluesky account',
|
|
2792
2864
|
description: "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings ▸ Connectors ▸ Bluesky, or connect_connector).",
|
|
2793
2865
|
inputSchema: {
|
|
2794
|
-
uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, or just its record key (3mtc4n3fibn2x)"),
|
|
2866
|
+
uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x)"),
|
|
2795
2867
|
confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent and cannot be undone'),
|
|
2796
2868
|
confirmText: z.string().optional().describe("the post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead."),
|
|
2797
2869
|
},
|
|
@@ -3048,7 +3120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3048
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('; ');
|
|
3049
3121
|
// durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
|
|
3050
3122
|
// prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
|
|
3051
|
-
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('; ');
|
|
3052
3124
|
// voice engines (generate_voice) + writing models (generate_text) — so the RAW PLAYGROUND is usable from one probe
|
|
3053
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';
|
|
3054
3126
|
const llm = d.options?.llm ? (d.options.llm.models || []).map(m => `${m.id} (${m.label})`).join('; ') : 'unavailable';
|
|
@@ -3689,10 +3761,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3689
3761
|
}));
|
|
3690
3762
|
|
|
3691
3763
|
// ── INSTAGRAM LIKE / UNLIKE (2026-09-05): Meta's Like Media and Comments API (changelog 2026-04-22). Facebook-Login
|
|
3692
|
-
// 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.
|
|
3693
3765
|
server.registerTool('like_instagram', {
|
|
3694
3766
|
title: 'Like or unlike on Instagram as the brand',
|
|
3695
|
-
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.',
|
|
3696
3768
|
inputSchema: {
|
|
3697
3769
|
mediaId: z.string().optional().describe('an IG media id (post or Reel), from list_instagram_media'),
|
|
3698
3770
|
commentId: z.string().optional().describe('an IG comment or reply id, from list_meta_comments'),
|
|
@@ -4945,7 +5017,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4945
5017
|
}, publishWrap(async (a) => {
|
|
4946
5018
|
const d = await apiPost('/api/pinterest/pin', a);
|
|
4947
5019
|
if (d?.idempotentReplay) return ok(`${d.note} (Nothing was pinned a second time.)`, d);
|
|
4948
|
-
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);
|
|
4949
5021
|
}));
|
|
4950
5022
|
// ── OPERATING A PIN AND A BOARD AFTER THEY EXIST (2026-08-05). We could create both and then never touch either
|
|
4951
5023
|
// again. Everything here is a WIRING gap, not a scope gap — boards:read/write and pins:read/write are all already
|
|
@@ -6947,7 +7019,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6947
7019
|
}));
|
|
6948
7020
|
server.registerTool('create_messenger_marketing_campaign', {
|
|
6949
7021
|
title: 'Create a Messenger marketing-message campaign',
|
|
6950
|
-
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.',
|
|
6951
7023
|
inputSchema: {
|
|
6952
7024
|
adAccountId: z.string().describe('ad account id (act_… or digits)'),
|
|
6953
7025
|
pageId: z.string().optional().describe('the sending Page; defaults to the brand’s one shared Page'),
|
|
@@ -6978,10 +7050,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6978
7050
|
}, wrap(async (a) => { const d = await apiGet('/api/meta/marketing-messages/estimate', a); return ok(d.note, d); }));
|
|
6979
7051
|
server.registerTool('send_messenger_marketing_message', {
|
|
6980
7052
|
title: 'Send a paid Messenger marketing message',
|
|
6981
|
-
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.',
|
|
6982
7054
|
inputSchema: {
|
|
6983
7055
|
adAccountId: z.string(),
|
|
6984
|
-
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)'),
|
|
6985
7058
|
subscriptionTokens: z.array(z.string()).optional(),
|
|
6986
7059
|
customAudienceId: z.string().optional(),
|
|
6987
7060
|
message: z.object({
|
|
@@ -7485,7 +7558,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7485
7558
|
}));
|
|
7486
7559
|
server.registerTool('google_ads_report', {
|
|
7487
7560
|
title: 'Google Ads GAQL report',
|
|
7488
|
-
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.',
|
|
7489
7562
|
inputSchema: {
|
|
7490
7563
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
|
|
7491
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"'),
|
|
@@ -7729,6 +7802,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7729
7802
|
// for — print it rather than re-asserting `a.status`, which would be a claim about the request, not the account.
|
|
7730
7803
|
return ok(d.note || `${d.level || 'campaign'} → ${d.verifiedStatus || a.status}.`, d);
|
|
7731
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
|
+
}));
|
|
7732
7824
|
server.registerTool('delete_google_ads_object', {
|
|
7733
7825
|
title: 'Remove a Google Ads campaign / ad group / ad / keyword / asset link / conversion action',
|
|
7734
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").',
|
|
@@ -16467,7 +16559,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16467
16559
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
16468
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'),
|
|
16469
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."),
|
|
16470
|
-
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.'),
|
|
16471
16569
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
16472
16570
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
16473
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.'),
|
|
@@ -16516,7 +16614,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16516
16614
|
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
16517
16615
|
// an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
|
|
16518
16616
|
// treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
|
|
16519
|
-
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');
|
|
16520
16618
|
return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${switchNote(r)}`, r);
|
|
16521
16619
|
}));
|
|
16522
16620
|
|
|
@@ -16865,6 +16963,8 @@ function memoryNoteVerdict(text) {
|
|
|
16865
16963
|
items: z.array(z.object({
|
|
16866
16964
|
key: z.string().optional().describe('a stable id for this ad if you have one (an ad_archive_id, creativeId, …). Omit and one is derived from the link/media so re-saving is idempotent'),
|
|
16867
16965
|
advertiser: z.string().optional().describe('the brand running the ad'),
|
|
16966
|
+
page_name: z.string().optional().describe('alias of advertiser: the field search_meta_ads returns, accepted as-is'),
|
|
16967
|
+
pageName: z.string().optional().describe('alias of advertiser'),
|
|
16868
16968
|
title: z.string().optional().describe('headline / hook'),
|
|
16869
16969
|
body: z.string().optional().describe('the ad copy'),
|
|
16870
16970
|
image: z.string().optional().describe('image URL'),
|
|
@@ -16889,7 +16989,7 @@ function memoryNoteVerdict(text) {
|
|
|
16889
16989
|
const key = swipeKeyOf(it);
|
|
16890
16990
|
const ex = s.ads.find(x => x && x.key === key);
|
|
16891
16991
|
if (ex) { if (ex.collectionId !== col.id) moved++; ex.collectionId = col.id; continue; }
|
|
16892
|
-
s.ads.push({ key, collectionId: col.id, advertiser: String(it.advertiser || '').slice(0, 120)
|
|
16992
|
+
s.ads.push({ key, collectionId: col.id, advertiser: String(it.advertiser || it.page_name || it.pageName || '').slice(0, 120) /* search_meta_ads says page_name (2026-09-14) */, title: String(it.title || '').slice(0, 300), body: String(it.body || '').slice(0, 2000), image: String(it.image || ''), video: String(it.video || ''), link: String(it.link || it.url || ''), platform: String(it.platform || '').slice(0, 40), savedAt: Date.now() , ...(it.platform === 'creator' && it.creator && typeof it.creator === 'object' ? { creator: it.creator } : {}) });
|
|
16893
16993
|
saved++;
|
|
16894
16994
|
}
|
|
16895
16995
|
s.activeId = col.id;
|
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"
|