hermoso 0.1.305 → 0.1.310
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 +8 -7
- package/mcp/http.mjs +102 -11
- package/mcp/tools.mjs +101 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,9 +155,10 @@ the routes.
|
|
|
155
155
|
|
|
156
156
|
## Instant: the hosted Claude.ai connector
|
|
157
157
|
|
|
158
|
-
Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude →
|
|
159
|
-
|
|
160
|
-
handshake is open
|
|
158
|
+
Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
|
|
159
|
+
press Continue, choose **Sign in now** under Authentication (Claude's detector pre-selects "No sign-in" because our
|
|
160
|
+
discovery handshake is open, and with that your first request comes back "Authentication required"), press Add and
|
|
161
|
+
Connect, approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
|
|
161
162
|
|
|
162
163
|
## Quickstart for Claude Code (one command)
|
|
163
164
|
|
|
@@ -181,10 +182,10 @@ claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermos
|
|
|
181
182
|
Rather install the CLI by hand? `npm install -g hermoso` puts the same `hermoso` command on your PATH, and the
|
|
182
183
|
skills use it when it is there.
|
|
183
184
|
|
|
184
|
-
The hosted URL works in Claude Code too, but
|
|
185
|
-
`claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"
|
|
186
|
-
reports `! Needs authentication`
|
|
187
|
-
|
|
185
|
+
The hosted URL works in Claude Code too, but the plugin is the lighter path there because it loads no tool list
|
|
186
|
+
into your sessions. If you want the connector: `claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"`,
|
|
187
|
+
and `claude mcp list` reports `! Needs authentication` until you run `claude mcp login hermoso` once and approve in
|
|
188
|
+
your browser (`--no-browser` prints the link on a headless machine). Measured against Claude Code 2.1.282 on 2026-09-25.
|
|
188
189
|
|
|
189
190
|
Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
|
|
190
191
|
learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
|
package/mcp/http.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
//
|
|
13
13
|
// When the cloud step happens, the remaining work is small and explicit (see ENABLE CHECKLIST at the bottom).
|
|
14
14
|
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
-
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
16
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
18
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
18
19
|
import { registerTools, MCP_INSTRUCTIONS, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
|
|
@@ -20,7 +21,15 @@ import { mcpCtx, connectedProviders } from './client.mjs';
|
|
|
20
21
|
|
|
21
22
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
22
23
|
// `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
|
|
23
|
-
|
|
24
|
+
// The release version directories show as ours (Smithery printed the hard-coded "1.0.0"). Server side this file sits
|
|
25
|
+
// beside cli/, whose package.json carries the release; inside the npm package there is no ../cli/ and ../package.json
|
|
26
|
+
// IS the CLI package. One resolver, so the byte-identical twins agree.
|
|
27
|
+
const PKG_VERSION = (() => { const r = createRequire(import.meta.url); for (const p of ['../cli/package.json', '../package.json']) { try { const v = r(p).version; if (v) return v; } catch {} } return '0.0.0'; })();
|
|
28
|
+
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null, onConnectEvent = null } = {}) {
|
|
29
|
+
// Outcomes of a real connect, for the host's connect watch (lib/mcp-connect-watch.mjs): a bearer we reject, and the
|
|
30
|
+
// first tools/list of a signed-in session (keyed by the token's hash, so a FRESH token's first listing marks a
|
|
31
|
+
// completed connect). Never throws into the request.
|
|
32
|
+
const connectEvent = (req, evt) => { if (typeof onConnectEvent !== 'function') return; try { onConnectEvent({ ua: String(req?.headers?.['user-agent'] || '').slice(0, 160), ...evt }); } catch {} };
|
|
24
33
|
if ((process.env.HERMOSO_MCP_REMOTE ?? process.env.HEIST_MCP_REMOTE) !== '1') return false; // gate 1: off by default
|
|
25
34
|
if (typeof verifyBearer !== 'function') { // gate 2: refuse without real auth
|
|
26
35
|
console.error('[mcp-remote] REFUSING to mount: no token verifier wired. A remote, money-spending MCP must authenticate every caller (no-anon-spend). Wire Firebase Auth → verifyBearer first.');
|
|
@@ -63,6 +72,42 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
63
72
|
app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
|
|
64
73
|
app.get(`/.well-known/oauth-protected-resource${MCP_PATH}`, protectedResourceMetadata);
|
|
65
74
|
|
|
75
|
+
// ── THE STATIC SERVER CARD A DIRECTORY READS INSTEAD OF SCANNING (2026-09-25) ─────────────────────────────────────
|
|
76
|
+
// Smithery's re-scan stopped at "Authentication required": its first probe (UA `SmitheryBot/1.0 (+https://…)`) gets
|
|
77
|
+
// the anonymous preview, but its connect step sends NO user-agent, and a UA-less tokenless handshake is exactly
|
|
78
|
+
// Grok's setup probe, which MUST stay challenged (a 200 there made Grok save us as a no-auth connector). Smithery's
|
|
79
|
+
// own answer for an OAuth server is this document (smithery.ai/docs/build/publish, read 2026-09-25: "you can bypass
|
|
80
|
+
// scanning by serving metadata manually at /.well-known/mcp/server-card.json" — serverInfo, authentication, tools,
|
|
81
|
+
// resources, prompts, SEP-1649 shapes). So the roster a crawler would have listed anonymously is published here,
|
|
82
|
+
// built from the SAME registerTools call the anonymous preview makes and read back through a real MCP client, so
|
|
83
|
+
// it cannot drift from what tools/list serves. Built once per process (the roster is static per process).
|
|
84
|
+
let cardPromise = null;
|
|
85
|
+
const buildServerCard = async () => {
|
|
86
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
87
|
+
const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js');
|
|
88
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
89
|
+
registerTools(server, { only: [...DEFAULT_TOOL_GROUPS], directory: false, widgetHost: false, hosted: true });
|
|
90
|
+
const [a, b] = InMemoryTransport.createLinkedPair();
|
|
91
|
+
const client = new Client({ name: 'server-card', version: '1' });
|
|
92
|
+
await Promise.all([server.connect(a), client.connect(b)]);
|
|
93
|
+
try {
|
|
94
|
+
const list = async (fn, key) => { const out = []; let cursor; do { const r = await fn(cursor ? { cursor } : {}).catch(() => null); if (!r) break; out.push(...(r[key] || [])); cursor = r.nextCursor; } while (cursor); return out; };
|
|
95
|
+
const tools = await list((p) => client.listTools(p), 'tools');
|
|
96
|
+
const resources = await list((p) => client.listResources(p), 'resources');
|
|
97
|
+
const prompts = await list((p) => client.listPrompts(p), 'prompts');
|
|
98
|
+
return { serverInfo: { name: 'hermoso', title: 'Hermoso', version: PKG_VERSION }, instructions: MCP_INSTRUCTIONS,
|
|
99
|
+
authentication: { required: true, schemes: ['oauth2', 'bearer'] },
|
|
100
|
+
transport: { type: 'streamable-http', url: `${BASE}${MCP_PATH}` },
|
|
101
|
+
tools, resources, prompts };
|
|
102
|
+
} finally { try { await client.close(); } catch {} try { await server.close(); } catch {} }
|
|
103
|
+
};
|
|
104
|
+
app.get('/.well-known/mcp/server-card.json', async (req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
cardPromise ||= buildServerCard().catch((e) => { cardPromise = null; throw e; });
|
|
107
|
+
res.set('Cache-Control', 'public, max-age=3600').json(await cardPromise);
|
|
108
|
+
} catch (e) { res.status(503).json({ error: 'server card unavailable, try again', detail: String(e?.message || e).slice(0, 200) }); }
|
|
109
|
+
});
|
|
110
|
+
|
|
66
111
|
// Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
|
|
67
112
|
//
|
|
68
113
|
// ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01, again 2026-08-24) ───────────────────
|
|
@@ -123,7 +168,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
123
168
|
// then the two that need no browser at all. `WWW-Authenticate` still points at the protected-resource metadata,
|
|
124
169
|
// which is what a spec-following client uses; this is for the one that is not following it.
|
|
125
170
|
const challenge = (res) => res.status(401)
|
|
126
|
-
|
|
171
|
+
// `scope` rides the challenge (MCP authorization spec, "Protected Resource Metadata Discovery Requirements":
|
|
172
|
+
// servers SHOULD include it; ChatGPT's own auth doc shows the same shape). The same ONE list the PRM and the AS
|
|
173
|
+
// metadata publish, so a client that scopes its authorize request from the challenge asks for exactly that.
|
|
174
|
+
.set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource", scope="hermoso.research hermoso.generate"`)
|
|
127
175
|
.json({
|
|
128
176
|
error: 'Authentication required',
|
|
129
177
|
error_description: 'This Hermoso MCP server needs a signed-in account. Normally your client opens a browser consent page. IF NO BROWSER OR CONSENT CARD OPENED, your client cannot complete OAuth — retrying will keep failing the same way. Read the `how_to_connect` field of THIS response and use one of those two browser-free routes instead. Tell the user which one you are taking.',
|
|
@@ -233,8 +281,40 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
233
281
|
// chat said "I'll send a connect card" and none ever came. Its catalog connectors (Stripe, Notion, Vercel) work
|
|
234
282
|
// because their servers challenge the first request. So that client, and any caller that asks with
|
|
235
283
|
// `?auth=required`, gets the challenge instead of the anonymous preview; everyone else keeps discovery.
|
|
236
|
-
|
|
284
|
+
//
|
|
285
|
+
// THE SAME DEFECT ON THREE MORE HOSTS, MEASURED BY THE clientInfo EACH PROBE SENDS (2026-09-25). The anonymous
|
|
286
|
+
// initialize is logged by name (below), and a probe from each host's add/connect flow was captured on prod:
|
|
287
|
+
// • claude.ai's "Add custom connector" dialog — clientInfo "Anthropic" 1.0.0, UA python-httpx — pre-selected
|
|
288
|
+
// "No sign-in [Detected]" on our 200, so a user who keeps the default gets a connector whose first tool call
|
|
289
|
+
// fails. Challenged, the same dialog detects "Sign in now" + "Claude's published identity (CIMD)".
|
|
290
|
+
// • Gemini CLI's connect test — clientInfo "mcp-test-client" 0.0.1, UA node — reported "Connected" and never
|
|
291
|
+
// offered sign-in; its session client names itself "gemini-cli-mcp-client".
|
|
292
|
+
// • Windsurf — UA windsurf/* — two anonymous handshakes on 2026-09-21 and never a signed-in session.
|
|
293
|
+
// Matched by the name the client gives itself, never by UA alone: python-httpx and node are also most of the
|
|
294
|
+
// registry crawlers, which keep the anonymous preview.
|
|
295
|
+
const SIGNIN_UPFRONT_CLIENTS = new Set(['Anthropic', 'mcp-test-client', 'gemini-cli-mcp-client']);
|
|
296
|
+
const signinUpfront = (req) => /^(grok-connectors-manager|windsurf)\b/i.test(String(req.headers['user-agent'] || ''))
|
|
297
|
+
|| SIGNIN_UPFRONT_CLIENTS.has(clientInfoOf(req.body))
|
|
237
298
|
|| String(req.query?.auth || '').toLowerCase() === 'required';
|
|
299
|
+
// ── THE DEFAULT IS INVERTED: A TOKENLESS HANDSHAKE IS CHALLENGED UNLESS IT IS A KNOWN LIVENESS/DIRECTORY BOT (2026-09-25) ──
|
|
300
|
+
// Matching the connect probes host by host (Grok, then claude.ai's "Anthropic", Gemini's "mcp-test-client") was
|
|
301
|
+
// whack-a-mole, and Mistral's proved it the same afternoon: its setup dialog sends clientInfo "mcp" 0.1.0 with UA
|
|
302
|
+
// MistralAI-MCPClient/1.0, our 200 made it select "No Authentication", and it then DISABLED its OAuth option. A
|
|
303
|
+
// connector-setup flow reads a 200 to a tokenless initialize as "this server needs no sign-in", which for us is
|
|
304
|
+
// false, and the MCP authorization spec's answer to an unauthenticated request is the 401 + WWW-Authenticate that
|
|
305
|
+
// starts sign-in. So that is the default now. The anonymous preview is kept ONLY for the bots that only look:
|
|
306
|
+
// uptime/liveness monitors and directory/registry crawlers, recognised by the self-describing user-agent every one
|
|
307
|
+
// of them sends (a +https:// contact URL, or bot/crawler/probe/monitor/registry/... in the name; read off 7 days
|
|
308
|
+
// of 200s on /mcp, 2026-09-25). A plain library UA (node, undici, python-httpx, Go-http-client) is NOT a bot by
|
|
309
|
+
// itself: Gemini CLI is "node" and claude.ai's probe is python-httpx. `?auth=none` asks for the preview explicitly.
|
|
310
|
+
// Directories that list tools with a sign-in (Smithery, Glama's inspector, OpenAI's and Anthropic's reviews) use
|
|
311
|
+
// OAuth already. SIGNIN_UPFRONT_CLIENTS still wins over a bot-looking UA.
|
|
312
|
+
const ANON_PREVIEW_UA_RE = /\+https?:\/\/|\b(bot|crawler|spider|probe|scanner|health-?check|uptime|monitor|liveness|registry|collector|audit|checkup|validator|indexer|tripwire|research|catalog-health|signals)\b|mcpbeat|sentineloracle|mcp-watch|mcpwatch|verifymcp|mcp\.market|proofbench|factanker/i;
|
|
313
|
+
const anonPreviewAllowed = (req) => {
|
|
314
|
+
if (String(req.query?.auth || '').toLowerCase() === 'none') return true;
|
|
315
|
+
if (signinUpfront(req)) return false;
|
|
316
|
+
return ANON_PREVIEW_UA_RE.test(String(req.headers['user-agent'] || ''));
|
|
317
|
+
};
|
|
238
318
|
const isAllPreauth = (body) => {
|
|
239
319
|
const arr = Array.isArray(body) ? body : [body];
|
|
240
320
|
const methods = arr.map((m) => m && m.method).filter((v) => typeof v === 'string');
|
|
@@ -267,7 +347,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
267
347
|
const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
|
|
268
348
|
|
|
269
349
|
async function serveAnonDiscovery(req, res, scope) {
|
|
270
|
-
const server = new McpServer({ name: 'hermoso', version:
|
|
350
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
271
351
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
272
352
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
273
353
|
// only the authenticated path would leave both tools listed in the submission.
|
|
@@ -300,7 +380,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
300
380
|
|
|
301
381
|
app.all(MCP_PATH, async (req, res) => {
|
|
302
382
|
const auth = req.headers.authorization || '';
|
|
303
|
-
|
|
383
|
+
// The auth-scheme name is case-insensitive (RFC 9110 §11.1, RFC 6750 §2.1): `bearer <key>` is the same
|
|
384
|
+
// credential, and reading only `Bearer ` made a client that lower-cases it look tokenless, challenged for ever.
|
|
385
|
+
const token = (/^Bearer[ \t]+(\S+)[ \t]*$/i.exec(auth) || [])[1] || '';
|
|
304
386
|
// A HANDSHAKE IS NOT USE. `verifyBearer` stamps the key's last_used_at, and the admin dashboard's "last
|
|
305
387
|
// active" takes the max of that, the billed ledger and the user's last_seen — so an agent that merely holds a
|
|
306
388
|
// connection open (initialize, tools/list, ping, a notification) kept reporting the account as ACTIVE while
|
|
@@ -310,9 +392,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
310
392
|
// unchanged in both branches: this decides bookkeeping, never access.
|
|
311
393
|
const didWork = methodsOf(req.body).includes('tools/call');
|
|
312
394
|
const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => null) : null;
|
|
395
|
+
if (!user && token) connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'bearer token rejected', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
|
|
313
396
|
if (!user) {
|
|
314
397
|
// No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
|
|
315
|
-
if (req.method === 'POST' && isAllPreauth(req.body) &&
|
|
398
|
+
if (req.method === 'POST' && isAllPreauth(req.body) && anonPreviewAllowed(req)) {
|
|
316
399
|
const scope = scopeFor(req, res);
|
|
317
400
|
if (scope === false) return; // unknown group — already answered 400
|
|
318
401
|
return serveAnonDiscovery(req, res, scope).catch(() => { try { challenge(res); } catch {} });
|
|
@@ -377,8 +460,8 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
377
460
|
// OPEN with the full roster, so the whole change would be silently inert. Never throws; see
|
|
378
461
|
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
379
462
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
380
|
-
const server = new McpServer({ name: 'hermoso', version:
|
|
381
|
-
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
463
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
464
|
+
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
382
465
|
const transport = new StreamableHTTPServerTransport({
|
|
383
466
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
384
467
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|
|
@@ -400,7 +483,15 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
400
483
|
// published app cannot be observed from here). It costs one frame after the handshake, it is only sent to
|
|
401
484
|
// widget hosts, and if the host does honour it the stale-snapshot problem heals itself. The real belt is
|
|
402
485
|
// LEGACY_TOOL_NAMES in tools.mjs, which keeps every name a snapshot could hold answering.
|
|
403
|
-
|
|
486
|
+
//
|
|
487
|
+
// ── MEASURED NOT GUILTY OF "No app tools available yet" (2026-09-25) ─────────────────────────────────────────
|
|
488
|
+
// A fresh ChatGPT developer-mode app shows "No app tools available yet" right after Allow. This nudge was the
|
|
489
|
+
// first suspect (it lands on the notification stream ChatGPT opens at connect), so it was switched off and the
|
|
490
|
+
// connect repeated: same panel. What IS true: our tools/list answered 200 with the whole roster inside the
|
|
491
|
+
// connect every time, and a plain page reload — with ZERO further requests from ChatGPT to us — lists every
|
|
492
|
+
// tool. The panel is rendered before ChatGPT's own background sync finishes and never re-reads it. So the
|
|
493
|
+
// nudge is back on, as it had been since 2026-09-14; MCP_LIST_CHANGED_NUDGE=0 turns it off.
|
|
494
|
+
if (process.env.MCP_LIST_CHANGED_NUDGE !== '0' && isWidgetHost(entry.client, req)) { const t = setTimeout(() => { try { server.sendToolListChanged(); } catch {} }, 2500); if (t && typeof t.unref === 'function') t.unref(); }
|
|
404
495
|
// If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
|
|
405
496
|
// objects are otherwise reachable only from this request's still-open response — close them explicitly
|
|
406
497
|
// rather than leaving a session pinned by a dead socket.
|
|
@@ -414,7 +505,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
414
505
|
// Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
|
|
415
506
|
// the SDK answered — a refused tools/call is still a call the roster earned.
|
|
416
507
|
for (const m of methodsOf(req.body)) {
|
|
417
|
-
if (m === 'tools/list') entry.listed = true;
|
|
508
|
+
if (m === 'tools/list') { if (!entry.listed) connectEvent(req, { step: 'tools-list', ok: true, tokenHash: createHash('sha256').update(token).digest('hex'), client: entry.client || '' }); entry.listed = true; }
|
|
418
509
|
else if (m === 'tools/call') {
|
|
419
510
|
entry.calls = (entry.calls || 0) + 1;
|
|
420
511
|
// THE FIRST CALL IS THE MILESTONE, NOT THE LISTING (2026-09-07). The connect_mcp reward was granted on "an API
|
package/mcp/tools.mjs
CHANGED
|
@@ -122,7 +122,7 @@ const timelineReviewText = (rv) => rv ? `\nREVIEW (${rv.verdict || 'unread'}${rv
|
|
|
122
122
|
const seamsText = (d) => {
|
|
123
123
|
const rows = Array.isArray(d?.seams) ? d.seams.filter((x) => x && x.before) : [], b = d?.budget, n = (x) => `${x >= 0 ? '+' : ''}${x}`;
|
|
124
124
|
const dl = (x) => x ? `exposure ${n(x.exposurePct)}%, black ${n(x.black)}, WB u${n(x.wbU)} v${n(x.wbV)}, grain ${n(x.grain)}, sharpness x${x.sharpness}` : 'unread';
|
|
125
|
-
return `${rows.length ? `\nSEAMS MATCHED: ${rows.map((x) => `seam ${x.seam} (${x.at}s) before ${dl(x.before)} -> after ${dl(x.after)}; ${x.applied}`).join(' | ')}` : ''}${b ? `\nBUDGET: ${b.total}s total - ${b.intro}s intro = ${b.survivingWindow.seconds}s of ${b.footage}${b.dropped?.length ? `; not shown: ${b.dropped.map((x) => `${x.from}-${x.to}s (${x.why})`).join(', ')}${b.fixes ? `. To keep it: ${b.fixes.join(' / ')}` : ''}` : ''}` : ''}`;
|
|
125
|
+
return `${rows.length ? `\nSEAMS MATCHED: ${rows.map((x) => `seam ${x.seam} (${x.at}s) before ${dl(x.before)} -> after ${dl(x.after)}; ${x.applied}${x.reframe ? `; ${x.reframe}` : ''}`).join(' | ')}` : ''}${b ? `\nBUDGET: ${b.total}s total - ${b.intro}s intro = ${b.survivingWindow.seconds}s of ${b.footage}${b.dropped?.length ? `; not shown: ${b.dropped.map((x) => `${x.from}-${x.to}s (${x.why})`).join(', ')}${b.fixes ? `. To keep it: ${b.fixes.join(' / ')}` : ''}` : ''}` : ''}`;
|
|
126
126
|
};
|
|
127
127
|
const okVideo = async (text, r) => {
|
|
128
128
|
if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; const t = text + geoLine(r) + qaLine(r); return { content: [{ type: 'text', text: p ? t + '\n(first frame attached — open the URL for the full video)' : t }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
|
|
@@ -299,7 +299,8 @@ export const MCP_INSTRUCTIONS = [
|
|
|
299
299
|
// disagree are worse than either one. What the user-facing line has to carry is only that omitting `model` works.
|
|
300
300
|
// So the honest shape is a TRIGGER, not a gate: call it when you have a question it answers. Keeping the tool
|
|
301
301
|
// discoverable is the other half of the fix, so the reasons to call it are spelled out rather than merely permitted.
|
|
302
|
-
|
|
302
|
+
// REMOVED 2026-09-25: the long restatement of this rule that sat here repeated the head's own 'ACT ON THE REQUEST' line
|
|
303
|
+
// word for word in substance; every host already reads the head copy, and the non-truncating ones paid for it twice.
|
|
303
304
|
'Capability map:',
|
|
304
305
|
'• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
|
|
305
306
|
'• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
@@ -645,9 +646,9 @@ const HOOK_ATTR = {
|
|
|
645
646
|
// is silently attributed to it. Naming the brand per call is the fix, and it is the most specific statement of
|
|
646
647
|
// intent there is, so the server lets it beat the key pin, the workspace header and the active-brand default — for
|
|
647
648
|
// that one request only, which is what makes a one-off post to a second client safe.
|
|
648
|
-
brand: z.string().optional().describe('WHICH BRAND this post belongs to —
|
|
649
|
-
hook: z.string().optional().describe('WHAT ANGLE
|
|
650
|
-
subject: z.string().optional().describe('WHAT THIS POST IS ABOUT —
|
|
649
|
+
brand: z.string().optional().describe('WHICH BRAND this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection\'s pin for THIS CALL ONLY; a name that matches no brand, or two, is REFUSED and nothing is posted.'),
|
|
650
|
+
hook: z.string().optional().describe('WHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.'),
|
|
651
|
+
subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance\'s second grouping axis: reuse the exact wording, as with hook.'),
|
|
651
652
|
// THE FORMAT AND THE IDEA (2026-09-23). The server's publish seam has recorded `recipe` since 2026-09-11, and no
|
|
652
653
|
// agent surface could send it, so every post published over MCP or the CLI said nothing about its format and
|
|
653
654
|
// post_performance's recipe axis returned no groups at all. Same spread, so every publish and schedule tool gains both.
|
|
@@ -674,7 +675,7 @@ const PUBLISH_SAFETY = {
|
|
|
674
675
|
// `?brandId=` on a GET/DELETE (the server belt `brandRefOf` consumes both; `?brand=` is /api/product/find's).
|
|
675
676
|
// tools/brand-per-call-roster-check.mjs derives the tool set by running registerTools and fails on a new one without it.
|
|
676
677
|
const MANAGE_BRAND = {
|
|
677
|
-
brand: z.string().optional().describe('WHICH BRAND the post lives in —
|
|
678
|
+
brand: z.string().optional().describe('WHICH BRAND the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a brand this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no brand, or two, is REFUSED and nothing is done.'),
|
|
678
679
|
};
|
|
679
680
|
const namedBrand = (a) => (a && typeof a.brand === 'string' && a.brand.trim() ? a.brand.trim() : '');
|
|
680
681
|
// POST body: the belt reads a string `brand` and deletes it before the route sees the body.
|
|
@@ -1623,8 +1624,16 @@ function registerAppResources(server) {
|
|
|
1623
1624
|
// /api/workspace, where resolveWs re-authorizes the pin per request, so hosted and stdio now resolve identically.
|
|
1624
1625
|
const pk = async (base) => { const s = await storeSuffix(); return s ? `${base}.${s}` : base; };
|
|
1625
1626
|
async function readStore(base) {
|
|
1627
|
+
// A SIGNED-OUT READ IS NOT AN EMPTY STORE (2026-09-25). /api/store/bootstrap answers an anonymous caller 200 with
|
|
1628
|
+
// nothing in it, so with no key list_library said "The Library is empty for this workspace" — measured over stdio
|
|
1629
|
+
// on prod — and every other store-backed tool (memory, skills, swipefile, playbooks) would answer the same kind of
|
|
1630
|
+
// lie. A process with no credential that reads back NOTHING has no workspace, so it says how to sign in; a 401 on
|
|
1631
|
+
// the read means the same. Decided on what came back, not on the missing token alone, so a stubbed or local store
|
|
1632
|
+
// that does answer is still read.
|
|
1626
1633
|
const key = await pk(base); // deliberately OUTSIDE the try: an unresolvable workspace must fail loudly, not read the wrong one
|
|
1627
|
-
|
|
1634
|
+
const signIn = () => Object.assign(new Error(SIGN_IN_HINT), { status: 401, _signedOut: true });
|
|
1635
|
+
let dump; try { dump = await apiGet('/api/store/bootstrap'); } catch (e) { if (signedOut()) throw signIn(); if (e?.status === 401) throw e; return null; }
|
|
1636
|
+
if (signedOut() && (!dump || typeof dump !== 'object' || !Object.keys(dump).length)) throw signIn();
|
|
1628
1637
|
const raw = dump && dump[key] && dump[key].value;
|
|
1629
1638
|
if (typeof raw !== 'string') return null;
|
|
1630
1639
|
try { return JSON.parse(raw); } catch { return null; }
|
|
@@ -2050,8 +2059,24 @@ export const CORE_FIRST_HEADLINE = Object.freeze([
|
|
|
2050
2059
|
]);
|
|
2051
2060
|
export const CORE_FIRST_EXTRA = Object.freeze(['get_brand', 'get_job', 'list_jobs', 'list_connectors', 'list_scheduled', ...CORE_FIRST_HEADLINE]);
|
|
2052
2061
|
// Hosts that have been SEEN to follow find_tools → call_tool. A name that does not match keeps the full roster.
|
|
2053
|
-
|
|
2054
|
-
|
|
2062
|
+
// WIDENED 2026-09-25 ON EVIDENCE: Gemini CLI (0.60, clientInfo "gemini-cli-mcp-client"), Grok (clientInfo "grok", UA
|
|
2063
|
+
// grok-connectors-manager) and Mistral Le Chat / Vibe (clientInfo "mcp", so it is recognised by its UA MistralAI-MCPClient)
|
|
2064
|
+
// were each asked for something no short list carries ("show my Google Ads campaigns") and each ran find_tools ->
|
|
2065
|
+
// call_tool(list_google_ads_campaigns) to the real campaigns — Gemini CLI on `?tools=core`, stricter than the short list.
|
|
2066
|
+
// Measured on Gemini CLI, the same one-question task sent 473K input tokens on the full roster and 127K on the core list:
|
|
2067
|
+
// these hosts send every listed schema on every model call. Mistral is matched by UA only: "mcp" names nothing.
|
|
2068
|
+
export const CORE_FIRST_VERIFIED_HOST_RE = /claude|chatgpt|openai-mcp|^grok\b|gemini-cli/i;
|
|
2069
|
+
export const CORE_FIRST_VERIFIED_UA_RE = /^(grok-connectors-manager|MistralAI-MCPClient|gemini-cli)/i;
|
|
2070
|
+
export const hostTakesCoreFirst = (client, ua) => CORE_FIRST_VERIFIED_HOST_RE.test(String(client || '')) || CORE_FIRST_VERIFIED_UA_RE.test(String(ua || ''));
|
|
2071
|
+
// HOSTS WITH A HARD TOOL CAP GET THE SHORT LIST TOO (2026-09-25, measured on prod). VS Code sends at most 128 tools per
|
|
2072
|
+
// request (code.visualstudio.com/docs/agents/run/tools) and Windsurf holds 100 across every server (docs.devin.ai
|
|
2073
|
+
// cascade/mcp). The full default roster is ~180, so on both the list was over the cap before the user's other servers
|
|
2074
|
+
// were counted: VS Code blocks the request or folds the overflow behind its own activate_* stubs, and Windsurf makes the
|
|
2075
|
+
// user untick tools by hand. Neither shows the product as it is, so the core-first list (under 50, the headline verb of
|
|
2076
|
+
// every area, find_tools + call_tool for the rest) is the one roster that fits. Matched on the clientInfo name VS Code
|
|
2077
|
+
// sends ("Visual Studio Code", seen on prod) or a Windsurf name/UA. NOT /vscode/: Cursor has called itself "cursor-vscode".
|
|
2078
|
+
export const TOOL_CAPPED_HOST_RE = /^visual studio code\b|windsurf/i;
|
|
2079
|
+
export const hostHasToolCap = (client, ua) => TOOL_CAPPED_HOST_RE.test(String(client || '')) || /windsurf/i.test(String(ua || ''));
|
|
2055
2080
|
export function defaultToolGroups(env = process.env) { return coreFirstRoster(env) ? ['core'] : [...DEFAULT_TOOL_GROUPS]; }
|
|
2056
2081
|
|
|
2057
2082
|
// Parse a `tools=` scope. Returns {groups} or {error} — an unknown name is REFUSED BY NAME rather than dropped,
|
|
@@ -2609,7 +2634,7 @@ function newToolScope(opts) {
|
|
|
2609
2634
|
// whose connector was tested on exactly that route. Cursor, Codex and anything unnamed are unverified, so on the
|
|
2610
2635
|
// HOSTED transport they keep the full default roster and nothing can look smaller than it is. The stdio CLI has no
|
|
2611
2636
|
// host to ask and stays a plain env opt-in. Widen CORE_FIRST_VERIFIED_HOST_RE on evidence, never on a guess.
|
|
2612
|
-
const coreFirst = !opts.only && coreFirstRoster() && (!opts.hosted || hostTakesCoreFirst(opts.client));
|
|
2637
|
+
const coreFirst = !opts.only && coreFirstRoster() && (!opts.hosted || hostTakesCoreFirst(opts.client, opts.ua) || hostHasToolCap(opts.client, opts.ua));
|
|
2613
2638
|
const asked = opts.only ? new Set(opts.only) : new Set(coreFirst ? ['core'] : [...DEFAULT_TOOL_GROUPS]);
|
|
2614
2639
|
asked.add('core'); // discovery/credits/billing/jobs must exist in EVERY roster or the connection is unusable
|
|
2615
2640
|
// `connectors` is `{connected:Set<provider>, readOk:boolean}` from the transport's own free read, or absent.
|
|
@@ -3064,6 +3089,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3064
3089
|
for (const m of String(h.description || '').matchAll(/\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+\b|\b[a-z]+(?:[A-Z][a-z0-9]+)+\b/g)) if (m[0].length >= 6) set.add(sq(m[0]));
|
|
3065
3090
|
_squashIdx.set(h, set); return set;
|
|
3066
3091
|
};
|
|
3092
|
+
// THE MATCH COUNT find_tools REPORTS (2026-09-25). A row is a STRONG match when a query word ITSELF landed in its NAME
|
|
3093
|
+
// (or it is the exact tool name asked for) and it answers as many of the query words as the best-covering such row.
|
|
3094
|
+
// Tiered so a search always counts something: a synonym landing in the name ("tweet" → post_to_x) is the next tier,
|
|
3095
|
+
// and a description-only search is the last. An empty query (browsing a group) counts every row. Rows carry `_cov`
|
|
3096
|
+
// (words answered), `_nd` (direct name hits), `_nh` (name hits incl. synonyms) and `_exact`.
|
|
3097
|
+
const findToolsMatchCount = (rows, hasQuery) => {
|
|
3098
|
+
if (!hasQuery) return rows.length;
|
|
3099
|
+
const exact = rows.filter((r) => r._exact).length;
|
|
3100
|
+
const rest = rows.filter((r) => !r._exact);
|
|
3101
|
+
const direct = rest.filter((r) => r._nd > 0), named = rest.filter((r) => r._nh > 0);
|
|
3102
|
+
const pool = direct.length ? direct : named.length ? named : rest;
|
|
3103
|
+
const best = pool.reduce((m, r) => Math.max(m, r._cov || 0), 0);
|
|
3104
|
+
return exact + pool.filter((r) => (r._cov || 0) >= best).length;
|
|
3105
|
+
};
|
|
3067
3106
|
const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12, onlyHealthy = false } = {}) => {
|
|
3068
3107
|
const q = String(query || '').toLowerCase().trim();
|
|
3069
3108
|
const g = String(group || '').toLowerCase().trim();
|
|
@@ -3081,7 +3120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3081
3120
|
// returned WITH their real group so the caller learns which group to enable instead of giving up.
|
|
3082
3121
|
if (g && grp !== g) { if (!_offGroup.has(name)) _offGroup.set(name, { grp, h }); continue; }
|
|
3083
3122
|
const desc = String(h.description || '');
|
|
3084
|
-
let score = 0;
|
|
3123
|
+
let score = 0, _cov = 0, _nh = 0, _nd = 0; // words answered, NAME hits, and name hits that are the word itself rather than a synonym (the match count below)
|
|
3085
3124
|
if (q) {
|
|
3086
3125
|
// A NAME-SHAPED ASK IS ALSO ITS WORDS (2026-09-12). Agents search the name they guess (list_meta_campaigns,
|
|
3087
3126
|
// update_meta_ad, edit_meta): kept as one literal token it matched nothing and filed a dead end, while its parts
|
|
@@ -3092,24 +3131,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3092
3131
|
if (_fieldHits) score += 4 * _fieldHits;
|
|
3093
3132
|
const descLc = desc.toLowerCase();
|
|
3094
3133
|
const nameTokens = name.split('_');
|
|
3095
|
-
let nameHits = 0, covered = 0;
|
|
3134
|
+
let nameHits = 0, covered = 0, directHits = 0;
|
|
3096
3135
|
for (const { w, alts, literal } of words) {
|
|
3097
|
-
if (literal) { if (name.includes(w)) { score += 5; nameHits++; covered++; } continue; } // "find_tools" typed as-is
|
|
3136
|
+
if (literal) { if (name.includes(w)) { score += 5; nameHits++; directHits++; covered++; } continue; } // "find_tools" typed as-is
|
|
3098
3137
|
const exactTok = nameTokens.find((t) => t === w);
|
|
3099
|
-
if (exactTok) { score += 4 * tokenWeight(exactTok); nameHits++; covered++; continue; } // the word IS a name token
|
|
3138
|
+
if (exactTok) { score += 4 * tokenWeight(exactTok); nameHits++; directHits++; covered++; continue; } // the word IS a name token
|
|
3100
3139
|
// the best-weighted synonym/stem that is a name token — "tweet" must land on post_to_x's `x` (rare), not its `post` (everywhere)
|
|
3101
3140
|
let synBest = 0;
|
|
3102
3141
|
for (const t of nameTokens) for (const a of alts) if (a !== w && (t === a || (a.length >= 4 && t.startsWith(a) && t.length - a.length <= 2))) synBest = Math.max(synBest, 3 * stemAwareWeight(ctx, tokenWeight, t, a));
|
|
3103
3142
|
if (synBest) { score += synBest; nameHits++; covered++; continue; }
|
|
3104
|
-
if (w.length >= 4 && name.includes(w)) { score += 2; nameHits++; covered++; continue; } // the literal word inside a name token
|
|
3143
|
+
if (w.length >= 4 && name.includes(w)) { score += 2; nameHits++; directHits++; covered++; continue; } // the literal word inside a name token
|
|
3105
3144
|
const typoTok = nameTokens.find((t) => withinOneEdit(w, t));
|
|
3106
|
-
if (typoTok) { score += 2 * tokenWeight(typoTok); nameHits++; covered++; continue; } // a typo of a name token
|
|
3145
|
+
if (typoTok) { score += 2 * tokenWeight(typoTok); nameHits++; directHits++; covered++; continue; } // a typo of a name token
|
|
3107
3146
|
if (alts.some((a) => a.length >= 3 && descLc.includes(a))) { score += 1; covered++; continue; } // any form in the description
|
|
3108
3147
|
if (alts.some((a) => grp.includes(a))) { score += 1; covered++; }
|
|
3109
3148
|
}
|
|
3110
3149
|
if (!score) continue;
|
|
3111
3150
|
if (words.length > 1) score += covered; // coverage: a tool that answers MORE of the words outranks one that answers one of them loudly
|
|
3112
3151
|
if (words.length > 1 && nameHits === words.length) score += 2; // every word landed in the NAME: a phrase hit
|
|
3152
|
+
_cov = covered; _nh = nameHits; _nd = directHits;
|
|
3113
3153
|
}
|
|
3114
3154
|
const hold = toolHoldReason(name, ctx);
|
|
3115
3155
|
const health = toolHealth(name);
|
|
@@ -3117,7 +3157,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3117
3157
|
// endpoint in outage by default; we do not, because "Hermoso has no such tool" is the most expensive wrong
|
|
3118
3158
|
// answer this product can give, and a hidden row is indistinguishable from an absent capability.
|
|
3119
3159
|
if (onlyHealthy && (hold || health.state === 'failing')) continue;
|
|
3120
|
-
rows.push({ name, group: grp, score, inRoster: !!h.enabled, callable: !hold, hold, cost: costOf(name, grp, hold), health, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
|
|
3160
|
+
rows.push({ name, group: grp, score, _cov, _nh, _nd, inRoster: !!h.enabled, callable: !hold, hold, cost: costOf(name, grp, hold), health, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
|
|
3121
3161
|
}
|
|
3122
3162
|
// AN EXACT TOOL NAME OUTRANKS THE GROUP FILTER (2026-09-12). The name-shaped split above made a scoped search for a real
|
|
3123
3163
|
// tool in the wrong group find its WORDS in-group (tiktok_creator_info in channels → post_to_tiktok), so `total` was no
|
|
@@ -3127,7 +3167,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3127
3167
|
for (const lit of new Set(q.split(/[\s,]+/).map((r) => r.replace(/[^a-z0-9_]/g, '')).filter((r) => r.includes('_')))) {
|
|
3128
3168
|
const off = _offGroup.get(lit); if (!off) continue;
|
|
3129
3169
|
const hold = toolHoldReason(lit, ctx);
|
|
3130
|
-
rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, inRoster: !!off.h.enabled, callable: !hold, hold, cost: costOf(lit, off.grp, hold), health: toolHealth(lit), title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
|
|
3170
|
+
rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, _exact: true, inRoster: !!off.h.enabled, callable: !hold, hold, cost: costOf(lit, off.grp, hold), health: toolHealth(lit), title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
|
|
3131
3171
|
}
|
|
3132
3172
|
}
|
|
3133
3173
|
// WHAT IS SHOWN IS DECIDED BY RELEVANCE; THE ORDER WITHIN IT IS DECIDED BY HEALTH (2026-09-17). (A plain comment,
|
|
@@ -3141,13 +3181,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3141
3181
|
// what is shown. An exact name hit (score MAX_SAFE_INTEGER) carries no penalty at all — an agent that named a
|
|
3142
3182
|
// tool outright gets it first, with its hold and its health printed beside it.
|
|
3143
3183
|
rows.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name)); // ties: the shorter, more specific name first
|
|
3144
|
-
|
|
3184
|
+
// HOW MANY "MATCH" IS A RELEVANCE COUNT, NEVER THE ROSTER (2026-09-25). Every tool whose description merely
|
|
3185
|
+
// contains one of the words scores, so "google ads report" reported 740 matches, which is the whole catalog and
|
|
3186
|
+
// tells an agent nothing. `total` now counts the STRONG matches (findToolsMatchCount): tools with a query word in
|
|
3187
|
+
// their NAME that answer as many of the words as the best such tool does. The ranking and the rows shown are
|
|
3188
|
+
// unchanged; the looser description-only hits are still ranked below and counted separately as `related`.
|
|
3189
|
+
const total = findToolsMatchCount(rows, !!q), related = rows.length, top = rows.slice(0, cap);
|
|
3145
3190
|
for (const r of top) r._penalty = r.score === Number.MAX_SAFE_INTEGER ? 0 : healthPenalty(r.health, r.hold);
|
|
3146
3191
|
top.sort((a, b) => a._penalty - b._penalty || b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name));
|
|
3147
3192
|
// THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
|
|
3148
3193
|
// not name. Unquoted and lowercased on purpose — the ledger collapses quoted strings to <q>, and one group per
|
|
3149
3194
|
// distinct ask is exactly what we want to read.
|
|
3150
|
-
if (!
|
|
3195
|
+
if (!related && g && _offGroup.size) {
|
|
3151
3196
|
// Re-score the excluded tools by NAME only (the cheap, unambiguous half): an exact or token hit outside the
|
|
3152
3197
|
// asked-for group is an answer, not a dead end — "it exists, in channel_admin; enable that group".
|
|
3153
3198
|
const qw = String(q || '').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
@@ -3155,11 +3200,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3155
3200
|
.map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
3156
3201
|
if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable that group with enable_tools, then call the tool by name):\n${off.join('\n')}`, { query: q, group: g, offGroup: off.length });
|
|
3157
3202
|
}
|
|
3158
|
-
if (!
|
|
3203
|
+
if (!related) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
|
|
3159
3204
|
for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
|
|
3160
3205
|
const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
|
|
3161
|
-
const
|
|
3162
|
-
|
|
3206
|
+
const looser = top.length > total ? top.length - total : 0;
|
|
3207
|
+
const text = related
|
|
3208
|
+
? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing the top ${cap} — narrow the query)` : ''}${looser ? `${total ? '; the other' : ''} ${looser} shown ${looser === 1 ? 'is a looser match' : 'are looser matches'}, ranked below` : ''}. Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first. COST is what the call spends (free means free on every plan); HEALTH is what this server has seen recently — "no recent calls" means we have not seen it run, not that it is broken, and a row marked FAILING or held is ranked last rather than hidden.\n${lines.join('\n')}`
|
|
3163
3209
|
: `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
|
|
3164
3210
|
// THE NEXT STEP, NAMED. The text already ends "Run any of them with call_tool({name, args})"; this is the same
|
|
3165
3211
|
// instruction with the actual name in it, plus the connect step when the best match is the one that is held.
|
|
@@ -3171,7 +3217,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3171
3217
|
: { do: `call_tool({ name: '${best.name}', args: { … } })`, why: `${best.name} is the best match${best.inRoster ? '' : ' and is not in your list, which does not stop it running'}${best.cost?.free ? ' and it is free' : ''}` });
|
|
3172
3218
|
if (best.health?.state === 'failing') hints.push({ do: `consider the next row, or tell the user ${best.name} is currently failing`, why: `${best.failures || best.health.failures} of its last ${best.health.calls} calls on this server failed` });
|
|
3173
3219
|
}
|
|
3174
|
-
return withHints({ content: [{ type: 'text', text }], structuredContent: { total, tools: top.map(({ score, _penalty, ...r }) => r) } }, hints);
|
|
3220
|
+
return withHints({ content: [{ type: 'text', text }], structuredContent: { total, related, tools: top.map(({ score, _penalty, _cov, _nh, _nd, _exact, ...r }) => r) } }, hints);
|
|
3175
3221
|
};
|
|
3176
3222
|
// "DID YOU MEAN" HAS TO DISCRIMINATE, AND THE OLD ONE DID NOT (2026-09-17, off the defect board). Not a `// ──`
|
|
3177
3223
|
// banner on purpose: tools/docs-data.mjs turns every banner into a public docs section, and this note sits
|
|
@@ -4791,7 +4837,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4791
4837
|
videoUrl: z.string().optional().describe('public https URL, data: URI, or /generated path — FB video post / IG Reel'),
|
|
4792
4838
|
productTags: z.array(z.any()).optional().describe('INSTAGRAM SHOPPING — make the post SHOPPABLE by tagging products from the brand’s own catalog; tapping a tag opens the product’s price sheet inside Instagram. Instagram only. ON A PHOTO each tag is {product_id, x, y}, where x and y are FRACTIONS of the image from 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle — and BOTH are required, max 20. ON A REEL it is {product_id} ALONE with no coordinates, max 30. ON A CAROUSEL it is an array PER SLIDE ([[{…}], [], [{…}]]) because Instagram tags each slide’s own container, max 5 per slide and 20 across the post. Ids come from search_instagram_shopping_products; call list_instagram_shopping_catalogs FIRST, because tagging needs an APPROVED Instagram Shop and without one this fails after the media is already uploaded. A tag whose product is not “approved” is stored and shown to nobody.'),
|
|
4793
4839
|
imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
|
|
4794
|
-
idempotencyKey: z.string().optional().describe('SAFE RETRIES
|
|
4840
|
+
idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
|
|
4795
4841
|
allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
|
|
4796
4842
|
async: z.boolean().optional().describe('publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.'),
|
|
4797
4843
|
link: z.string().optional().describe('a URL to attach (FB text post only)'),
|
|
@@ -4860,7 +4906,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4860
4906
|
// no need for one edge case just for Facebook."
|
|
4861
4907
|
server.registerTool('schedule_post', {
|
|
4862
4908
|
title: 'Schedule a post for later',
|
|
4863
|
-
description: 'Queue a post
|
|
4909
|
+
description: 'Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, every one live. google_business is in the schema but HELD BACK (Google’s API allowlist) and is refused at enqueue. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
|
|
4864
4910
|
inputSchema: {
|
|
4865
4911
|
...HOOK_ATTR,
|
|
4866
4912
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
|
|
@@ -5057,7 +5103,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5057
5103
|
title: 'Change a scheduled post',
|
|
5058
5104
|
description: 'Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:"" moves a company-Page post back to the person\u2019s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.',
|
|
5059
5105
|
inputSchema: {
|
|
5060
|
-
brand: z.string().optional().describe('WHICH BRAND this post is in —
|
|
5106
|
+
brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
|
|
5061
5107
|
...POST_INTENT,
|
|
5062
5108
|
id: z.string().describe('the scheduled post id from list_scheduled'),
|
|
5063
5109
|
at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
|
|
@@ -5150,7 +5196,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5150
5196
|
// schedule_post put a post in another brand, and then cancelling it needed use_brand — i.e. changing the whole
|
|
5151
5197
|
// connection to undo one call. The wire spelling is `brandId` because these are GET/DELETE calls and `?brand=`
|
|
5152
5198
|
// already means a brand NAME on /api/product/find.
|
|
5153
|
-
inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled'), brand: z.string().optional().describe('WHICH BRAND this post is in —
|
|
5199
|
+
inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled'), brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.') },
|
|
5154
5200
|
outputSchema: { cancelled: z.string().optional() },
|
|
5155
5201
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
5156
5202
|
}, wrap(async (a) => {
|
|
@@ -5166,7 +5212,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5166
5212
|
title: 'Retry a failed scheduled post',
|
|
5167
5213
|
description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
|
|
5168
5214
|
inputSchema: {
|
|
5169
|
-
brand: z.string().optional().describe('WHICH BRAND this post is in —
|
|
5215
|
+
brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
|
|
5170
5216
|
id: z.string().describe('the scheduled post id from list_scheduled'),
|
|
5171
5217
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
|
|
5172
5218
|
at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
|
|
@@ -5190,7 +5236,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5190
5236
|
title: 'Duplicate a scheduled post',
|
|
5191
5237
|
description: 'Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.',
|
|
5192
5238
|
inputSchema: {
|
|
5193
|
-
brand: z.string().optional().describe('WHICH BRAND this post is in —
|
|
5239
|
+
brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
|
|
5194
5240
|
id: z.string().describe('the post to copy, from list_scheduled'),
|
|
5195
5241
|
...POST_INTENT, // the copy inherits the original's format and idea (and hook and subject); pass either to change it
|
|
5196
5242
|
at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
|
|
@@ -5295,7 +5341,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5295
5341
|
text: z.string().describe('the post text'),
|
|
5296
5342
|
imageUrl: z.string().optional().describe('a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves).'),
|
|
5297
5343
|
imageUrls: z.array(z.string()).optional().describe('A CAROUSEL IS NOT AVAILABLE ON A PERSONAL PROFILE — LinkedIn\'s organic multi-image post publishes from a COMPANY PAGE. Passing several here is refused by name rather than posting slide 1; use post_to_linkedin_page instead.'),
|
|
5298
|
-
idempotencyKey: z.string().optional().describe('SAFE RETRIES
|
|
5344
|
+
idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
|
|
5299
5345
|
allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
|
|
5300
5346
|
visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
|
|
5301
5347
|
},
|
|
@@ -5616,7 +5662,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5616
5662
|
imageUrl: z.string().optional().describe('a Hermoso render image URL (or an upload_file url)'),
|
|
5617
5663
|
videoUrl: z.string().optional().describe('a Hermoso render video URL — takes 1–2 minutes to ingest'),
|
|
5618
5664
|
imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
|
|
5619
|
-
idempotencyKey: z.string().optional().describe('SAFE RETRIES
|
|
5665
|
+
idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
|
|
5620
5666
|
allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
|
|
5621
5667
|
title: z.string().optional().describe('Pin title, max 100 characters'),
|
|
5622
5668
|
description: z.string().optional().describe('Pin description, max 800 characters — this is what Pinterest search reads'),
|
|
@@ -7035,7 +7081,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7035
7081
|
};
|
|
7036
7082
|
server.registerTool('create_meta_ad', {
|
|
7037
7083
|
title: 'Build a full Meta ad (campaign -> ad set -> ad, paused)',
|
|
7038
|
-
description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it
|
|
7084
|
+
description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it carries 24-hour Meta PREVIEW LINKS for the first ad — hand them to the user; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — IG media ids and Facebook post ids are both bare digits, so Hermoso never guesses, and a Facebook boost given an IG media id is refused. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
|
|
7039
7085
|
inputSchema: {
|
|
7040
7086
|
boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs, and creative fields do not apply — a boost shows the post as published.'), boostTarget: z.enum(['facebook','instagram']).optional().describe("Which surface the boosted post lives on. Default facebook. REQUIRED for an Instagram post: an IG media id and a Facebook post id are both bare digits, so this is never inferred — Meta takes a different creative for each (object_story_id for a Page post; object_id + instagram_user_id + source_instagram_media_id for an IG post). list_meta_posts(target:'instagram') returns the ids."),
|
|
7041
7087
|
adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
|
|
@@ -8711,7 +8757,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8711
8757
|
}));
|
|
8712
8758
|
server.registerTool('create_google_ads_performance_max_campaign', {
|
|
8713
8759
|
title: 'Create a Google Ads Performance Max campaign',
|
|
8714
|
-
description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps)
|
|
8760
|
+
description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps). ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
|
|
8715
8761
|
inputSchema: {
|
|
8716
8762
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
|
|
8717
8763
|
name: z.string().describe('campaign name'),
|
|
@@ -12717,7 +12763,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12717
12763
|
inputSchema: {
|
|
12718
12764
|
adAccountId: z.string().optional(),
|
|
12719
12765
|
budget: z.number().describe('budget in the ad account’s currency (not micro-currency — the conversion is handled)'),
|
|
12720
|
-
objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
|
|
12766
|
+
objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
|
|
12721
12767
|
goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional(),
|
|
12722
12768
|
bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional(),
|
|
12723
12769
|
bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional(),
|
|
@@ -12738,7 +12784,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12738
12784
|
inputSchema: {
|
|
12739
12785
|
adAccountId: z.string().optional(),
|
|
12740
12786
|
budget: z.number().describe('budget in the ad account’s currency'),
|
|
12741
|
-
objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
|
|
12787
|
+
objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
|
|
12742
12788
|
bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional().describe('default CPC — must fit the campaign objective'),
|
|
12743
12789
|
bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional(),
|
|
12744
12790
|
goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional(),
|
|
@@ -12810,14 +12856,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12810
12856
|
}));
|
|
12811
12857
|
server.registerTool('create_reddit_ads_campaign', {
|
|
12812
12858
|
title: 'Create a Reddit campaign',
|
|
12813
|
-
description: 'Create the top tier of a Reddit ad — the campaign, which sets the OBJECTIVE everything under it optimises toward and (optionally) a lifetime spend cap. ALWAYS created PAUSED, with no override; it spends nothing until set_reddit_ads_status(confirm:true). Pick the objective deliberately, because the ad group\u2019s bid type has to match it and it cannot be changed afterwards: CLICKS is Reddit\u2019s name for traffic to a website (there is no TRAFFIC), CONVERSIONS optimises toward pixel events and needs a working pixel, LEAD_GENERATION
|
|
12859
|
+
description: 'Create the top tier of a Reddit ad — the campaign, which sets the OBJECTIVE everything under it optimises toward and (optionally) a lifetime spend cap. ALWAYS created PAUSED, with no override; it spends nothing until set_reddit_ads_status(confirm:true). Pick the objective deliberately, because the ad group\u2019s bid type has to match it and it cannot be changed afterwards: CLICKS is Reddit\u2019s name for traffic to a website (there is no TRAFFIC), CONVERSIONS optimises toward pixel events and needs a working pixel, LEAD_GENERATION optimises toward LEAD / SIGN_UP pixel events (Reddit retired onsite lead forms), IMPRESSIONS and VIDEO_VIEWABLE_IMPRESSIONS buy reach, APP_INSTALLS and CATALOG_SALES are for apps and product feeds. Reddit\u2019s NEW names (since 2026-09-21) are accepted too: BRAND_AWARENESS (= IMPRESSIONS / VIDEO_VIEWABLE_IMPRESSIONS) and SALES (= CONVERSIONS, or CATALOG_SALES when useCatalog is true); Reddit rolls them out per account, so an account without them yet answers with Reddit\u2019s own refusal \u2014 use the legacy name then. A campaign on its own can never serve: create an ad group under it, then an ad pointing at a post. The result is read back from Reddit.',
|
|
12814
12860
|
inputSchema: {
|
|
12815
12861
|
adAccountId: z.string().optional().describe('Reddit ad account id (a2_\u2026) \u2014 omit when only one is shared'),
|
|
12816
12862
|
name: z.string(),
|
|
12817
|
-
objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
|
|
12863
|
+
objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
|
|
12818
12864
|
spendCapCents: z.number().optional().describe('lifetime spend ceiling for the whole campaign, in minor units of the ad account\u2019s currency'),
|
|
12865
|
+
useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog'),
|
|
12819
12866
|
},
|
|
12820
|
-
outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), adAccountId: z.string().optional() },
|
|
12867
|
+
outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), useCatalog: z.boolean().optional(), adAccountId: z.string().optional() },
|
|
12821
12868
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12822
12869
|
}, wrap(async (a) => {
|
|
12823
12870
|
const d = await apiPost('/api/reddit-ads/campaigns', a);
|
|
@@ -14812,7 +14859,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14812
14859
|
}));
|
|
14813
14860
|
server.registerTool('create_tiktok_ads_ad', {
|
|
14814
14861
|
title: 'Create a TikTok ad',
|
|
14815
|
-
description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free
|
|
14862
|
+
description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here, for free. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a Warning: warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
|
|
14816
14863
|
inputSchema: {
|
|
14817
14864
|
advertiserId: z.string().optional(),
|
|
14818
14865
|
adgroupId: z.string().describe('the ad group whose targeting, budget and schedule this ad runs under'),
|
|
@@ -15846,7 +15893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15846
15893
|
imageUrl: z.string().optional().describe('a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused.'),
|
|
15847
15894
|
videoUrl: z.string().optional().describe('a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute.'),
|
|
15848
15895
|
imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
|
|
15849
|
-
idempotencyKey: z.string().optional().describe('SAFE RETRIES
|
|
15896
|
+
idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
|
|
15850
15897
|
allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
|
|
15851
15898
|
altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Not available on a PERSONAL-profile post: LinkedIn\u2019s member posting API has no alt-text field at all.'),
|
|
15852
15899
|
title: z.string().optional().describe('video title'),
|
|
@@ -17085,9 +17132,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17085
17132
|
server.group('create');
|
|
17086
17133
|
server.registerTool('make_thumbnail', {
|
|
17087
17134
|
title: 'Make video thumbnail',
|
|
17088
|
-
description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover
|
|
17135
|
+
description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any \"thumbnail\", \"video cover\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks (ids on `framework`; combining two is fine) before you pick; hermoso_capabilities has each one's 'realize it with' note and the emotion, overlay, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. Never infer text intent from the topic.\n3. HOW MANY — ask once: one, or a SET (offer 4: one concept at different emotions / camera takes). Default 1; `variants` caps at 16.\n\n`emotion` is the biggest CTR lever on a face (identity lock is automatic for every face photo). To fix a finished one, re-call with `tweak` + `sourceImage` for a surgical edit (emotion / background / background_color / rim_light) — tweaks chain. ALWAYS check the returned postRenderCheck against the image before presenting it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, every `reference`) in ENGLISH, translating the user's words: the models render English better. `headline`, `headlineLines` and `bakedUiText` stay verbatim in the user's language.",
|
|
17089
17136
|
inputSchema: {
|
|
17090
|
-
framework: z.string().optional().describe("concept framework id (default 'posed_portrait')
|
|
17137
|
+
framework: z.string().optional().describe("concept framework id (default 'posed_portrait') — before_after · social_ui · three_step · screenshot · posed_portrait · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality — or your own concept in words"),
|
|
17091
17138
|
frameworkRequested: z.boolean().optional().describe('true ONLY when the USER named this framework — it is what authorizes a text-carrying framework (social_ui / news_clip / specific_day / map_aerial) to bake its short UI label'),
|
|
17092
17139
|
sceneBrief: z.string().optional().describe('what the thumbnail depicts — the concept in one dense sentence, rendered exactly'),
|
|
17093
17140
|
topic: z.string().optional().describe("the video's topic — used to pick the hero object when you don't name keyElements"),
|
|
@@ -17101,7 +17148,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17101
17148
|
faceImages: z.array(z.string()).optional().describe('up to 3 face photos (URLs or local paths) — each becomes a locked CHARACTER identity, in order'),
|
|
17102
17149
|
people: z.array(z.object({ describe: z.string() }).passthrough()).optional().describe('people described in prose instead of by photo (each still gets the chosen expression)'),
|
|
17103
17150
|
castGenericPerson: z.boolean().optional().describe('pass true only after the user has explicitly chosen a generated stranger over their own face'),
|
|
17104
|
-
emotion: z.string().optional().describe("the expression on the face (default 'shock') —
|
|
17151
|
+
emotion: z.string().optional().describe("the expression on the face (default 'shock') — shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh, or your own phrase"),
|
|
17105
17152
|
emotions: z.array(z.string()).optional().describe('render one variant per emotion (variants = emotions × takes, max 16)'),
|
|
17106
17153
|
takes: z.number().optional().describe('camera takes per emotion, 1–4: designed framing / low-angle hero / extreme close-up / wide dutch tilt'),
|
|
17107
17154
|
variants: z.number().optional().describe('how many thumbnails to render (default 1, max 16). Each is its own billed render — offer a set of 4 rather than assuming'),
|
|
@@ -17269,9 +17316,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17269
17316
|
|
|
17270
17317
|
server.registerTool('make_template_ad', {
|
|
17271
17318
|
title: 'Make template ad',
|
|
17272
|
-
description: "An ad or post rendered from HTML: no AI model, ~30s, a couple of credits. Presets are SHORTCUTS; 'custom' is YOUR OWN design as config.html (+ css), so no layout, type, colour or motion is 'unsupported'. custom: { html, css?, size? ('9:16' default | '4:5' | '1:1' | '16:9' | any 'W:H' | {w,h} px), durationSeconds? (1-60 = VIDEO; CSS/SVG animation and <video> are frame-stepped, scripts stripped), slides?:[{html, css?}] (2-35 = carousel) }; {{logo}} {{brandName}} {{domain}} {{accent}} fill from the brand; images and fonts load by https URL; notes[] lists what failed to load. YOU author preset copy: short, casual, believable, finished phrases within budget.
|
|
17319
|
+
description: "An ad or post rendered from HTML: no AI model, ~30s, a couple of credits. Presets are SHORTCUTS; 'custom' is YOUR OWN design as config.html (+ css), so no layout, type, colour or motion is 'unsupported'. custom: { html, css?, size? ('9:16' default | '4:5' | '1:1' | '16:9' | any 'W:H' | {w,h} px), durationSeconds? (1-60 = VIDEO; CSS/SVG animation and <video> are frame-stepped, scripts stripped), slides?:[{html, css?}] (2-35 = carousel) }; {{logo}} {{brandName}} {{domain}} {{accent}} fill from the brand; images and fonts load by https URL; notes[] lists what failed to load. YOU author preset copy: short, casual, believable, finished phrases within budget. The preset ids — slideshow, imessage-chat, chatgpt-chat, apple-notes, value-prop, static-mockup, airdrop-carousel, app-ui-tour, imessage-cascade, photo-grid, vignette, kinetic-type, myth-vs-fact, carousel — and each one's fields are listed on `config`. config.music on a VIDEO: omit for the format's free library bed, 'off' for silence, or any words (a mood or a description) to compose a bed to them (a flat music fee, in hermoso_capabilities). Image URLs may be any public URL.",
|
|
17273
17320
|
inputSchema: {
|
|
17274
|
-
config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id
|
|
17321
|
+
config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id, plus its fields. PRESETS: 'slideshow' (IMAGES, TikTok photo mode / Reels 1080x1920, or size:'4:5' feed carousels; no branding): { slides:[{text, sub?, image?, blur?, background?, position?}] (2-35; words never rewritten), style? ('tiktok-classic'|'clean-minimal'|'note-style' or a look in words), textStyle?, video?:true (+ an MP4) }; 2 credits, +1 per slide past 5, +2 for the MP4. 'imessage-chat' (VIDEO ~15s): { thread:{contactName, messages:[{from:'them'|'me', text?, product?:{image,title,domain}}]}, theme?, endCard }. 'chatgpt-chat' (VIDEO): { question, answer (may **bold** the brand), productImage?, endCard }. 'apple-notes' (VIDEO): { title, lines[], theme?, endCard }. 'value-prop' (VIDEO ~17s): { hook ≤40ch, claims[3-5 ≤34ch], productImages[2-3], palette[], endCard }. 'static-mockup' (IMAGE): { style:'imessage'|'notes'|'card', size?:{w,h}, ...fields }. 'airdrop-carousel' (VIDEO): { brandName, products:[{image, title?}] (3-16), endCard }. 'app-ui-tour' (VIDEO): { hook?, appName, iconImage?, beats:[{screenImage, caption}] (2-6), endCard }. 'imessage-cascade' (VIDEO): { notifications:[{sender, text}] (4-8), backgroundImage?, endCard }. 'photo-grid' (VIDEO): { title?, photos:[{image, label?}] (4-9), endCard }. 'vignette' (VIDEO): { hook, lines[2-4 ≤40ch], heroImage, endCard }. 'kinetic-type' (VIDEO, own SFX): { phrases[3-6 ≤34ch], productImages?[≤4], endCard }. 'myth-vs-fact' (VIDEO with a real VOICEOVER, small extra charge): { pairs:[{myth ≤50ch, fact ≤60ch}] (2-4; [brackets] accent), endCard }, real truths only. 'carousel' (IMAGES, 5-10 branded 1080x1080): { cover:{hook?, title}, slides:[{headline, support?, stat?:{value, label}}] (3-8), cta:{headline, cta?, domain?}, productImage?, logo? }. endCard = { headline, cta, domain?, logo?, color? }; palette and fontStack optional."),
|
|
17275
17322
|
},
|
|
17276
17323
|
outputSchema: { ...JOB_OUT },
|
|
17277
17324
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -17369,6 +17416,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17369
17416
|
// a constant, or keyframes [{t | src, v, ease}] (shape spelled out on `segments`: one description beats thirteen copies)
|
|
17370
17417
|
const KF = z.union([z.number(), z.array(z.any())]);
|
|
17371
17418
|
// seam matching (lib/seam-match.mjs): 'auto' | 'off' | {grade, level, grain, blur: booleans, strength 0-1}
|
|
17419
|
+
const REFRAME_OPT = z.union([z.enum(['auto', 'off']), z.number()]);
|
|
17372
17420
|
const SEAM_MATCH = z.union([z.enum(['auto', 'off']), z.object({ grade: z.boolean().optional(), level: z.boolean().optional(), grain: z.boolean().optional(), blur: z.boolean().optional(), strength: z.number().optional() })]);
|
|
17373
17421
|
server.registerTool('edit_timeline', {
|
|
17374
17422
|
title: 'Compose an edit (timeline)',
|
|
@@ -17379,7 +17427,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17379
17427
|
+ "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
|
|
17380
17428
|
+ "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
|
|
17381
17429
|
+ "PRO, NOT IMOVIE: ease every curve (never linear on a move); keep the picture filling the frame through a move (scale up while it moves: two frames sliding side by side with a seam is the amateur tell); hide the handoff under the fastest, blurriest frames; carry direction into the next shot; cut on motion; end every effect cleanly; 0.2-0.6 s in total; a sound whose peak lands on the handoff (sfx whoosh at handoff minus 0.45 s, or the hook's own payoff sound). Moving segments get a real shutter blur automatically (motionBlur). "
|
|
17382
|
-
+ "EVERY SEAM IS MATCHED AUTOMATICALLY, hard cuts too: each cut is measured and the incoming clip graded (exposure, white balance, black level), grained UP (never smoothed) and softened while it moves toward the outgoing one; the reply gives before/after deltas per seam. match (timeline: every cut; segment: the cut into it): 'auto' default, 'off' for a deliberate contrast, or {grade, level, grain, blur: false to skip one, strength 0-1}; a segment's own constant exposure / contrast / saturation replaces the automatic grade. Still yours: subject size and headroom (scale it, never a jump from a third of the frame to two thirds) and sound (a 0.25-0.5 s J/L-cut, never a sonic wall). A clip placed after a hook gets a BUDGET (total - intro = its surviving window, and what was dropped). The plainest thing that links wins: a straight cut on action beats a decorative effect; over 0.5 s is too long in anything under 20 s; never flash more than 3 times a second. "
|
|
17430
|
+
+ "EVERY SEAM IS MATCHED AUTOMATICALLY, hard cuts too: each cut is measured and the incoming clip graded (exposure, white balance, black level), grained UP (never smoothed) and softened while it moves toward the outgoing one; the reply gives before/after deltas per seam. match (timeline: every cut; segment: the cut into it): 'auto' default, 'off' for a deliberate contrast, or {grade, level, grain, blur: false to skip one, strength 0-1}; a segment's own constant exposure / contrast / saturation replaces the automatic grade. A JUMP CUT (two moments of one shot at one framing) is punched in ~1.2x on the face automatically (reframe: 'off' or the step 1.1-1.5; a segment with its own scale is left alone). Still yours: subject size and headroom (scale it, never a jump from a third of the frame to two thirds) and sound (a 0.25-0.5 s J/L-cut, never a sonic wall). A clip placed after a hook gets a BUDGET (total - intro = its surviving window, and what was dropped). The plainest thing that links wins: a straight cut on action beats a decorative effect; over 0.5 s is too long in anything under 20 s; never flash more than 3 times a second. "
|
|
17383
17431
|
+ "RECIPES (c = the cut second, adapt freely): whip pan: A over its last 0.22 s x 0 to -0.22, scale 1 to 1.35, mblur 0 to 220, all ease in; B overlap 0.08, opacity 0 to 1 over 0.08, x 0.22 to 0, scale 1.35 to 1, mblur 220 to 0, all ease out over 0.3 s; whoosh at c-0.45. Zoom through: A over its last 0.35 s scale 1 to 3 ease in anchored on the object, blur 0 to 10; B overlap 0.12, opacity 0 to 1, scale 1.5 to 1 and blur 10 to 0 ease out over 0.4 s. Cut on action: A out ON the motion, B scale 1.08 to 1 ease out over 0.25 s, audio.lead 0.2. Speed ramp: speed [{src:t0,v:1},{src:t0+0.25,v:0.3}] then [{src:t1,v:0.3},{src:t1+0.1,v:2}] into the cut. Circle wipe: B overlap 0.5 + overlays [{mode:'mask', segment:1, start, end, html: a white div whose clip-path circle grows via @keyframes}]. Card (picture in picture: a proven ad playing in a rounded card over the host watching it, any length): the host segment full frame, then the clip ON TOP with at:0, fit:'contain' (crop to reframe it), scale ~0.6-0.7, y ~0.12, radius ~0.04-0.06; a card is not a cut, so it is never graded toward the host; duck it under the host's first line with audio.gain keys (the host's voice leads the switch) and end it on a hard cut at a sentence break. "
|
|
17384
17432
|
+ "SELF-CRITIQUE: the reply carries a vision REVIEW of each seam (pro / ok / amateur, linked or not, with fixes; about 2 credits, review:false skips it) and the seam frames. When it says ok or amateur, fix what it names and re-run the same sources (twice at most) before presenting; on a re-run give a generated segment {src: its URL, between: true} so it is not paid for twice. Then look at the WHOLE result once with video_frames, not only the seams: the first frame is not black or frozen, no dead air over ~0.3 s at the head, no lone black, flash or repeated frame at a cut, and nothing static for more than ~4-5 s (recut it or add a re-hook). "
|
|
17385
17433
|
+ "FOLLOW-UPS ('cut earlier', 'no splat', 'whip pan instead', 'use the second hook') re-run this with the SAME sources and the one change; the result echoes the resolved timeline (e.g. the found payoff cut) to edit from. Refusals are free and name the field.",
|
|
@@ -17402,6 +17450,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17402
17450
|
reverse: z.boolean().optional(),
|
|
17403
17451
|
between: z.boolean().optional().describe('a bridge clip between its neighbours (a re-used generated shot): trimmed and graded to them automatically'),
|
|
17404
17452
|
match: SEAM_MATCH.optional().describe('the cut INTO this segment (default: the timeline match)'),
|
|
17453
|
+
reframe: REFRAME_OPT.optional().describe('the cut INTO this segment'),
|
|
17405
17454
|
slowmo: z.enum(['blend', 'hold', 'flow']).optional().describe('how slow motion fills frames (flow = motion-interpolated)'),
|
|
17406
17455
|
scale: KF.optional(), x: KF.optional().describe('canvas widths'), y: KF.optional().describe('canvas heights'), rotate: KF.optional().describe('degrees'),
|
|
17407
17456
|
opacity: KF.optional(), blur: KF.optional(), mblur: KF.optional().describe('directional motion blur px'), mblurAngle: z.number().optional(),
|
|
@@ -17416,11 +17465,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17416
17465
|
motionBlur: z.boolean().optional().describe('default true: anything that moves gets a real shutter blur along its path'),
|
|
17417
17466
|
review: z.boolean().optional().describe('default true: a vision read of each seam (about 2 credits) comes back with the render, with the seam frames'),
|
|
17418
17467
|
match: SEAM_MATCH.optional().describe("every cut: 'auto' default"),
|
|
17468
|
+
reframe: REFRAME_OPT.optional().describe("every cut: 'auto' default"),
|
|
17419
17469
|
},
|
|
17420
17470
|
outputSchema: { ...JOB_OUT },
|
|
17421
17471
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17422
17472
|
}, wrap(async (a) => {
|
|
17423
|
-
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}) };
|
|
17473
|
+
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}), ...(a.reframe != null ? { reframe: a.reframe } : {}) };
|
|
17424
17474
|
const r = await renderJob('postedit', { ...(a.videoUrl ? { videoUrl: a.videoUrl } : {}), ops: [op] }, 'MCP timeline');
|
|
17425
17475
|
const tl = r?.raw?.timeline;
|
|
17426
17476
|
const gen = Array.isArray(r?.raw?.generatedShots) && r.raw.generatedShots.length ? `\nGENERATED SHOT: ${r.raw.generatedShots.map((g) => `${g.model} ${g.seconds}s ${abs(g.video)}`).join('; ')} (billed as its own render)` : '';
|
|
@@ -17489,7 +17539,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17489
17539
|
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
17490
17540
|
server.registerTool('clip_video', {
|
|
17491
17541
|
title: 'Clip a long video',
|
|
17492
|
-
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview,
|
|
17542
|
+
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED), a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source ~15s to ~600MB; only the first ~40 minutes is analysed (truncated:true says so). Cost: a ~7-credit hold settled to the exact transcription + encode cost, plus the clip-selection model's tokens as their own small event. RETURNS clips[] — each its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode. SUBTITLES ARE BURNED IN BY DEFAULT (slim white CAPS, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL — cues follow the transcript's per-sentence timestamps, split by character count; never promise frame-accurate sync. captionsBurned counts the clips that really carry a burned track and captionNote says why any are bare.",
|
|
17493
17543
|
inputSchema: {
|
|
17494
17544
|
video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
|
|
17495
17545
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
@@ -17608,7 +17658,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17608
17658
|
|
|
17609
17659
|
server.registerTool('generate_video', {
|
|
17610
17660
|
title: 'Generate video',
|
|
17611
|
-
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them
|
|
17661
|
+
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
|
|
17612
17662
|
inputSchema: {
|
|
17613
17663
|
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
17614
17664
|
raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence. Billing, durable delivery and per-model validation are unchanged.'),
|
|
@@ -17857,7 +17907,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17857
17907
|
}))).filter(Boolean);
|
|
17858
17908
|
} catch {}
|
|
17859
17909
|
const d = await apiGet('/api/skills').catch(() => ({ skills: [] }));
|
|
17860
|
-
let custom = await readStore('heist.skills.v1'); if (!Array.isArray(custom)) custom = []; // the workspace's OWN skills (built-ins alone came from /api/skills)
|
|
17910
|
+
let custom = await readStore('heist.skills.v1').catch((e) => { if (e?._signedOut) return []; throw e; }); if (!Array.isArray(custom)) custom = []; // the workspace's OWN skills (built-ins alone came from /api/skills); signed out, the built-ins still list
|
|
17861
17911
|
const inApp = (d.skills || []).map(s => `${s.id} (${s.kind || s.group})`).join(', ');
|
|
17862
17912
|
const customLine = custom.map(s => `- ${s.name} (${s.id})`).join('\n');
|
|
17863
17913
|
const text = `Skill bundles (call get_skill with the name):\n${bundles.map(b => `- ${b.name}: ${b.description}`).join('\n') || '(none bundled)'}\n\nIn-app strategy skills + creative recipes (pass as plan_ad's recipe / create's skill): ${inApp}\n\nYour custom skills (save_skill / delete_skill):\n${customLine || '(none yet)'}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.310",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 863 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. 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",
|