hermoso 0.1.363 → 0.1.365
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/http.mjs +9 -4
- package/mcp/tool-hints.mjs +27 -5
- package/mcp/tools.mjs +173 -51
- package/package.json +1 -1
package/mcp/http.mjs
CHANGED
|
@@ -384,7 +384,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
384
384
|
const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
|
|
385
385
|
|
|
386
386
|
async function serveAnonDiscovery(req, res, scope) {
|
|
387
|
-
|
|
387
|
+
// A widget host (ChatGPT, whose plugin scanner reads this anonymous roster) gets its own instructions — see
|
|
388
|
+
// MCP_INSTRUCTIONS_WIDGET. The SAME host decision as the roster below, so the two can never disagree.
|
|
389
|
+
const widgetHost = isWidgetHost(clientInfoOf(req.body), req);
|
|
390
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
|
|
388
391
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
389
392
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
390
393
|
// only the authenticated path would leave both tools listed in the submission.
|
|
@@ -399,7 +402,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
399
402
|
// every one of those the roster IS the product description. Serving them the core set would publish Hermoso as
|
|
400
403
|
// a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
|
|
401
404
|
// default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
|
|
402
|
-
registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost
|
|
405
|
+
registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost, hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
403
406
|
// WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
|
|
404
407
|
// not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
|
|
405
408
|
// tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
|
|
@@ -511,8 +514,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
511
514
|
// OPEN with the full roster, so the whole change would be silently inert. Never throws; see
|
|
512
515
|
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
513
516
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
514
|
-
|
|
515
|
-
|
|
517
|
+
// ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
|
|
518
|
+
const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
|
|
519
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
|
|
520
|
+
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost, 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
|
|
516
521
|
const transport = new StreamableHTTPServerTransport({
|
|
517
522
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
518
523
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -77,24 +77,46 @@ export function videoChoiceDraftCall(tool, d) {
|
|
|
77
77
|
if (t === 'plan_ad' || t === 'clone_video') return `${t}({…the same arguments, format: 'video', draft: {model: '${m}', durationSeconds: ${s}}})`;
|
|
78
78
|
return `${t || 'the same call'}({…the same arguments, model: '${m}', durationSeconds: ${s}})`;
|
|
79
79
|
}
|
|
80
|
-
export function videoChoiceHints(tool, choice) {
|
|
80
|
+
export function videoChoiceHints(tool, choice, opts = {}) {
|
|
81
81
|
const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
|
|
82
82
|
const out = [
|
|
83
83
|
{ do: videoChoiceImageCall(tool), why: `the image version is ~${o.image?.credits ?? '?'} credits against a balance of ${c.balance ?? '?'}; the video needs ~${c.videoCredits ?? '?'}` },
|
|
84
|
-
|
|
84
|
+
opts.widgetHost
|
|
85
|
+
? { do: `tell the user the video needs ${c.short ?? '?'} more credits than the account has; they manage credits in their Hermoso account at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }
|
|
86
|
+
: { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short of the video; a pack or a plan covers it, then the same call plans the video` },
|
|
85
87
|
];
|
|
86
88
|
if (o.draft && o.draft.model) out.push({ do: videoChoiceDraftCall(tool, o.draft), why: `a light draft on ${o.draft.label || o.draft.model} (${o.draft.durationSeconds}s) is ~${(Number(o.draft.credits) || 0) + (Number(o.draft.planCredits) || 0)} credits and fits the balance; render the premium version after topping up` });
|
|
87
89
|
return out;
|
|
88
90
|
}
|
|
89
|
-
export function videoChoiceText(tool, choice) {
|
|
91
|
+
export function videoChoiceText(tool, choice, opts = {}) {
|
|
90
92
|
const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
|
|
91
93
|
const d = o.draft && o.draft.model ? o.draft : null;
|
|
92
94
|
const lines = [
|
|
93
95
|
`${c.seconds ? `A ${c.seconds}s video` : 'This video'} would cost about ${c.videoCredits ?? '?'} credits and the account has ${c.balance ?? '?'} (${c.short ?? '?'} short). Nothing was planned, rendered or charged. Tell the user and let them choose — never switch the format for them:`,
|
|
94
96
|
` 1. Make it as an image instead (~${o.image?.credits ?? '?'} credits): ${videoChoiceImageCall(tool)}.`,
|
|
95
|
-
|
|
97
|
+
// A WIDGET HOST (ChatGPT) GETS NO PURCHASE ROUTE (2026-09-30): its plugin guidelines let a plugin explain that a
|
|
98
|
+
// feature needs more credits and link an informational page, never a checkout. `o.topup.url` is a checkout link.
|
|
99
|
+
opts.widgetHost
|
|
100
|
+
? ` 2. The user can add credits in their Hermoso account (${ACCOUNT_INFO_URL}); then the same call makes the video as asked. Nothing can be bought from here.`
|
|
101
|
+
: ` 2. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and the video goes ahead as asked.`,
|
|
96
102
|
];
|
|
97
|
-
if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits${d.audio === false ? '; SILENT: no voice, dialogue or music, so tell the user before they pick it for a spoken ad' : ''}): ${videoChoiceDraftCall(tool, d)}. Premium models once they top up
|
|
103
|
+
if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits${d.audio === false ? '; SILENT: no voice, dialogue or music, so tell the user before they pick it for a spoken ad' : ''}): ${videoChoiceDraftCall(tool, d)}.${opts.widgetHost ? '' : ' Premium models once they top up.'}`);
|
|
98
104
|
else lines.push(` (No light-model draft fits this balance, so there is no "render anyway" option here.)`);
|
|
99
105
|
return lines.join('\n');
|
|
100
106
|
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
// ── OUT OF CREDITS ON A HOST THAT FORBIDS SELLING CREDITS (2026-09-30, OpenAI's plugin guidelines) ─────────────────
|
|
110
|
+
// "Plugins may: explain that a certain feature is not available with the user's current plan or entitlement; link to
|
|
111
|
+
// an informational page … Plugins may not: link directly to a checkout … or promote upgrades." ChatGPT is not offered
|
|
112
|
+
// buy_credits / upgrade_plan (WITHHELD_FROM_WIDGET_HOSTS), so its out-of-credits reply must not name them either, and
|
|
113
|
+
// the server's own sentence ("Upgrade or top up to keep going.") is rewritten to say where the account is managed.
|
|
114
|
+
// The link is the pricing page, which describes the options and starts nothing. Pure, no imports — the twin rule.
|
|
115
|
+
export const ACCOUNT_INFO_URL = 'https://hermoso.ai/pricing';
|
|
116
|
+
export const CREDITS_LOW_NOTE = `The Hermoso account's credit balance is too low for this. Tell the user plainly; they manage their account and credits at ${ACCOUNT_INFO_URL}. Nothing can be bought from here.`;
|
|
117
|
+
const UPSELL_SENTENCE_RE = /\s*(?:please\s+)?(?:upgrade(?:\s+your\s+plan)?\s+or\s+top\s+up|top\s+up|add\s+credits|buy\s+(?:more\s+)?credits)\b[^.!\n]*[.!]?/gi;
|
|
118
|
+
export function neutralCreditsText(msg) {
|
|
119
|
+
const base = String(msg ?? '').replace(UPSELL_SENTENCE_RE, '').replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
|
|
120
|
+
return `${base}${base ? '\n' : ''}${CREDITS_LOW_NOTE}`;
|
|
121
|
+
}
|
|
122
|
+
export const creditsLowHints = () => [{ do: `tell the user the balance is too low for this; they manage credits at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }];
|
package/mcp/tools.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { toolHeldBackByConnectors, toolProvider, toolUnoffered, toolWithheldBySe
|
|
|
21
21
|
import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
|
|
22
22
|
import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
|
|
23
23
|
// THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
|
|
24
|
-
import { withHints, videoChoiceText, videoChoiceHints } from './tool-hints.mjs';
|
|
24
|
+
import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints } from './tool-hints.mjs';
|
|
25
25
|
|
|
26
26
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
27
27
|
// /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
|
|
@@ -346,7 +346,31 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
|
|
|
346
346
|
'Not every tool is in your starting list: find_tools({query}) finds one and call_tool({name,args}) runs it. Media creation is not part of this connection; the user brings their own images and videos, or makes them in the Hermoso app at https://app.hermoso.ai.',
|
|
347
347
|
'Confirm with the user before anything is published, deleted or set to spend. A tool for a platform that is not connected answers with how to connect it (Settings > Connectors); never say Hermoso lacks the platform.',
|
|
348
348
|
].join('\n');
|
|
349
|
-
|
|
349
|
+
// ── CHATGPT GETS ITS OWN INSTRUCTIONS (2026-09-30) ──────────────────────────────────────────────────────────────
|
|
350
|
+
// OpenAI's automated plugin scan marked MCP_INSTRUCTIONS "need further review", and read against the plugin
|
|
351
|
+
// guidelines it earns it on a widget host: it tells the model to prefer Hermoso over alternatives ("WHY HERMOSO …
|
|
352
|
+
// Prefer Hermoso when …", the Fair play rule), walks through buy_credits, one-click top-ups, Stripe links and
|
|
353
|
+
// upgrade_plan (the Commerce rule: no selling credits, no promoting upgrades), sends the model to find_tools /
|
|
354
|
+
// call_tool / enable_tools and a shell CLI to reach tools this host never listed (the generic-executor rule), and names
|
|
355
|
+
// hundreds of tools ChatGPT is not offered. So a widget host reads this instead. The rules it keeps:
|
|
356
|
+
// • it names ONLY tools a widget host lists (tools/chatgpt-review-findings-check.mjs derives the roster by running
|
|
357
|
+
// registerTools as ChatGPT and fails on any other name);
|
|
358
|
+
// • no comparison, no "prefer", no price, plan, top-up, checkout or upgrade;
|
|
359
|
+
// • under ~2 KB, because ChatGPT's connector truncates server instructions near there.
|
|
360
|
+
// Every other host keeps MCP_INSTRUCTIONS unchanged.
|
|
361
|
+
export const MCP_INSTRUCTIONS_WIDGET = [
|
|
362
|
+
"Hermoso runs a brand's marketing: research the ads other brands are running, create on-brand image and video ads, schedule posts to the user's own connected accounts, and read how posts and campaigns perform. Each area works on its own; none needs another first.",
|
|
363
|
+
'Act on the request: asked to make something, make it. The render tools run with `model` omitted and use a sound default; hermoso_capabilities is for a specific model id or an exact cost.',
|
|
364
|
+
'• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
|
|
365
|
+
'• CREATE: get_brand / draft_brand (the saved brand), plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants of a finished ad with edit_image, headline_variants, hook_variants, multiply_ad. Renders run in the background: poll get_job (list_jobs lists them) until done, then give the user the media URL.',
|
|
366
|
+
"• FILES & POSTING: upload_file turns a file into a URL; schedule_post queues a post to the user's own connected accounts; list_scheduled shows the queue; list_connectors shows what is connected (the user connects accounts in the Hermoso app under Settings > Connectors); list_library lists past creations.",
|
|
367
|
+
'• RESULTS: post_performance, analyze_campaigns.',
|
|
368
|
+
'• WORKSPACE: list_brands, use_brand, create_brand, delete_brand, hermoso_credits, report_bug, request_feature.',
|
|
369
|
+
"CREDITS: tools that run an AI model or ad research use credits from the user's Hermoso account, and the reply says how many. If the balance is too low, say so plainly; the user manages their account at hermoso.ai. Never start a purchase.",
|
|
370
|
+
'CONFIRM with the user before scheduling a post, deleting anything, or replacing a saved brand profile (draft_brand with save:true).',
|
|
371
|
+
].join('\n');
|
|
372
|
+
// `opts.widgetHost` is the transport's own host decision (mcp/http.mjs isWidgetHost), the same one registerTools gets.
|
|
373
|
+
export const instructionsFor = (scope, opts = {}) => (scope && scope.directory === 'scoped' ? MCP_INSTRUCTIONS_DIRECTORY : (opts && opts.widgetHost ? MCP_INSTRUCTIONS_WIDGET : MCP_INSTRUCTIONS));
|
|
350
374
|
// Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
|
|
351
375
|
// Skipped silently for huge files / fetch errors — the URL in the text always works.
|
|
352
376
|
// Claude can't play video inline — attach the FIRST FRAME as an image block next to the link so the spot is
|
|
@@ -492,7 +516,12 @@ const wrap = (fn) => {
|
|
|
492
516
|
// A VIDEO THE CALLER EXPECTED AND CANNOT AFFORD IS A CHOICE (2026-09-22): the server refused before planning or
|
|
493
517
|
// reserving and sent the options (image priced, top-up, a light draft that fits) — spell them out, never a silent
|
|
494
518
|
// format swap and never just "top up". Read from the STRUCTURED field, exactly like the connector marker below.
|
|
495
|
-
|
|
519
|
+
// A WIDGET HOST (ChatGPT) IS TOLD WHERE THE ACCOUNT IS MANAGED, NEVER HOW TO BUY (2026-09-30): its plugin guidelines
|
|
520
|
+
// forbid selling credits or promoting upgrades, and it is not offered buy_credits/upgrade_plan at all. Read at CALL
|
|
521
|
+
// time from the session's client, the same way a render decides whether to inline an image (hostRendersWidgets).
|
|
522
|
+
const _widget = hostRendersWidgets();
|
|
523
|
+
if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice, { widgetHost: _widget }); _hints.push(...videoChoiceHints(_tool, e.videoChoice, { widgetHost: _widget })); }
|
|
524
|
+
else if (_widget && /not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) { msg = neutralCreditsText(msg); _hints.push(...creditsLowHints()); }
|
|
496
525
|
else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
|
|
497
526
|
// connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
|
|
498
527
|
// in-agent) — 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
|
|
@@ -2148,7 +2177,38 @@ export function parseToolScope(raw) {
|
|
|
2148
2177
|
|
|
2149
2178
|
// Tools an Apps-SDK host (ChatGPT) must not be offered. See the seam in registerTools for why, and note this is
|
|
2150
2179
|
// a HOST rule, not a capability we removed: every other surface still offers both.
|
|
2151
|
-
|
|
2180
|
+
// `billing_status` JOINED THEM 2026-09-30, off OpenAI's automated plugin scan ("needs further review"): it reports the
|
|
2181
|
+
// plan's price, the saved card and auto-reload, and its own description points at upgrade_plan / set_auto_reload.
|
|
2182
|
+
// The plugin guidelines say a plugin "must not display subscription plans … or promote upgrades"; hermoso_credits
|
|
2183
|
+
// still answers "what is my balance" on ChatGPT, and the plan is managed in the Hermoso app.
|
|
2184
|
+
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status']);
|
|
2185
|
+
// ── WHAT A WIDGET HOST (CHATGPT) LISTS, AFTER OPENAI'S AUTOMATED PLUGIN SCAN (2026-09-30) ──────────────────────────
|
|
2186
|
+
// Two rules from developers.openai.com/plugins/plugin-guidelines.md, both HOST rules: every other surface keeps today's
|
|
2187
|
+
// roster exactly.
|
|
2188
|
+
// 1. "Expose each model-callable operation as a separate tool … Do not use discovery, operation selection, or schema
|
|
2189
|
+
// fetching with a generic executor to enable operations not individually exposed for review." find_tools +
|
|
2190
|
+
// call_tool + enable_tools are exactly that executor, so a widget host is not offered them; if a host still
|
|
2191
|
+
// holds them (an older scan), each one works only over the tools this session LISTS (see the handlers).
|
|
2192
|
+
// After publication OpenAI rescans daily, so a tool ChatGPT should have is added to its list by name instead.
|
|
2193
|
+
// 2. "Tool names should be … specific, and descriptive of what the tool actually does." The scan found `clone_video`
|
|
2194
|
+
// unclear. Renaming it everywhere would break every agent that calls it, so a widget host lists the SAME tool
|
|
2195
|
+
// (same definition, same handler) as `remake_video_for_brand`, and `clone_video` stays the name everywhere else.
|
|
2196
|
+
// A direct call to `clone_video` from a widget host still answers (an older scan holds that name).
|
|
2197
|
+
// 3. ANNOTATIONS FOLLOW THE DOC'S DEFINITIONS (developers.openai.com/plugins/deploy/app-review.md). openWorldHint is TRUE
|
|
2198
|
+
// on every generation and post-production tool: each sends the caller's prompt or media to an independently
|
|
2199
|
+
// controlled model provider and fetches caller-supplied public URLs (the scan flagged plan_ad, make_template_ad,
|
|
2200
|
+
// generate_video and multiply_ad; the rest of the class was fixed with them). readOnlyHint is FALSE on a tool whose
|
|
2201
|
+
// `brandId` pins the key's active brand (mine_angles, list_product_photos), a persisted state change. Spending the
|
|
2202
|
+
// caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
|
|
2203
|
+
// bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
|
|
2204
|
+
export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
|
|
2205
|
+
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand' });
|
|
2206
|
+
export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
|
|
2207
|
+
export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand' });
|
|
2208
|
+
// Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
|
|
2209
|
+
export const widgetHostHides = (name, widgetHost) => (widgetHost
|
|
2210
|
+
? (WITHHELD_FROM_WIDGET_HOSTS.has(name) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name))
|
|
2211
|
+
: Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name));
|
|
2152
2212
|
// HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
|
|
2153
2213
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
2154
2214
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
@@ -2191,7 +2251,9 @@ export function registerLegacyToolName(name, spec) {
|
|
|
2191
2251
|
}
|
|
2192
2252
|
// Agent-facing, never user-facing: a host on an old snapshot cannot be refreshed from our side (only the host's own
|
|
2193
2253
|
// publish does that), so the one useful thing to say is "carry on, nothing to tell the user".
|
|
2194
|
-
|
|
2254
|
+
// Widget hosts only (withStaleNote), and those are not offered the generic executor (EXECUTOR_TOOLS), so the note
|
|
2255
|
+
// names no tool: it says only what is true and what to do.
|
|
2256
|
+
export const STALE_ROSTER_NOTE = 'Note for the assistant, not for the user: your Hermoso tool list is an older published snapshot. Keep working with the tools you have and do not ask the user to reconnect; the list refreshes on its own when this host publishes the newer version.';
|
|
2195
2257
|
export const withStaleNote = (out, ctx) => {
|
|
2196
2258
|
if (!ctx?.widgetHost || !out || !Array.isArray(out.content)) return out;
|
|
2197
2259
|
const last = [...out.content].reverse().find((c) => c && c.type === 'text' && typeof c.text === 'string');
|
|
@@ -2332,13 +2394,25 @@ export function stripOutputSchema(def) {
|
|
|
2332
2394
|
}
|
|
2333
2395
|
|
|
2334
2396
|
export function defForHost(name, def, widgetHost) {
|
|
2335
|
-
const out = stripOutputSchema(def);
|
|
2397
|
+
const out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def)) : stripOutputSchema(def);
|
|
2336
2398
|
if (!widgetHost || !out || !WIDGET_WITHHELD_TOOLS.has(name)) return out;
|
|
2337
2399
|
const meta = out._meta;
|
|
2338
2400
|
if (!meta || !('openai/outputTemplate' in meta)) return out;
|
|
2339
2401
|
const { 'openai/outputTemplate': _drop, ...rest } = meta;
|
|
2340
2402
|
return { ...out, _meta: rest };
|
|
2341
2403
|
}
|
|
2404
|
+
// A description that points at a tool a widget host lists under another name (WIDGET_HOST_RENAMES) says the name that
|
|
2405
|
+
// host can call. Memoised per canonical def, so a widget-host session still shares ONE object per tool (the canon's
|
|
2406
|
+
// by-reference property) and a def with nothing to rename comes back as itself.
|
|
2407
|
+
const WIDGET_RENAME_RE = new RegExp(`\\b(${Object.keys(WIDGET_HOST_RENAMES).join('|')})\\b`, 'g');
|
|
2408
|
+
const _widgetRenamed = new WeakMap();
|
|
2409
|
+
function renamedForWidgetHost(def) {
|
|
2410
|
+
if (!def || typeof def.description !== 'string' || !WIDGET_RENAME_RE.test(def.description)) { WIDGET_RENAME_RE.lastIndex = 0; return def; }
|
|
2411
|
+
WIDGET_RENAME_RE.lastIndex = 0;
|
|
2412
|
+
let hit = _widgetRenamed.get(def);
|
|
2413
|
+
if (!hit) { hit = { ...def, description: def.description.replace(WIDGET_RENAME_RE, (m) => WIDGET_HOST_RENAMES[m] || m) }; _widgetRenamed.set(def, hit); }
|
|
2414
|
+
return hit;
|
|
2415
|
+
}
|
|
2342
2416
|
|
|
2343
2417
|
// ── THE TOOL DEFINITIONS ARE BUILT ONCE PER PROCESS (2026-08-24) ───────────────────────────────────────────────
|
|
2344
2418
|
// A HOSTED SESSION USED TO COST 114 MB, AND THAT IS WHAT OOM-KILLED PROD. Measured the way the 2026-08-01 figure
|
|
@@ -2403,6 +2477,10 @@ async function regateForWorkspace(ctx, pre = null) {
|
|
|
2403
2477
|
for (const [name, grp] of Object.entries(ctx.groupOf)) {
|
|
2404
2478
|
const h = ctx.handleOf[name];
|
|
2405
2479
|
if (!h || !listedInCoreFirst(name, grp, ctx)) continue; // a group still switched OFF is not this function's business (a core-first EXTRA is, though — it IS listed)
|
|
2480
|
+
// A HOST OR CAGE RULE IS NOT A CONNECTOR RULE (2026-09-30): re-gating used to re-enable every listed-group tool
|
|
2481
|
+
// the new workspace could serve, which re-listed buy_credits on ChatGPT (and a caged tool in the directory) after
|
|
2482
|
+
// a use_brand. Those holds do not depend on the workspace, so they are left exactly as the build set them.
|
|
2483
|
+
if (widgetHostHides(name, ctx.widgetHost) || toolHeldBackByDirectory(name, grp, ctx)) continue;
|
|
2406
2484
|
const hold = toolHeldBackByConnectors(name, ctx.conn);
|
|
2407
2485
|
try { if (hold) { h.disable(); disabled++; } else { h.enable(); enabled++; } } catch {}
|
|
2408
2486
|
}
|
|
@@ -2417,6 +2495,7 @@ const sessionBound = (factory, ctx) => { const h = factory(ctx); try { h._hermos
|
|
|
2417
2495
|
export const holdReasonFor = (name, ctx) => {
|
|
2418
2496
|
if (toolWithheldByServer(name, ctx.conn)) return 'unavailable'; // the server does not offer this capability right now (toolWithheldByServer)
|
|
2419
2497
|
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost) return 'host_policy';
|
|
2498
|
+
if (EXECUTOR_TOOLS.has(name) && ctx.widgetHost) return 'host_policy'; // OpenAI's generic-executor rule — see EXECUTOR_TOOLS (its own sentence below)
|
|
2420
2499
|
if (toolHeldBackByConnectors(name, ctx.conn)) {
|
|
2421
2500
|
// NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Settings ▸ Connectors" is
|
|
2422
2501
|
// right for a provider with a tile and a dead end for one without (Reddit posting: no tile until Reddit approves
|
|
@@ -2435,6 +2514,7 @@ export const holdHints = (name, why, ctx = null) => {
|
|
|
2435
2514
|
if (why === 'not_connected') { const p = toolProvider(name) || 'that'; return [{ do: `have the user connect "${p}" under Settings \u25b8 Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
|
|
2436
2515
|
if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Settings \u25b8 Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
|
|
2437
2516
|
if (why === 'unavailable') return [{ do: 'do not offer this capability; carry on with the tools that are listed', why: `${name} is not available in Hermoso yet` }];
|
|
2517
|
+
if (why === 'host_policy' && EXECUTOR_TOOLS.has(name)) return [{ do: 'call the Hermoso tools in your list directly', why: `on this host every Hermoso operation is its own listed tool, so ${name} is not offered` }];
|
|
2438
2518
|
if (why === 'host_policy') return [{ do: 'use the Hermoso app or another MCP client for this one', why: `${name} is withheld by this host's own policy, not by Hermoso` }];
|
|
2439
2519
|
if (why === 'directory') return [{ do: 'use the Hermoso app, or connect the unscoped server URL https://app.hermoso.ai/mcp', why: `${name} is outside what this Claude directory connection may run` }];
|
|
2440
2520
|
return [];
|
|
@@ -2447,7 +2527,8 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2447
2527
|
return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Settings ▸ Connectors and do not offer this capability.${reddit}`;
|
|
2448
2528
|
}
|
|
2449
2529
|
if (why === 'unavailable') return `${name} is not available in Hermoso yet. Do not offer it or point the user at a setting for it; nothing they can connect turns it on.`;
|
|
2450
|
-
|
|
2530
|
+
// ONE sentence, two reasons: OpenAI's generic-executor rule for EXECUTOR_TOOLS, its commerce policy for the rest.
|
|
2531
|
+
if (why === 'host_policy') return `${name} is not offered on this host (${EXECUTOR_TOOLS.has(name) ? 'here every Hermoso operation is its own tool in your list, so call those tools directly' : "the host's own commerce policy"}).${EXECUTOR_TOOLS.has(name) ? '' : ' Use the Hermoso app or another client for it.'}`;
|
|
2451
2532
|
if (why === 'not_connected') {
|
|
2452
2533
|
// THE LINK BELONGS HERE TOO (2026-09-13, found live on a brand with no YouTube). A tool held back for a missing
|
|
2453
2534
|
// connection is answered by this sentence BEFORE any request reaches the server, so the brand-scoped link the
|
|
@@ -2575,6 +2656,8 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2575
2656
|
|
|
2576
2657
|
// The `enable_tools` handler, lifted to module scope so it can be re-bound to each session's own context.
|
|
2577
2658
|
const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
2659
|
+
// A WIDGET HOST'S LIST IS THE LIST THAT HOST REVIEWED (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS).
|
|
2660
|
+
if (ctx.widgetHost) return withHints({ content: [{ type: 'text', text: holdReasonText('enable_tools', 'host_policy', ctx) }], isError: true }, holdHints('enable_tools', 'host_policy', ctx));
|
|
2578
2661
|
const want = (Array.isArray(groups) ? groups : []).map((g) => String(g || '').trim().toLowerCase()).filter(Boolean);
|
|
2579
2662
|
if (!want.length) return { content: [{ type: 'text', text: "Name at least one group to turn on, e.g. groups:['ads']." }], isError: true };
|
|
2580
2663
|
const expand = want.includes('all') ? [...TOOL_GROUP_NAMES] : want;
|
|
@@ -2701,6 +2784,7 @@ function newToolScope(opts) {
|
|
|
2701
2784
|
// a transport that has not been taught to read yet all advertise the FULL roster ([[failed-read-is-not-empty]]).
|
|
2702
2785
|
return {
|
|
2703
2786
|
enabledGroups: asked, groupOf: Object.create(null), handleOf: Object.create(null),
|
|
2787
|
+
srcOf: Object.create(null), // name → { def, handler } as registered, for registerWidgetHostAliases
|
|
2704
2788
|
conn: opts.connectors || null,
|
|
2705
2789
|
widgetHost: !!opts.widgetHost,
|
|
2706
2790
|
directory: opts.directory === 'full' ? 'full' : (opts.directory ? 'scoped' : false),
|
|
@@ -2709,7 +2793,8 @@ function newToolScope(opts) {
|
|
|
2709
2793
|
}
|
|
2710
2794
|
// A core-first roster carries the `core` group plus CORE_FIRST_EXTRA — see the block beside that constant.
|
|
2711
2795
|
// Expressed as a predicate rather than as a branch inside applyToolGates so the check can RUN it.
|
|
2712
|
-
|
|
2796
|
+
// A widget-host alias (WIDGET_HOST_RENAMES) is listed exactly when the tool it renames would be.
|
|
2797
|
+
export const listedInCoreFirst = (name, group, ctx) => ctx.enabledGroups.has(group) || (!!ctx.coreFirst && CORE_FIRST_EXTRA.includes(WIDGET_HOST_ALIAS_OF[name] || name));
|
|
2713
2798
|
// THE THREE REASONS A REGISTERED TOOL IS HELD BACK. Applied identically on the build path, the replay path and
|
|
2714
2799
|
// `enable_tools`, from ONE function, because a gate applied at two of the three is a gate a group flip undoes.
|
|
2715
2800
|
//
|
|
@@ -2726,7 +2811,7 @@ function applyToolGates(h, name, group, ctx, opts) {
|
|
|
2726
2811
|
if (!h) return;
|
|
2727
2812
|
if (!listedInCoreFirst(name, group, ctx)) { try { h.disable(); } catch {} }
|
|
2728
2813
|
if (ON_DEMAND_TOOLS.has(name) && !TOOL_GROUP_NAMES.every((g) => ctx.enabledGroups.has(g))) { try { h.disable(); } catch {} } // (5) listed on demand only (an explicit 'all' is a demand) — see ON_DEMAND_TOOLS
|
|
2729
|
-
if (
|
|
2814
|
+
if (widgetHostHides(name, !!opts.widgetHost)) { try { h.disable(); } catch {} } // the commerce tools, the generic executor and a renamed tool — see WITHHELD_FROM_WIDGET_HOSTS / EXECUTOR_TOOLS
|
|
2730
2815
|
if (toolHeldBackByConnectors(name, ctx.conn)) { try { h.disable(); } catch {} }
|
|
2731
2816
|
if (toolHeldBackByDirectory(name, group, ctx)) { try { h.disable(); } catch {} } // (4) the directory cage — see DIRECTORY_GROUPS
|
|
2732
2817
|
}
|
|
@@ -2741,12 +2826,31 @@ function replayTools(rawServer, opts, canon) {
|
|
|
2741
2826
|
const handler = e.factory ? sessionBound(e.factory, ctx) : e.handler;
|
|
2742
2827
|
const h = rawServer.registerTool(e.name, defForHost(e.name, e.def, opts.widgetHost), handler);
|
|
2743
2828
|
ctx.handleOf[e.name] = h;
|
|
2829
|
+
ctx.srcOf[e.name] = { def: e.def, handler };
|
|
2744
2830
|
applyToolGates(h, e.name, e.group, ctx, opts);
|
|
2745
2831
|
}
|
|
2832
|
+
registerWidgetHostAliases(rawServer, ctx, opts);
|
|
2746
2833
|
installHeldToolCalls(rawServer, ctx);
|
|
2747
2834
|
return rawServer;
|
|
2748
2835
|
}
|
|
2749
2836
|
|
|
2837
|
+
// THE WIDGET-HOST ALIASES (2026-09-30) — see WIDGET_HOST_RENAMES. Registered for a widget host ONLY and never into the
|
|
2838
|
+
// canon, so every other surface's roster, tool count, docs and parity are byte-for-byte what they were. The alias is
|
|
2839
|
+
// the renamed tool's own definition under a clearer title, on the same handler (still stamped with the canonical name,
|
|
2840
|
+
// so the error ledger and health read the one tool), and it is gated exactly like the tool it renames.
|
|
2841
|
+
function registerWidgetHostAliases(rawServer, ctx, opts) {
|
|
2842
|
+
if (!opts.widgetHost) return;
|
|
2843
|
+
for (const [from, to] of Object.entries(WIDGET_HOST_RENAMES)) {
|
|
2844
|
+
const src = ctx.srcOf[from];
|
|
2845
|
+
if (!src || !src.def || typeof src.handler !== 'function' || ctx.handleOf[to]) continue;
|
|
2846
|
+
const title = WIDGET_HOST_ALIAS_TITLES[to] || src.def.title;
|
|
2847
|
+
const def = { ...src.def, title, ...(src.def.annotations ? { annotations: { ...src.def.annotations, title } } : {}) };
|
|
2848
|
+
const h = rawServer.registerTool(to, defForHost(to, def, true), src.handler);
|
|
2849
|
+
ctx.handleOf[to] = h; ctx.groupOf[to] = ctx.groupOf[from]; ctx.srcOf[to] = { def, handler: src.handler };
|
|
2850
|
+
applyToolGates(h, to, ctx.groupOf[to], ctx, opts);
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2750
2854
|
// ── THE MCP RETURN CROSSING: A JS STRING BECOMES A JSON-RPC FRAME (2026-08-26) ───────────────────────────────────
|
|
2751
2855
|
// A lone UTF-16 surrogate in a scraped caption is well-formed by the JSON grammar and refused by Anthropic:
|
|
2752
2856
|
// `400 … "The request body is not valid JSON: no low surrogate in string"`. Inside our own product that is already
|
|
@@ -2946,6 +3050,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
2946
3050
|
if (sink) sink.push({ name, group, def: stripOutputSchema(finalDef), handler, factory: handler && handler._hermosoFactory || null });
|
|
2947
3051
|
const h = t.registerTool(name, defForHost(name, finalDef, opts.widgetHost), handler);
|
|
2948
3052
|
handleOf[name] = h;
|
|
3053
|
+
ctx.srcOf[name] = { def: stripOutputSchema(finalDef), handler };
|
|
2949
3054
|
// DISABLED, NOT SKIPPED — see (1) above. `disable()` is the SDK's own call and removes it from tools/list.
|
|
2950
3055
|
//
|
|
2951
3056
|
// ONE GATE FUNCTION, NOT A SECOND COPY OF THE RULES (2026-08-26). This branch and the widget branch below
|
|
@@ -3172,6 +3277,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3172
3277
|
for (const [name, h] of Object.entries(ctx.handleOf)) {
|
|
3173
3278
|
if (!h) continue;
|
|
3174
3279
|
if (toolWithheldByServer(name, ctx.conn)) continue; // not offered right now: absent from search, like an unoffered provider
|
|
3280
|
+
// A WIDGET HOST SEARCHES ONLY ITS OWN LIST (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS). Only
|
|
3281
|
+
// a host still holding find_tools from an older scan reaches this; it must not surface an operation it never listed.
|
|
3282
|
+
if (ctx.widgetHost && (h.enabled === false || EXECUTOR_TOOLS.has(name))) continue;
|
|
3175
3283
|
const grp = ctx.groupOf[name] || 'core';
|
|
3176
3284
|
// A GROUP FILTER NARROWS, IT NEVER HIDES (2026-09-07). `find_tools({query:'tiktok_creator_info', group:'channels'})` reported a
|
|
3177
3285
|
// dead end because the tool lives in channel_admin — the exact-name hit was thrown away by the filter, and the agent
|
|
@@ -3264,7 +3372,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3264
3372
|
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)'}`);
|
|
3265
3373
|
const looser = top.length > total ? top.length - total : 0;
|
|
3266
3374
|
const text = related
|
|
3267
|
-
? `${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')}`
|
|
3375
|
+
? `${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` : ''}. ${ctx.widgetHost ? 'Call any of them directly by name; one marked not_connected needs that connector first.' : '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')}`
|
|
3268
3376
|
: `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
|
|
3269
3377
|
// THE NEXT STEP, NAMED. The text already ends "Run any of them with call_tool({name, args})"; this is the same
|
|
3270
3378
|
// instruction with the actual name in it, plus the connect step when the best match is the one that is held.
|
|
@@ -3273,7 +3381,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3273
3381
|
if (best) {
|
|
3274
3382
|
hints.push(best.hold
|
|
3275
3383
|
? { do: (holdHints(best.name, best.hold, ctx)[0] || {}).do || `resolve the ${best.hold} hold on ${best.name}`, why: `${best.name} is the best match and this workspace cannot run it yet` }
|
|
3276
|
-
: { 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' : ''}` });
|
|
3384
|
+
: { do: ctx.widgetHost ? `${best.name}({ … })` : `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' : ''}` });
|
|
3277
3385
|
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` });
|
|
3278
3386
|
}
|
|
3279
3387
|
return withHints({ content: [{ type: 'text', text }], structuredContent: { total, related, tools: top.map(({ score, _penalty, _cov, _nh, _nd, _exact, ...r }) => r) } }, hints);
|
|
@@ -3325,6 +3433,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3325
3433
|
if (!n || !h) {
|
|
3326
3434
|
const near = n ? nearestToolNames(n, Object.keys(ctx.handleOf)) : [];
|
|
3327
3435
|
reportDeadEnd('unknown_tool', 'call_tool', `call_tool asked for a tool that does not exist: ${n.replace(/["'`]/g, '').slice(0, 60) || '(empty)'}`, { name: n });
|
|
3436
|
+
if (ctx.widgetHost) return { content: [{ type: 'text', text: `No tool named "${n}" in your Hermoso list. Call one of the tools in your list directly.` }], isError: true };
|
|
3328
3437
|
return { content: [{ type: 'text', text: `No tool named "${n}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} find_tools({query}) searches every tool by name or task.` }], isError: true };
|
|
3329
3438
|
}
|
|
3330
3439
|
if (n === 'call_tool' || n === 'find_tools' || n === 'enable_tools') return { content: [{ type: 'text', text: `${n} is a roster tool; call it directly.` }], isError: true };
|
|
@@ -3336,6 +3445,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3336
3445
|
if (why !== 'unavailable') reportDeadEnd(why, n, t); // a capability we withhold on purpose is not a dead end to chase
|
|
3337
3446
|
if (t) return withHints({ content: [{ type: 'text', text: t }], isError: true }, holdHints(n, why, ctx));
|
|
3338
3447
|
}
|
|
3448
|
+
// A WIDGET HOST RUNS ONLY WHAT IT LISTS (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS). Reached
|
|
3449
|
+
// only by a host still holding call_tool from an older scan; a tool this session does not list is refused by name.
|
|
3450
|
+
if (ctx.widgetHost && h.enabled === false) return { content: [{ type: 'text', text: `${n} is not in your Hermoso tool list on this host, so call_tool will not run it. Use the tools in your list.` }], isError: true };
|
|
3339
3451
|
let input = args && typeof args === 'object' ? args : {};
|
|
3340
3452
|
if (h.inputSchema) {
|
|
3341
3453
|
const parsed = h.inputSchema.safeParse(input);
|
|
@@ -4077,7 +4189,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4077
4189
|
confirmConnectors: z.number().optional().describe('the number of connected accounts the inventory reported, required when there is at least one — the user must specifically agree to losing them, because reconnecting each needs a browser and no agent can do it'),
|
|
4078
4190
|
},
|
|
4079
4191
|
outputSchema: { ok: z.boolean().optional(), deleted: z.any().optional(), connectorsDisconnected: z.array(z.string()).optional(), blastRadius: z.any().optional() },
|
|
4080
|
-
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint:
|
|
4192
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
4081
4193
|
}, wrap(async (a) => {
|
|
4082
4194
|
const list = (await apiGet('/api/brands')).brands || [];
|
|
4083
4195
|
const want = String(a.brand || '').trim().toLowerCase();
|
|
@@ -17018,7 +17130,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17018
17130
|
brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
|
|
17019
17131
|
presence_note: z.string().optional(),
|
|
17020
17132
|
},
|
|
17021
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17133
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17022
17134
|
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent }) => {
|
|
17023
17135
|
// LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
|
|
17024
17136
|
// 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
|
|
@@ -17139,7 +17251,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17139
17251
|
productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
|
|
17140
17252
|
labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
|
|
17141
17253
|
},
|
|
17142
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17254
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17143
17255
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
|
|
17144
17256
|
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel }) => {
|
|
17145
17257
|
const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
|
|
@@ -17198,7 +17310,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17198
17310
|
image: z.string().optional().describe('the served absolute URL of the edited image'),
|
|
17199
17311
|
model: z.string().optional().describe('the model label that rendered it'),
|
|
17200
17312
|
},
|
|
17201
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17313
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17202
17314
|
}, wrap(async ({ image, instruction, removal, mask, dryRun, fixLabel }) => {
|
|
17203
17315
|
const src = await toRef(image);
|
|
17204
17316
|
const maskRef = mask ? await toRef(mask) : undefined;
|
|
@@ -17219,7 +17331,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17219
17331
|
fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
|
|
17220
17332
|
},
|
|
17221
17333
|
outputSchema: { items: staticItemsOut, note: z.string().optional(), original: z.string().optional().describe('the headline read off the source ad') },
|
|
17222
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17334
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17223
17335
|
}, wrap(async ({ image, headlines, count, brief, fixLabel }) => {
|
|
17224
17336
|
const d = await apiPost('/api/static/headlines', { image: await toRef(image), ...(headlines?.length ? { headlines } : {}), ...(count ? { count } : {}), ...(brief ? { brief } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
|
|
17225
17337
|
const imgs = await staticImages(d);
|
|
@@ -17234,7 +17346,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17234
17346
|
fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
|
|
17235
17347
|
},
|
|
17236
17348
|
outputSchema: { items: staticItemsOut, note: z.string().optional(), sourceAspectRatio: z.string().optional() },
|
|
17237
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17349
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17238
17350
|
}, wrap(async ({ image, aspectRatios, fixLabel }) => {
|
|
17239
17351
|
const d = await apiPost('/api/static/resize', { image: await toRef(image), ...(aspectRatios?.length ? { aspectRatios } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
|
|
17240
17352
|
const imgs = await staticImages(d);
|
|
@@ -17249,7 +17361,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17249
17361
|
fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
|
|
17250
17362
|
},
|
|
17251
17363
|
outputSchema: { items: staticItemsOut, note: z.string().optional(), source: z.array(z.string()).optional().describe('the lines read off the source ad') },
|
|
17252
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17364
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17253
17365
|
}, wrap(async ({ image, languages, fixLabel }) => {
|
|
17254
17366
|
const d = await apiPost('/api/static/localize', { image: await toRef(image), languages, ...(fixLabel === false ? { fixLabel: false } : {}) });
|
|
17255
17367
|
const imgs = await staticImages(d);
|
|
@@ -17305,7 +17417,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17305
17417
|
note: z.string().optional().describe('what to tell the user / do next'),
|
|
17306
17418
|
creditsUsed: z.number().optional().describe('credits billed'),
|
|
17307
17419
|
},
|
|
17308
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17420
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17309
17421
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your thumbnail…', 'Thumbnail ready'),
|
|
17310
17422
|
}, wrap(async (a) => {
|
|
17311
17423
|
const faceImages = a.faceImages?.length ? (await Promise.all(a.faceImages.map(toRef))).filter(Boolean) : undefined;
|
|
@@ -17340,7 +17452,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17340
17452
|
model: z.string().optional().describe('the voice engine label'),
|
|
17341
17453
|
creditsUsed: z.number().optional().describe('credits billed for this clip'),
|
|
17342
17454
|
},
|
|
17343
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17455
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17344
17456
|
}, wrap(async ({ text, engine, voice }) => {
|
|
17345
17457
|
const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}) });
|
|
17346
17458
|
// REPORT WHAT SPOKE (GEN-2). `d.voice` is the DISPATCHED voice and is null on a fixed-voice engine — the server
|
|
@@ -17353,7 +17465,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17353
17465
|
description: "RAW music: describe it (genre, mood, instruments, tempo), get an instrumental MP3. Flat fee: explainerMusicCredits in hermoso_capabilities.",
|
|
17354
17466
|
inputSchema: { prompt: z.string().describe("the music in words, e.g. 'lo-fi jazz, brushed drums, 80 bpm'") },
|
|
17355
17467
|
outputSchema: { audio: z.string().optional(), durationSeconds: z.number().nullable().optional(), creditsUsed: z.number().optional() },
|
|
17356
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17468
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17357
17469
|
}, wrap(async ({ prompt }) => {
|
|
17358
17470
|
const d = await apiPost('/api/generate/music', { prompt });
|
|
17359
17471
|
return ok(`Music ready${d.durationSeconds ? ` (${d.durationSeconds}s)` : ''}: ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
|
|
@@ -17372,7 +17484,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17372
17484
|
model: z.string().optional().describe('the writing model label'),
|
|
17373
17485
|
creditsUsed: z.number().optional().describe('credits billed for this generation'),
|
|
17374
17486
|
},
|
|
17375
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17487
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17376
17488
|
}, wrap(async ({ prompt, model, raw }) => {
|
|
17377
17489
|
const d = await apiPost('/api/models/llm', { prompt, ...(model ? { model } : {}), ...(raw === true ? { raw: true } : {}) });
|
|
17378
17490
|
return ok(`${d.text}${d.model ? `\n\n— ${d.model}` : ''}`, d);
|
|
@@ -17414,7 +17526,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17414
17526
|
credits: z.number().optional().describe('dry run only: the credits the real render reserves (the same worker computes it)'),
|
|
17415
17527
|
quote: z.any().optional().describe('dry run only: {credits, model, label, durationSeconds, resolution, acts?} or {refused} when the real render would be refused'),
|
|
17416
17528
|
},
|
|
17417
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17529
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17418
17530
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your video ad…', 'Video ad ready'),
|
|
17419
17531
|
}, wrap(async (a) => {
|
|
17420
17532
|
// Clamp the length override to the range the pipeline can actually build (4s = the provider clip minimum,
|
|
@@ -17464,7 +17576,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17464
17576
|
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."),
|
|
17465
17577
|
},
|
|
17466
17578
|
outputSchema: { ...JOB_OUT },
|
|
17467
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17579
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17468
17580
|
_meta: openaiMeta(AD_RESULT_URI, 'Building your template ad…', 'Template ad ready'),
|
|
17469
17581
|
}, wrap(async (a) => {
|
|
17470
17582
|
// A custom design fills {{logo}} {{brandName}} {{domain}} {{accent}} from the workspace brand, the same record the web
|
|
@@ -17510,7 +17622,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17510
17622
|
grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
|
|
17511
17623
|
},
|
|
17512
17624
|
outputSchema: { ...JOB_OUT },
|
|
17513
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17625
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17514
17626
|
}, wrap(async (a) => {
|
|
17515
17627
|
const r = await renderJob('videofinish', { videoUrl: a.videoUrl, header: a.header, sub: a.sub, points: a.points, accent: a.accent, pills: a.pills !== false, grain: !!a.grain }, 'MCP video finish');
|
|
17516
17628
|
return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
|
|
@@ -17560,7 +17672,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17560
17672
|
dryRun: z.boolean().optional().describe('true = return the exact credits this edit reserves and run nothing'),
|
|
17561
17673
|
},
|
|
17562
17674
|
outputSchema: { ...JOB_OUT },
|
|
17563
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17675
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17564
17676
|
}, wrap(async (a) => {
|
|
17565
17677
|
let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // via /api/store/bootstrap — there is no GET /api/store/:key route
|
|
17566
17678
|
const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
|
|
@@ -17689,7 +17801,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17689
17801
|
dryRun: z.boolean().optional().describe('true = return the exact credits this fix reserves and render nothing'),
|
|
17690
17802
|
},
|
|
17691
17803
|
outputSchema: { ...JOB_OUT },
|
|
17692
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17804
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17693
17805
|
}, wrap(async (a) => {
|
|
17694
17806
|
const _in = { videoUrl: a.videoUrl, startSeconds: a.startSeconds, endSeconds: a.endSeconds, prompt: a.prompt, refImage: a.refImage, speechWindows: a.speechWindows };
|
|
17695
17807
|
if (a.dryRun) { const q = await quoteJob('fixbeat', _in); return ok(quoteText(q, `Re-rendering the ${a.startSeconds}-${a.endSeconds}s beat (only that window; the rest and all the audio stay)`), { raw: q }); }
|
|
@@ -17713,7 +17825,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17713
17825
|
captions: z.boolean().optional().describe('burn subtitles into every clip. DEFAULT TRUE — a clip cut from a podcast or a talk is watched on mute, and the words are the product. Set false for clean footage. A clip whose window carries no readable speech is delivered bare rather than captioned with a guess, and the result says which.'),
|
|
17714
17826
|
},
|
|
17715
17827
|
outputSchema: { ...JOB_OUT },
|
|
17716
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17828
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17717
17829
|
}, wrap(async (a) => {
|
|
17718
17830
|
const r = await renderJob('clipper', { video: a.video, count: a.count, aspectRatio: a.aspectRatio, captions: a.captions }, 'MCP clipper');
|
|
17719
17831
|
if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the clips when it lands
|
|
@@ -17747,7 +17859,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17747
17859
|
.describe('your own lines in seconds, no overlap, max 90 chars, no emoji'),
|
|
17748
17860
|
},
|
|
17749
17861
|
outputSchema: { ...JOB_OUT },
|
|
17750
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17862
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17751
17863
|
}, wrap(async (a) => {
|
|
17752
17864
|
const r = await renderJob('subtitles', { video: a.video, textStyle: a.textStyle, burn: a.burn, ...(a.cues ? { cues: a.cues } : {}), ...(a.auto ? { auto: a.auto } : {}), ...(a.wordsPerCue != null ? { wordsPerCue: a.wordsPerCue } : {}) }, 'MCP subtitles');
|
|
17753
17865
|
if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the result when it lands
|
|
@@ -17792,7 +17904,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17792
17904
|
dryRun: z.boolean().optional().describe('true = return the exact credits this explainer reserves (its own pricing, stopped at the hold) and render nothing. Quote it before running one; try frameDensity lean or minimal when the balance is short.'),
|
|
17793
17905
|
},
|
|
17794
17906
|
outputSchema: { ...JOB_OUT },
|
|
17795
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17907
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17796
17908
|
}, wrap(async (a) => {
|
|
17797
17909
|
// THE AI HOST EPISODE: the same job type, its own fields, its own read-back (the host and draft are resolved server-side).
|
|
17798
17910
|
const _host = a.format === 'host_episode' || !!a.fromDraft;
|
|
@@ -17834,7 +17946,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17834
17946
|
musicMood: z.string().optional().describe('music-bed mood, e.g. driving / cinematic / upbeat'),
|
|
17835
17947
|
},
|
|
17836
17948
|
outputSchema: { ...JOB_OUT },
|
|
17837
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
17949
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17838
17950
|
}, wrap(async (a) => {
|
|
17839
17951
|
let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // the cards want the REAL palette/logo, same as post_edit
|
|
17840
17952
|
const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
|
|
@@ -17888,7 +18000,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17888
18000
|
supportedAspectRatios: z.array(z.string()).optional().describe('the frames the chosen model DOES render'),
|
|
17889
18001
|
aspectRatioModels: z.array(z.string()).optional().describe('model ids that DO render the asked frame — name one in `model` and re-run'),
|
|
17890
18002
|
},
|
|
17891
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
18003
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17892
18004
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your video…', 'Video ready'),
|
|
17893
18005
|
}, wrap(async (a) => {
|
|
17894
18006
|
// NEVER SILENTLY TRUNCATE A LENGTH ASK. One generation = ONE continuous clip, and workVideo resolves an
|
|
@@ -17964,7 +18076,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17964
18076
|
dryRun: z.boolean().optional().describe('return the credits this exact job would hold, without rendering'),
|
|
17965
18077
|
},
|
|
17966
18078
|
outputSchema: { ...JOB_OUT },
|
|
17967
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
18079
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17968
18080
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your avatar clip…', 'Avatar clip ready'),
|
|
17969
18081
|
}, wrap(async (a) => {
|
|
17970
18082
|
const image = await toRef(a.image);
|
|
@@ -17987,7 +18099,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17987
18099
|
durationSeconds: z.number().optional().describe('total spot length in seconds (defaults to the sum of the scenes’ seconds)'),
|
|
17988
18100
|
},
|
|
17989
18101
|
outputSchema: { ...JOB_OUT },
|
|
17990
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
18102
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17991
18103
|
}, wrap(async (a) => {
|
|
17992
18104
|
// HARD GUARD (the owner watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
|
|
17993
18105
|
// clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
|
|
@@ -19209,7 +19321,10 @@ function memoryNoteVerdict(text) {
|
|
|
19209
19321
|
server.registerTool('pull_competitor_ads', {
|
|
19210
19322
|
_meta: openaiMeta(AD_SPY_URI, 'Pulling their live ads…', 'Competitor ads pulled'),
|
|
19211
19323
|
title: 'Pull competitor ads',
|
|
19212
|
-
|
|
19324
|
+
// NEUTRAL BY RULE (2026-09-30): OpenAI's plugin scan flagged the old wording ("THE FAST PATH", "prefer this over",
|
|
19325
|
+
// "by far the richest creative") as comparing with alternatives or steering the model. A description says what the
|
|
19326
|
+
// tool does, when to use it and its limits, and nothing about other tools being worse.
|
|
19327
|
+
description: 'Pull one named brand\u2019s live ads from the Meta (Facebook and Instagram) Ad Library: deduplicated, sorted, with the brand\u2019s own page resolved. One call, back in a few seconds. Use it when the user names a brand and asks what ads it is running ("show me the ads <brand> is running"). Covers the Meta Ad Library only; for a broader search across brands or platforms use research_ads. Spends credits.',
|
|
19213
19328
|
inputSchema: {
|
|
19214
19329
|
companyName: z.string().optional().describe('the advertiser name, e.g. "Liquid Death" (company / brand / name are read as this too)'),
|
|
19215
19330
|
domain: z.string().optional().describe('the advertiser domain, e.g. liquiddeath.com — a full website URL works (url / website are read as this too). Pass companyName OR domain'),
|
|
@@ -19857,7 +19972,7 @@ function memoryNoteVerdict(text) {
|
|
|
19857
19972
|
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
19858
19973
|
socialHandle: z.string().optional().describe('a social handle to draft from (influencers/creators) — pair with platform'),
|
|
19859
19974
|
platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
|
|
19860
|
-
save: z.boolean().optional().describe('save as the workspace’s brand (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to overwrite, false to never save'),
|
|
19975
|
+
save: z.boolean().optional().describe('save as the workspace’s brand (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to REPLACE the saved brand profile (the drafted fields overwrite the saved ones), false to never save'),
|
|
19861
19976
|
},
|
|
19862
19977
|
outputSchema: {
|
|
19863
19978
|
name: z.string().optional().describe('the drafted brand name — VERIFY it matches the brand the user meant'),
|
|
@@ -19870,7 +19985,10 @@ function memoryNoteVerdict(text) {
|
|
|
19870
19985
|
products: z.any().optional().describe('the detected products'),
|
|
19871
19986
|
productImages: z.array(z.any()).optional().describe('product photo URLs'),
|
|
19872
19987
|
},
|
|
19873
|
-
|
|
19988
|
+
// destructiveHint TRUE (2026-09-30, OpenAI's plugin scan): `save:true` replaces the saved brand profile field by
|
|
19989
|
+
// field, which a user can only undo by re-entering what was there. The default path never overwrites (it saves
|
|
19990
|
+
// only into an empty workspace) — but the doc's rule is "even in only select modes", so the hint is honest.
|
|
19991
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
19874
19992
|
}, wrap(async ({ save, ...a }) => {
|
|
19875
19993
|
const d = await apiPost('/api/brand/draft', a);
|
|
19876
19994
|
const p = d.profile || d;
|
|
@@ -20031,7 +20149,7 @@ function memoryNoteVerdict(text) {
|
|
|
20031
20149
|
title: 'Make an insert clip',
|
|
20032
20150
|
description: 'A reaction picture (image, or video from videoStart) with its sound, cut to when the sound lands (or seconds): a 1080x1920 clip for post_edit join.',
|
|
20033
20151
|
inputSchema: { image: z.string().optional(), video: z.string().optional(), videoStart: z.number().optional(), sound: z.string(), soundStart: z.number().optional(), soundEnd: z.number().optional(), seconds: z.number().optional() },
|
|
20034
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20152
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20035
20153
|
}, wrap(async (a) => {
|
|
20036
20154
|
const d = await apiPost('/api/video/insert', a);
|
|
20037
20155
|
return okVideo(`Insert clip (${d.seconds}s): ${abs(d.video)}\n${d.howToUse || ''}`, { ...d, url: abs(d.video), video: abs(d.video) });
|
|
@@ -20065,7 +20183,7 @@ function memoryNoteVerdict(text) {
|
|
|
20065
20183
|
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master -> 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
|
|
20066
20184
|
inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
|
|
20067
20185
|
outputSchema: { ...JOB_OUT },
|
|
20068
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20186
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20069
20187
|
}, wrap(async ({ video, aspectRatio }) => {
|
|
20070
20188
|
const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
|
|
20071
20189
|
return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
|
|
@@ -20076,7 +20194,7 @@ function memoryNoteVerdict(text) {
|
|
|
20076
20194
|
description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL. Two engines: the default ('standard') is the safe precision upscaler; engine:'flux' is the FLUX 3 video upscaler (1080p/2K/4K) with an optional mode:'creative' detail-enhancement pass — pick it when the user asks for the FLUX upscaler or wants added detail rather than a faithful enlargement.",
|
|
20077
20195
|
inputSchema: { video: z.string().describe('the source video URL'), engine: z.enum(['standard', 'flux']).optional().describe("default 'standard', the precision upscaler. 'flux' = the FLUX 3 video upscaler"), mode: z.enum(['precise', 'creative']).optional().describe("FLUX only — 'creative' turns on its detail-enhancement pass; default precise") },
|
|
20078
20196
|
outputSchema: { ...JOB_OUT },
|
|
20079
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20197
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20080
20198
|
}, wrap(async ({ video, engine, mode }) => {
|
|
20081
20199
|
const r = await renderJob('upscale', { video, factor: 2, ...(engine ? { engine } : {}), ...(mode ? { mode } : {}) }, engine === 'flux' ? 'Upscale 2x · FLUX' : 'Upscale 2x');
|
|
20082
20200
|
return okVideo(`Upscaled video: ${r.url}`, r);
|
|
@@ -20104,7 +20222,7 @@ function memoryNoteVerdict(text) {
|
|
|
20104
20222
|
interactionId: z.string().optional().describe('OPTIONAL: the interactionId an earlier Gemini Omni render or edit returned; the edit continues that clip on the same Omni model. If it cannot run, the video editor edits it and the reply says so.'),
|
|
20105
20223
|
},
|
|
20106
20224
|
outputSchema: { ...JOB_OUT },
|
|
20107
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20225
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20108
20226
|
}, wrap(async ({ video, instruction, keepAudio, lighting, reference, literal, previewFirstFrame, previewStill, previewAt, elements, interactionId }) => {
|
|
20109
20227
|
const prompt = String(instruction || '').trim();
|
|
20110
20228
|
if (!prompt) return { content: [{ type: 'text', text: 'Say what to change — edit_video needs an instruction.' }], isError: true };
|
|
@@ -20143,7 +20261,7 @@ function memoryNoteVerdict(text) {
|
|
|
20143
20261
|
dryRun: z.boolean().optional().describe('true = return the plan and the quote, render nothing'),
|
|
20144
20262
|
},
|
|
20145
20263
|
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
|
|
20146
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20264
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20147
20265
|
}, wrap(async ({ video, count, change, axes, notes, regions, dryRun }) => {
|
|
20148
20266
|
const src = String(video || '').trim();
|
|
20149
20267
|
if (!/^https?:\/\//.test(src)) return { content: [{ type: 'text', text: 'Pass the source video as a URL — a previous render, a job result, or an entry from list_library.' }], isError: true };
|
|
@@ -20181,7 +20299,7 @@ function memoryNoteVerdict(text) {
|
|
|
20181
20299
|
hooks: z.array(z.object({ url: z.string().optional(), prompt: z.string().optional(), mechanic: z.string().optional(), start: z.number().optional().describe('url: skip the video’s own first seconds'), cutAt: z.number().optional().describe('url: override the found payoff cut') })).optional().describe('your own openings, one version each'),
|
|
20182
20300
|
},
|
|
20183
20301
|
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), hookSeconds: z.number().nullable().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
|
|
20184
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20302
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20185
20303
|
}, wrap(async ({ video, count, notes, hookSeconds, resolution, productImage, useBrand, plan, dryRun, hooks }) => {
|
|
20186
20304
|
const src = String(video || '').trim();
|
|
20187
20305
|
if (!src) return { content: [{ type: 'text', text: 'Pass the finished video as a URL: a previous render, a job result, an entry from list_library, or an upload_file link.' }], isError: true };
|
|
@@ -20211,7 +20329,7 @@ function memoryNoteVerdict(text) {
|
|
|
20211
20329
|
voice: z.string().optional().describe("optional target voice preset, e.g. 'Aria' (warm female) or 'George' (confident male). Defaults to a voice matching the source speaker's register."),
|
|
20212
20330
|
},
|
|
20213
20331
|
outputSchema: { ...JOB_OUT },
|
|
20214
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20332
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20215
20333
|
}, wrap(async ({ video, language, script, voice }) => {
|
|
20216
20334
|
// Forward `script` ONLY when the caller actually supplied one. Sending '' used to hit the worker's
|
|
20217
20335
|
// empty-script guard, so the documented {video, language} call could never succeed.
|
|
@@ -20227,7 +20345,7 @@ function memoryNoteVerdict(text) {
|
|
|
20227
20345
|
voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read). A saved VOICE CLONE of the user's own voice counts as a preset here — name it the way it is saved on their cast"),
|
|
20228
20346
|
},
|
|
20229
20347
|
outputSchema: { ...JOB_OUT },
|
|
20230
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20348
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20231
20349
|
}, wrap(async ({ video, voice }) => {
|
|
20232
20350
|
const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
|
|
20233
20351
|
return okVideo(`Voice-swapped video: ${r.url}`, r);
|
|
@@ -20244,7 +20362,7 @@ function memoryNoteVerdict(text) {
|
|
|
20244
20362
|
tier: z.enum(['pro', 'standard']).optional().describe("'pro' (default): 1080p and real hand-object interaction. 'standard': about 25% fewer credits and faster, but 720p, and it tends to mime a held object with empty hands. hermoso_capabilities lists the exact credits for both"),
|
|
20245
20363
|
},
|
|
20246
20364
|
outputSchema: { ...JOB_OUT },
|
|
20247
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20365
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20248
20366
|
}, wrap(async ({ image, video, prompt = '', orientation = 'video', tier }) => {
|
|
20249
20367
|
const r = await renderJob('motion', { image, video, prompt, orientation, ...(tier ? { tier } : {}) }, 'Motion recast');
|
|
20250
20368
|
return okVideo(`Recast video: ${r.url}`, r);
|
|
@@ -20315,7 +20433,7 @@ function memoryNoteVerdict(text) {
|
|
|
20315
20433
|
variants: z.array(z.any()).optional().describe('the distinct ad angles ({name, hook, headline, visual brief})'),
|
|
20316
20434
|
angles: z.array(z.any()).optional().describe('alternate key the planner may return the variants under'),
|
|
20317
20435
|
},
|
|
20318
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20436
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20319
20437
|
}, wrap(async ({ brand, product, count = 6, language }) => {
|
|
20320
20438
|
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
|
|
20321
20439
|
const d = await apiPost('/api/batch/plan', { brand: brandObj, product, count, language: language || '' });
|
|
@@ -20417,7 +20535,7 @@ function memoryNoteVerdict(text) {
|
|
|
20417
20535
|
slots: z.any().optional().describe('the filled slot map (layout elements swapped to your brand)'),
|
|
20418
20536
|
residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
|
|
20419
20537
|
},
|
|
20420
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20538
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20421
20539
|
}, cloneStaticHandler = wrap(async ({ imageUrl, brandId }) => {
|
|
20422
20540
|
const brand = await activeBrand(brandId);
|
|
20423
20541
|
if (!brand) throw new Error('No saved brand to clone for — onboard one with draft_brand, or pass a brandId from list_brands.');
|
|
@@ -20442,7 +20560,7 @@ function memoryNoteVerdict(text) {
|
|
|
20442
20560
|
slots: z.any().optional().describe('the filled slot map (layout elements swapped to your brand)'),
|
|
20443
20561
|
residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
|
|
20444
20562
|
},
|
|
20445
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
20563
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20446
20564
|
}, (a, extra) => cloneStaticHandler(a, extra)); // its OWN function object: the registry stamps each handler with one tool name, and a shared one ended up named remix_static for both
|
|
20447
20565
|
|
|
20448
20566
|
server.registerTool('mine_angles', {
|
|
@@ -20462,7 +20580,9 @@ function memoryNoteVerdict(text) {
|
|
|
20462
20580
|
droppedQuotes: z.number().optional().describe('quotes the model returned that were not verbatim in the material, dropped'),
|
|
20463
20581
|
note: z.string().optional().describe('what was read, what was dropped, and the next step; or why no angles were returned'),
|
|
20464
20582
|
},
|
|
20465
|
-
|
|
20583
|
+
// NOT read-only (2026-09-30, OpenAI's plugin scan): passing brandId switches this key's ACTIVE BRAND for every later
|
|
20584
|
+
// call, exactly like use_brand (activeBrand pins it server-side). That is a persisted, user-visible state change.
|
|
20585
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
20466
20586
|
}, wrap(async ({ brandId, reviews, reviewsUrl, useOwnReviewsOnly }) => {
|
|
20467
20587
|
const brand = await activeBrand(brandId);
|
|
20468
20588
|
const own = {};
|
|
@@ -20497,7 +20617,8 @@ function memoryNoteVerdict(text) {
|
|
|
20497
20617
|
summary: z.string().optional().describe('a readable rundown of the saved product photos'),
|
|
20498
20618
|
photos: z.array(z.any()).optional().describe('the saved photos ({url, label, …})'),
|
|
20499
20619
|
},
|
|
20500
|
-
|
|
20620
|
+
// NOT read-only, for the same reason as mine_angles (2026-09-30): brandId pins this key's active brand.
|
|
20621
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
20501
20622
|
}, wrap(async ({ brandId }) => {
|
|
20502
20623
|
const brand = await activeBrand(brandId);
|
|
20503
20624
|
const d = await apiPost('/api/product/photos', { brand: brand || {} });
|
|
@@ -20752,5 +20873,6 @@ function memoryNoteVerdict(text) {
|
|
|
20752
20873
|
const d = await apiPost('/api/posts/backfill', { channel: a.channel, ...(a.confirm ? { confirm: true } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.accountRef ? { accountRef: a.accountRef } : {}) });
|
|
20753
20874
|
return ok(d.note || `${d.dryRun ? 'Dry run' : 'Imported'} on ${a.channel}.`, d);
|
|
20754
20875
|
}));
|
|
20876
|
+
registerWidgetHostAliases(rawServer, ctx, opts);
|
|
20755
20877
|
installHeldToolCalls(rawServer, ctx);
|
|
20756
20878
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.365",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 864 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",
|