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 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
- **863 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
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 863 tools cover
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 in the app on the MCP & CLI tab, under Terminal & API keys).`);
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
- return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true, offered, gated, connectLink };
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
- export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick } = {}) {
433
- const deadline = Date.now() + timeoutMs;
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
- const job = await getJob(id);
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: MCP_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: MCP_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).
@@ -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