hermoso 0.1.310 → 0.1.330
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 +3 -3
- package/bin/hermoso.mjs +1 -1
- package/mcp/client.mjs +34 -4
- package/mcp/http.mjs +39 -3
- package/mcp/roster-scope.mjs +8 -0
- package/mcp/tools.mjs +190 -47
- package/package.json +2 -2
- package/skills/hermoso-marketing/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**864 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
@@ -175,7 +175,7 @@ claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermos
|
|
|
175
175
|
Codex, Gemini CLI and the rest are in [Install in one command](#install-in-one-command).
|
|
176
176
|
3. **Sign in once.** `npx -y hermoso auth login` opens your browser; your agent also runs it by itself the first
|
|
177
177
|
time it needs Hermoso. On a machine with no browser, use `npx -y hermoso auth login --token hmk_…` with a key
|
|
178
|
-
from the **MCP & CLI** tab, under **Terminal & API keys
|
|
178
|
+
from the **MCP & CLI** tab, under **Terminal & API keys** (Settings → API keys, or https://app.hermoso.ai/?tab=api-keys, go straight there).
|
|
179
179
|
4. **Ask for what you want**, in your normal prompts. Claude Code picks the Hermoso skill for the job and runs the
|
|
180
180
|
commands. You type none of them.
|
|
181
181
|
|
|
@@ -217,7 +217,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
217
217
|
|
|
218
218
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
219
219
|
|
|
220
|
-
### What the
|
|
220
|
+
### What the 864 tools cover
|
|
221
221
|
|
|
222
222
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
223
223
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/bin/hermoso.mjs
CHANGED
|
@@ -77,7 +77,7 @@ async function main() {
|
|
|
77
77
|
if (isLocal) { await saveConfig({ apiBase, token: '', profile }); return console.log(`✓ Local dev — no auth required. API: ${apiBase}`); }
|
|
78
78
|
if (sub === 'login') { // browser sign-in: spin a loopback server, open the app's /?cliauth page, receive a minted key
|
|
79
79
|
const key = await browserLogin(apiBase);
|
|
80
|
-
if (!key) return die(`Sign-in didn’t complete. Re-run "hermoso auth login", or paste a key: hermoso auth login --token <key> (create one
|
|
80
|
+
if (!key) return die(`Sign-in didn’t complete. Re-run "hermoso auth login", or paste a key: hermoso auth login --token <key> (create one at https://app.hermoso.ai/?tab=api-keys, the MCP & CLI tab under Terminal & API keys).`);
|
|
81
81
|
await saveConfig({ apiBase, token: key, profile });
|
|
82
82
|
return console.log(`✓ Signed in — key stored (~/.hermoso/config.json). API: ${apiBase}`);
|
|
83
83
|
}
|
package/mcp/client.mjs
CHANGED
|
@@ -330,7 +330,9 @@ export async function connectedProviders() {
|
|
|
330
330
|
// connectLink: the app deep link for THIS brand with a {provider} slot, so a tool held back for a missing
|
|
331
331
|
// connection can hand the user the one-click link instead of only naming the Connectors page.
|
|
332
332
|
const connectLink = typeof r?.connectLink === 'string' && r.connectLink.includes('{provider}') ? r.connectLink : '';
|
|
333
|
-
|
|
333
|
+
// withheld: tools the server does not offer right now (roster-scope.mjs toolWithheldByServer). Absent = none.
|
|
334
|
+
const withheld = new Set((Array.isArray(r?.withheld) ? r.withheld : []).filter((n) => typeof n === 'string' && n));
|
|
335
|
+
return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true, offered, gated, connectLink, withheld };
|
|
334
336
|
} catch { return { connected: new Set(), readOk: false }; }
|
|
335
337
|
}
|
|
336
338
|
// Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
|
|
@@ -429,10 +431,38 @@ export function jobWaitMs(requested, cap) {
|
|
|
429
431
|
return Math.min(cap, Math.floor(n));
|
|
430
432
|
}
|
|
431
433
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
+
// A 404 IN THE SECONDS AFTER A SUBMIT IS NOT "NO SUCH JOB" (journey QA 2026-09-25). edit_video queued a render on the
|
|
435
|
+
// revision a deploy was retiring; the poll 3s later reached the new revision, which had never heard of it, and the tool
|
|
436
|
+
// answered "Error: No such job" about a real render that held credits. The server now reads through to the durable job
|
|
437
|
+
// mirror before a 404 (lib/job-readthrough.mjs), and this is the client half: inside a short grace from the submit a
|
|
438
|
+
// 404 (and a 502/503/504, which is what a rollover looks like from outside) is retried; past it a 404 is final and is
|
|
439
|
+
// said in words, never as the bare route error. PURE, so tools/job-readthrough-check.mjs runs it.
|
|
440
|
+
export const JOB_MISS_GRACE_MS = 30_000;
|
|
441
|
+
export function pollMissVerdict(status, { startedAt, now = Date.now(), deadline = Infinity, graceMs = JOB_MISS_GRACE_MS } = {}) {
|
|
442
|
+
const st = Number(status);
|
|
443
|
+
if (st === 404) return (now - startedAt < graceMs && now < deadline) ? 'retry' : 'final';
|
|
444
|
+
if ((st === 502 || st === 503 || st === 504) && now < deadline) return 'retry';
|
|
445
|
+
return 'throw';
|
|
446
|
+
}
|
|
447
|
+
export function jobMissMessage(id) {
|
|
448
|
+
return `Hermoso has no record of job ${id} on this workspace, after checking the live queue and the durable job store for ${Math.round(JOB_MISS_GRACE_MS / 1000)}s. `
|
|
449
|
+
+ 'If it was just submitted, the server that took it was replaced before it wrote the job down, so it cannot be followed from here. A render lost that way is not charged: the credits held for it are released automatically. '
|
|
450
|
+
+ 'Call list_jobs to see this workspace\'s recent jobs. Do not re-run the render on the strength of this message alone.';
|
|
451
|
+
}
|
|
452
|
+
export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick, getJobFn = getJob } = {}) {
|
|
453
|
+
const startedAt = Date.now();
|
|
454
|
+
const deadline = startedAt + timeoutMs;
|
|
434
455
|
for (;;) {
|
|
435
|
-
|
|
456
|
+
let job;
|
|
457
|
+
try { job = await getJobFn(id); }
|
|
458
|
+
catch (e) {
|
|
459
|
+
const v = pollMissVerdict(e?.status, { startedAt, now: Date.now(), deadline });
|
|
460
|
+
if (v === 'final') throw Object.assign(new Error(jobMissMessage(id)), { status: 404, _viaApi: true, _jobMissing: true });
|
|
461
|
+
if (v === 'throw') throw e;
|
|
462
|
+
await new Promise(r => setTimeout(r, Math.min(intervalMs, Math.max(50, deadline - Date.now()))));
|
|
463
|
+
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
436
466
|
onTick?.(job);
|
|
437
467
|
if (job.status === 'done') return { job, result: jobResult(job) };
|
|
438
468
|
// A FAILED JOB HAS ALREADY BEEN RECORDED BY THE SERVER (2026-09-21). The job runner files the worker's real error in
|
package/mcp/http.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { randomUUID, createHash } from 'node:crypto';
|
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
18
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
|
-
import { registerTools, MCP_INSTRUCTIONS, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
|
|
19
|
+
import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
|
|
20
20
|
import { mcpCtx, connectedProviders } from './client.mjs';
|
|
21
21
|
|
|
22
22
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
@@ -310,9 +310,37 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
310
310
|
// Directories that list tools with a sign-in (Smithery, Glama's inspector, OpenAI's and Anthropic's reviews) use
|
|
311
311
|
// OAuth already. SIGNIN_UPFRONT_CLIENTS still wins over a bot-looking UA.
|
|
312
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
|
+
// ── A DIRECTORY HEALTH CHECK THAT DOES NOT SAY WHAT IT IS IN ITS UA, RECOGNISED BY NAME + UA TOGETHER (2026-09-25) ──
|
|
314
|
+
// Glama's connector health check (glama.ai/mcp/connectors/io.github.hermoso-ai/hermoso) sends UA "node" — bare,
|
|
315
|
+
// exactly what Gemini CLI sends — so the UA rule above cannot see it, and since the inversion it has been 401 every
|
|
316
|
+
// ~20 min and the listing reads "Status: Unhealthy". Matched on the evidence, not a guess: its "Last Tested" stamp
|
|
317
|
+
// moved 01:05 → 01:24 UTC on 2026-09-26 in step with our tokenless `node` POSTs at 01:05:36 and 01:24:22, and the
|
|
318
|
+
// one pre-inversion [mcp-anon] line on that cadence names clientInfo "mcpdd" (2026-09-25T13:34:47). It keeps the
|
|
319
|
+
// preview only when BOTH match, so neither Gemini CLI ("node" + "mcp-test-client") nor any other host naming
|
|
320
|
+
// itself differently changes state. Glama's user connections are configured as OAuth on their side (the listing's
|
|
321
|
+
// inspector links carry authType "oauth"), so they never decide sign-in from this answer.
|
|
322
|
+
// NOT here, deliberately: python-httpx2 (sends a foreign bearer "ligh…" on retry, unidentified — it is not the
|
|
323
|
+
// Glama check, whatever an earlier note said), docker-mcp-gateway and Go-http-client (a gateway carrying real
|
|
324
|
+
// users), and mcp.so's "Fetch tools" (UA node, name unknown; it already falls through to our OAuth). The refused
|
|
325
|
+
// line below logs the clientInfo of every challenged tokenless initialize, so the next entry comes from evidence.
|
|
326
|
+
const DIRECTORY_PROBES = [{ client: 'mcpdd', ua: /^node$/i }]; // Glama connector health check
|
|
327
|
+
const directoryProbe = (req) => {
|
|
328
|
+
const name = clientInfoOf(req.body); const ua = String(req.headers['user-agent'] || '');
|
|
329
|
+
return !!name && DIRECTORY_PROBES.some((p) => p.client === name && p.ua.test(ua));
|
|
330
|
+
};
|
|
331
|
+
// THE PREVIEW HAS TO OUTLAST THE INITIALIZE. Only the initialize carries clientInfo; the tools/list that follows is
|
|
332
|
+
// a bare tokenless POST from UA "node", indistinguishable from anyone. So a preview granted by NAME answers the
|
|
333
|
+
// initialize with an Mcp-Session-Id marked as a preview, the SDK client echoes it, and a tokenless follow-up that
|
|
334
|
+
// carries it keeps the preview. It unlocks nothing `?auth=none` does not already give anyone (the public tool list;
|
|
335
|
+
// tools/call is refused on this path whatever the header says), and no host that connects users ever holds one,
|
|
336
|
+
// because we only ever issue it to a name on the list above. The anonymous transport is stateless, so the SDK
|
|
337
|
+
// ignores the id rather than validating it.
|
|
338
|
+
const ANON_PREVIEW_SID_PREFIX = 'anonpreview_';
|
|
339
|
+
const heldPreviewSession = (req) => String(req.headers['mcp-session-id'] || '').startsWith(ANON_PREVIEW_SID_PREFIX);
|
|
313
340
|
const anonPreviewAllowed = (req) => {
|
|
314
341
|
if (String(req.query?.auth || '').toLowerCase() === 'none') return true;
|
|
315
342
|
if (signinUpfront(req)) return false;
|
|
343
|
+
if (directoryProbe(req) || heldPreviewSession(req)) return true;
|
|
316
344
|
return ANON_PREVIEW_UA_RE.test(String(req.headers['user-agent'] || ''));
|
|
317
345
|
};
|
|
318
346
|
const isAllPreauth = (body) => {
|
|
@@ -347,7 +375,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
347
375
|
const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
|
|
348
376
|
|
|
349
377
|
async function serveAnonDiscovery(req, res, scope) {
|
|
350
|
-
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions:
|
|
378
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope) });
|
|
351
379
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
352
380
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
353
381
|
// only the authenticated path would leave both tools listed in the submission.
|
|
@@ -372,6 +400,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
372
400
|
try { const m = (Array.isArray(req.body) ? req.body : [req.body]).find((x) => x && x.method === 'initialize'); const ci = m?.params?.clientInfo || {}; console.error(`[mcp-anon] initialize client=${JSON.stringify(String(ci.name || '').slice(0, 64))} v=${JSON.stringify(String(ci.version || '').slice(0, 24))} proto=${String(m?.params?.protocolVersion || '').slice(0, 16)} ua=${JSON.stringify(String(req.headers['user-agent'] || '').slice(0, 80))} src=${srcOf(req) || '-'}`); } catch {}
|
|
373
401
|
}
|
|
374
402
|
if (typeof onAnonDiscovery === 'function' && methodsOf(req.body).includes('tools/list')) { try { onAnonDiscovery({ client: clientInfoOf(req.body), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req) }); } catch {} }
|
|
403
|
+
// A preview granted by name carries its marker into the follow-ups (see ANON_PREVIEW_SID_PREFIX); set before the
|
|
404
|
+
// transport writes, and Node merges it into the SDK's own headers.
|
|
405
|
+
if (methodsOf(req.body).includes('initialize') && directoryProbe(req)) res.setHeader('mcp-session-id', ANON_PREVIEW_SID_PREFIX + randomUUID().replace(/-/g, ''));
|
|
375
406
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
376
407
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
377
408
|
await server.connect(transport);
|
|
@@ -400,6 +431,11 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
400
431
|
if (scope === false) return; // unknown group — already answered 400
|
|
401
432
|
return serveAnonDiscovery(req, res, scope).catch(() => { try { challenge(res); } catch {} });
|
|
402
433
|
}
|
|
434
|
+
// The refused half of the [mcp-anon] line: the clientInfo a challenged tokenless initialize named, so a
|
|
435
|
+
// directory check that starts failing can be identified from the log instead of guessed from its UA.
|
|
436
|
+
if (!token && req.method === 'POST' && methodsOf(req.body).includes('initialize')) {
|
|
437
|
+
try { console.error(`[mcp-anon] refused initialize client=${JSON.stringify(clientInfoOf(req.body))} ua=${JSON.stringify(String(req.headers['user-agent'] || '').slice(0, 80))} src=${srcOf(req) || '-'}`); } catch {}
|
|
438
|
+
}
|
|
403
439
|
return challenge(res);
|
|
404
440
|
}
|
|
405
441
|
|
|
@@ -460,7 +496,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
460
496
|
// OPEN with the full roster, so the whole change would be silently inert. Never throws; see
|
|
461
497
|
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
462
498
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
463
|
-
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions:
|
|
499
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope) });
|
|
464
500
|
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
|
|
465
501
|
const transport = new StreamableHTTPServerTransport({
|
|
466
502
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -156,7 +156,15 @@ export const metaAlternativeNote = (name) => INSTAGRAM_LOGIN_TOOLS.has(String(na
|
|
|
156
156
|
: isWhatsAppTool(name)
|
|
157
157
|
? ' A WhatsApp Business Account the business already manages is ticked on the "meta" connection\'s assets step; the "whatsapp" connection sets up a number the business does not have yet. Either one is enough for this tool.'
|
|
158
158
|
: '';
|
|
159
|
+
// ── A TOOL THE SERVER WITHHOLDS FROM EVERY ROSTER (2026-09-27, owner: "why do we even allow access to creator
|
|
160
|
+
// marketplace if we can't even access it ourselves?"). `withheld` rides the same /api/connectors/providers read as the
|
|
161
|
+
// connected set: the server names the tools whose capability it does not offer right now (today: the Instagram
|
|
162
|
+
// creator marketplace trio while META_CREATOR_MARKETPLACE_SCOPE is off). ONE server flag decides it, so flipping the
|
|
163
|
+
// env var lights every surface back up with no code change. Absent or unreadable = nothing withheld (fail open, like
|
|
164
|
+
// every other read of this set); a withheld tool is held like a connector-gated one and answered as 'unavailable'.
|
|
165
|
+
export function toolWithheldByServer(name, conn) { return !!(conn && conn.withheld instanceof Set && conn.withheld.has(String(name || ''))); }
|
|
159
166
|
export function toolHeldBackByConnectors(name, conn) {
|
|
167
|
+
if (toolWithheldByServer(name, conn)) return true; // the server says this capability is not offered now
|
|
160
168
|
if (!conn || !conn.readOk) return false; // property 1 — fail OPEN on an unreadable store
|
|
161
169
|
const p = toolProvider(name);
|
|
162
170
|
if (p === null) return false; // property 2 — unmapped is never held back
|