hermoso 0.1.532 → 0.1.534

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
@@ -22,25 +22,53 @@ campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a
22
22
  - **Posting.** Post and schedule to your own social channels, then read each post's results. The channel list
23
23
  is under *What it connects to* below.
24
24
 
25
- ## Set up in Claude, ChatGPT or Cursor
25
+ ## Set up in ChatGPT, Claude, Grok Bot or a coding agent
26
26
 
27
- **Claude (claude.ai, Claude Desktop):** Customize → Connectors → Add → *Add custom connector*, paste the URL
28
- below, choose **Sign in now** under Authentication, then Add and Connect.
27
+ **ChatGPT:** one click. **[Add to ChatGPT](https://chatgpt.com/plugins/plugin_asdk_app_6abde87d08048191b2ee7cda76721749)** opens Hermoso in ChatGPT's app
28
+ directory. Press Add and sign in with your Hermoso account, no key needed. Then, in a new ChatGPT chat:
29
+ `@Hermoso set up my brand from my website and make my first ad`
30
+
31
+ **Claude (claude.ai, Claude Desktop):** one click. **[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)**
32
+ opens Claude's *Add custom connector* dialog with Hermoso already filled in. Press Continue, choose **Sign in now**
33
+ if Claude asks about Authentication, then sign in with your Hermoso account.
34
+
35
+ **Grok Bot:** it cannot open the Hermoso sign-in page, so it signs in with a key. **[Create a key](https://app.hermoso.ai/?tab=api-keys)**,
36
+ then **[add the Hermoso Marketing Bot](https://x.ai/bot/THQqogUKuhn1whWU0hrmb)** and paste the key when it asks.
37
+
38
+ **Claude Code, Cursor, Codex and other coding agents:** one prompt. Paste this into a new chat and the agent installs
39
+ Hermoso, opens the sign-in and makes your first ad (this is Claude Code's install line; each agent's own is in the app
40
+ under MCP & CLI, and in [Install in one command](#install-in-one-command)):
41
+
42
+ ```text
43
+ Set up Hermoso for me. It is an AI marketing tool (hermoso.ai) that researches ads, makes image and video ads and posts to my social accounts.
44
+ 1. Install it by running: claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
45
+ If the new skills only load after a restart, tell me, and use npx meanwhile.
46
+ 2. Sign me in by running: npx -y hermoso auth login
47
+ It opens my browser and I will approve the connection there. If this machine has no browser, ask me for a Hermoso API key and run npx -y hermoso auth login --token <key> instead.
48
+ 3. Once I am signed in, do one small task to show it works: tell me my Hermoso credit balance and which profile is saved.
49
+ 4. Then set up my brand from my website and make my first ad.
50
+ ```
51
+
52
+ <details>
53
+ <summary>Set it up by hand</summary>
54
+
55
+ **Claude by hand:** Customize → Connectors → Add → *Add custom connector*, paste the URL below, choose **Sign in now** under
56
+ Authentication, then Add and Connect. On a Team or Enterprise plan an Owner adds it for the organization first.
29
57
 
30
58
  ```text
31
59
  https://app.hermoso.ai/mcp
32
60
  ```
33
61
 
62
+ **ChatGPT by hand:** turn on Developer mode once (Settings → Security and login), then Plugins → + → Create app →
63
+ Create MCP App. Paste `https://app.hermoso.ai/mcp` as the Server URL, choose OAuth, leave the advanced OAuth
64
+ settings empty, and press Create.
65
+
34
66
  **Claude Code:**
35
67
 
36
68
  ```bash
37
69
  claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
38
70
  ```
39
71
 
40
- **ChatGPT:** turn on Developer mode once (Settings → Security and login), then Plugins → + → Create app →
41
- Create MCP App. Paste `https://app.hermoso.ai/mcp` as the Server URL, choose OAuth, leave the advanced OAuth
42
- settings empty, and press Create.
43
-
44
72
  **Cursor:**
45
73
 
46
74
  ```bash
@@ -53,6 +81,8 @@ Or add the MCP server to Cursor's `mcp.json` after `npx -y hermoso auth login`:
53
81
  { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"] } } }
54
82
  ```
55
83
 
84
+ </details>
85
+
56
86
  Codex, Gemini CLI, Grok and the rest are in [Install in one command](#install-in-one-command).
57
87
 
58
88
  ## Examples
@@ -67,7 +97,7 @@ Ask in your own words. The agent picks the tools:
67
97
  - "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
68
98
  - "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
69
99
 
70
- **1199 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
100
+ **1233 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
71
101
  catalog with exact per-render credit costs plus the full capability map.
72
102
 
73
103
  **No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
@@ -242,7 +272,10 @@ the routes.
242
272
 
243
273
  ## Instant: the hosted Claude.ai connector
244
274
 
245
- Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
275
+ **[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)** opens Claude with the connector already filled in: press Continue, sign in with your
276
+ Hermoso account, and you are done.
277
+
278
+ By hand: paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
246
279
  press Continue, choose **Sign in now** under Authentication (Claude's detector pre-selects "No sign-in" because our
247
280
  discovery handshake is open, and with that your first request comes back "Authentication required"), press Add and
248
281
  Connect, approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
@@ -304,7 +337,7 @@ block entirely if you signed in above; it is there for CI, where the process can
304
337
 
305
338
  Then ask your agent: *“Generate an image ad with Hermoso.”*
306
339
 
307
- ### What the 1199 tools cover
340
+ ### What the 1233 tools cover
308
341
 
309
342
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
310
343
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -1,7 +1,8 @@
1
1
  // GENERATED by tools/chatgpt-roster-rank.mjs. Do not edit by hand: re-run the tool (it ranks by the committed usage in
2
2
  // tools/lib/chatgpt-roster-usage.json). The provider families ChatGPT lists, best first; a family is all or nothing.
3
3
  // The core (every tool that needs no connection) is always listed and is not in these lists. Cap: CHATGPT_TOOL_CAP
4
- // (950) in mcp/tools.mjs, under ChatGPT's own 1,000-tool limit.
4
+ // (1000) in mcp/tools.mjs, under ChatGPT's own 1,000-tool limit; the families get that minus the room
5
+ // the ChatGPT extension tools take (CHATGPT_EXT_TOOL_ROOM), because those tools are on the same list.
5
6
  export const CHATGPT_ROSTER_FAMILIES = Object.freeze([
6
7
  'google_drive', // long tail, pinned (plugin skills)
7
8
  'intercom', // long tail, pinned (plugin skills)
@@ -49,4 +50,4 @@ export const CHATGPT_ROSTER_LEFT_OUT = Object.freeze([
49
50
  'vercel', // long tail
50
51
  'whop', // long tail
51
52
  ]);
52
- export const CHATGPT_ROSTER_META = Object.freeze({ cap: 950, usageCollectedAt: "2026-11-03T20:25:36.816Z" });
53
+ export const CHATGPT_ROSTER_META = Object.freeze({ cap: 1000, usageCollectedAt: "2026-11-03T20:25:36.816Z" });
package/mcp/client.mjs CHANGED
@@ -124,6 +124,10 @@ function headers(extra = {}) {
124
124
  // reliable discriminator here. Forgeable, and harmlessly so: the worst a caller achieves is under-reporting
125
125
  // their own activity, which is why this decides bookkeeping and nothing else.
126
126
  if (ctx) h['x-hermoso-inproc'] = '1';
127
+ // WHICH HOST THE PERSON IS IN, FOR A REFUSAL ONLY (2026-11-12, lib/buy-page.mjs). A hosted session remembers its client
128
+ // (mcp/http.mjs rememberedClient); a ChatGPT one is named here so the server can mail the out-of-credits note in ~10
129
+ // minutes, since nothing can be bought inside ChatGPT. Forgeable and harmless: the worst a caller does is hurry its own email.
130
+ if (ctx && /openai|chatgpt/i.test(String(ctx.client || ''))) h['x-hermoso-host'] = 'chatgpt';
127
131
  // A PRICE QUOTE AND THE AGENT'S STATED GOAL (2026-11-11, spend-guard.mjs): get_cost / expected_credits run the tool's own
128
132
  // handler with every request marked as a quote (the server stops each spending point and prices it); `context` rides
129
133
  // as one redacted, percent-encoded sentence the server puts on the ledger row of whatever the call bills.
@@ -175,7 +179,7 @@ async function unwrap(res, { read = false } = {}) {
175
179
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
176
180
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
177
181
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
178
- throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}) }); // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
182
+ throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}), ...(body?.buy && typeof body.buy === 'object' && body.buy.pack ? { buy: body.buy } : {}) }); // `buy` (2026-11-12): the two priced doors a balance refusal carries (server buyDoorsFor): a pack and a plan, each with its price and a checkout link // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
179
183
  }
180
184
  // WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
181
185
  // sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
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, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups } from './tools.mjs';
19
+ import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups, widgetHostScope, withFirstRunLine, firstRunOffers } from './tools.mjs';
20
20
  import { chatgptExtRequested } from './openai-extensions.mjs';
21
21
  import { mcpCtx, connectedProviders } from './client.mjs';
22
22
 
@@ -177,7 +177,36 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
177
177
  const rpcCancelledIdKeys = (body) => (Array.isArray(body) ? body : [body]).filter((m) => m && m.method === 'notifications/cancelled' && m.params && m.params.requestId !== undefined && m.params.requestId !== null).map((m) => rpcIdKey(m.params.requestId));
178
178
  const GET_STREAM_MAX = Math.max(1, Number(process.env.MCP_GET_STREAM_MAX || 64));
179
179
  let openGetStreams = 0;
180
- const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS });
180
+ // ── A STOPPING OR RETIRED INSTANCE HANDS ITS STREAMS BACK, IT DOES NOT LET THEM BREAK (2026-11-13) ──────────────────
181
+ // On SIGTERM, and when this revision has lost the traffic (server.js, lib/pump-handover.mjs retiredVerdict), every open
182
+ // GET stream is ended CLEANLY with an SSE `retry:` field first, the spec's own reconnection hint, so the client reopens
183
+ // it at once and the reopen lands on the serving revision (a stream cut by process exit reads as a network error, and
184
+ // some clients back off). While draining, a new GET stream is answered 503 + Retry-After (it is optional, the spec lets a
185
+ // server refuse it) and POSTs are untouched: a tool call in flight is never cut by this, and a session id this instance
186
+ // holds keeps working until its own idle expiry. A session the client reopens elsewhere gets the 404 → re-initialize
187
+ // path below, which is how every deploy already ends a session. undrain() reverses it (a rollback brings the traffic back).
188
+ const openGetResponses = new Set();
189
+ let draining = '';
190
+ const RECONNECT_HINT_MS = 1000;
191
+ function drain(why = 'draining') {
192
+ if (!draining) console.error(`[mcp-remote] draining (${why}): ending ${openGetResponses.size} notification stream(s) with a reconnect hint`);
193
+ draining = String(why || 'draining');
194
+ let ended = 0;
195
+ for (const res of [...openGetResponses]) {
196
+ try {
197
+ if (!res.writableEnded && !res.destroyed) {
198
+ // An open 200 on a GET here IS the SDK's SSE stream (its headers go through writeHead, so getHeader cannot see them;
199
+ // any refusal it answers is JSON and already ended). Events are written whole, so a field between them is valid SSE.
200
+ if (res.headersSent && res.statusCode === 200) res.write(`retry: ${RECONNECT_HINT_MS}\n\n`);
201
+ res.end(); ended++;
202
+ }
203
+ } catch {}
204
+ openGetResponses.delete(res);
205
+ }
206
+ return { ended, sessions: sessions.size };
207
+ }
208
+ function undrain() { if (draining) console.error(`[mcp-remote] no longer draining (was: ${draining})`); draining = ''; }
209
+ const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS, draining: draining || null });
181
210
  // THE 401 IS THE ONLY THING A STUCK AGENT EVER READS, so it carries the way out (2026-09-04). It used to be the
182
211
  // three words `Authentication required`, and that is exactly how far a real user got: a Cursor-based client
183
212
  // (Grok Bot) registered fine, listed all 160 tools, and then every call 401'd while its own consent card never
@@ -408,6 +437,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
408
437
  // A widget host (ChatGPT, whose plugin scanner reads this anonymous roster) gets its own instructions — see
409
438
  // MCP_INSTRUCTIONS_WIDGET. The SAME host decision as the roster below, so the two can never disagree.
410
439
  const widgetHost = isWidgetHost(clientInfoOf(req.body), req);
440
+ scope = widgetHostScope(scope, widgetHost); // the URL our app handed ChatGPT users is the plain URL there (LEGACY_WIDGET_CONNECT_GROUPS)
411
441
  const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
412
442
  // `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
413
443
  // as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
@@ -457,7 +487,18 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
457
487
  // doing anything"). Only a tools/call is somebody doing something, so only a tools/call stamps. Auth itself is
458
488
  // unchanged in both branches: this decides bookkeeping, never access.
459
489
  const didWork = methodsOf(req.body).includes('tools/call');
460
- const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => null) : null;
490
+ // A KEY STORE THAT COULD NOT BE READ IS NOT A REJECTED KEY (2026-11-11). During the database restart at 22:41 a
491
+ // Codex session's valid hmk_ key was answered with the 401 challenge, logged as "bearer token rejected"; a 401 tells
492
+ // the client to drop its credential and sign in again, for an outage of a few seconds. verifyBearer answers null for
493
+ // an unknown or revoked key and THROWS when the lookup itself fails, so a throw is a 503 the client retries. Same rule
494
+ // as the refresh path in mcp/oauth.mjs ("a failed read is not a revocation").
495
+ let storeDown = false;
496
+ const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => { storeDown = true; return null; }) : null;
497
+ if (storeDown) {
498
+ connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'key store read failed', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
499
+ res.setHeader('Retry-After', '5');
500
+ return res.status(503).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Hermoso is briefly unavailable. Please retry in a few seconds; your connection is fine.' }, id: null });
501
+ }
461
502
  if (!user && token) connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'bearer token rejected', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
462
503
  if (!user) {
463
504
  // No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
@@ -476,13 +517,18 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
476
517
 
477
518
  // The stream cap, BEFORE any session work: a burst of reconnects must be refused at the door, not after allocating.
478
519
  if (req.method === 'GET') {
520
+ if (draining) {
521
+ res.set('Retry-After', '1');
522
+ return res.status(503).json({ error: 'This instance is handing over to a new one: reopen the stream (it reconnects to the new instance). Tool calls (POST) are unaffected.' });
523
+ }
479
524
  if (openGetStreams >= GET_STREAM_MAX) {
480
525
  res.set('Retry-After', '30');
481
526
  return res.status(503).json({ error: `This instance already holds ${openGetStreams} open notification streams — retry the stream in 30s. Tool calls (POST) are unaffected.` });
482
527
  }
483
528
  openGetStreams++;
484
529
  const t = setTimeout(() => { try { res.end(); } catch {} }, GET_STREAM_MS); if (t && typeof t.unref === 'function') t.unref();
485
- res.once('close', () => { openGetStreams = Math.max(0, openGetStreams - 1); clearTimeout(t); });
530
+ openGetResponses.add(res);
531
+ res.once('close', () => { openGetStreams = Math.max(0, openGetStreams - 1); clearTimeout(t); openGetResponses.delete(res); });
486
532
  }
487
533
 
488
534
  const sid = req.headers['mcp-session-id'];
@@ -520,8 +566,12 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
520
566
  // Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
521
567
  // answered here so we never build a server whose only job would be to reject the request.
522
568
  if (!init) return needSession(res);
523
- const scope = scopeFor(req, res);
524
- if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
569
+ const scope0 = scopeFor(req, res);
570
+ if (scope0 === false) return; // unknown group — already answered 400, and nothing was allocated
571
+ // On ChatGPT the URL our own app handed out (seven named groups, no ads or analytics) is read as the plain URL:
572
+ // a widget host has no executor, so a group it does not list is a feature it does not have. See
573
+ // LEGACY_WIDGET_CONNECT_GROUPS in tools.mjs. The same host decision as `widgetHost` below.
574
+ const scope = widgetHostScope(scope0, isWidgetHost(entry?.client || clientInfoOf(req.body), req));
525
575
  sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
526
576
  // ── WHAT THIS CALLER CAN ACTUALLY USE (2026-08-26) ────────────────────────────────────────────────────────
527
577
  // One free store read, on the ONE request per session that mints the roster, so the tools we advertise are
@@ -539,7 +589,20 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
539
589
  const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
540
590
  // ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
541
591
  const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
542
- const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
592
+ // AN ACCOUNT THAT HAS MADE NOTHING YET, ON EVERY HOST (2026-10-19 for ChatGPT; every host since 2026-11-12). Read once
593
+ // per session, bounded so a slow read can never hold the connect: null (no guidance) is the answer to every failure.
594
+ // Read BEFORE the server is built, because it changes what initialize says: a fresh account's `instructions` open with
595
+ // one line saying how to start (withFirstRunLine in tools.mjs), which a host reads before the user types anything. The
596
+ // first successful tool reply of the session then carries the same next ask (firstRunNote), for hosts that ignore
597
+ // instructions. `offer` is what this session's own instructions can name, so a focused app or the directory cage is
598
+ // never told to offer a tool it does not list.
599
+ const baseInstructions = instructionsFor(scope, { widgetHost });
600
+ let firstRun = null;
601
+ if (typeof firstRunFor === 'function' && user.accountId) {
602
+ try { firstRun = await Promise.race([Promise.resolve(firstRunFor({ accountId: user.accountId })).catch(() => null), new Promise((r) => { const t = setTimeout(() => r(null), 1500); if (t && typeof t.unref === 'function') t.unref(); })]); } catch { firstRun = null; }
603
+ }
604
+ firstRun = firstRun && typeof firstRun === 'object' && firstRun.fresh === true ? { ...firstRun, offer: firstRunOffers(baseInstructions) } : null;
605
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: withFirstRunLine(baseInstructions, firstRun) });
543
606
  registerTools(server, { only: scope.groups, directory: scope.directory || false, app: scope.app || null, connectors, widgetHost, hosted: true, ext: extAsked(req, scope), 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
544
607
  const transport = new StreamableHTTPServerTransport({
545
608
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
@@ -550,15 +613,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
550
613
  // WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
551
614
  // request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
552
615
  // tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
553
- // A CHATGPT ACCOUNT THAT HAS MADE NOTHING YET (2026-10-19). Read once per session, ChatGPT hosts only, bounded so a slow
554
- // read can never hold the connect: null (no note) is the answer to every failure. The first successful tool reply of
555
- // the session then offers one concrete next ask (firstRunNote in tools.mjs). Most ChatGPT signups since the
556
- // 10-15 publish had made nothing, and a live "make me a video ad" chat on a connected account never called us.
557
- let firstRun = null;
558
- if (widgetHost && typeof firstRunFor === 'function' && user.accountId) {
559
- try { firstRun = await Promise.race([Promise.resolve(firstRunFor({ accountId: user.accountId })).catch(() => null), new Promise((r) => { const t = setTimeout(() => r(null), 1500); if (t && typeof t.unref === 'function') t.unref(); })]); } catch { firstRun = null; }
560
- }
561
- entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true, firstRun: firstRun && typeof firstRun === 'object' ? firstRun : null };
616
+ // The fresh-account read (firstRun) happened above, before the server was built; the session keeps it so the first
617
+ // successful tool reply can carry the same next ask.
618
+ entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true, firstRun };
562
619
  if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
563
620
  // LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
564
621
  // ever read is a guess. One line per session (not per call) so a new host identifies itself once and
@@ -663,6 +720,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
663
720
 
664
721
  console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
665
722
  app.locals.mcpStreamStats = streamStats; // for the check and the admin read; the return value stays `true` as every caller asserts
723
+ app.locals.mcpDrain = drain; app.locals.mcpUndrain = undrain; // server.js: on SIGTERM and while this revision is retired
666
724
  return true;
667
725
  }
668
726
 
package/mcp/tool-cost.mjs CHANGED
@@ -46,6 +46,9 @@ export const CREATE_FREE_READS = Object.freeze([
46
46
  // The product catalog (2026-10-06): a store read or write, and a product page read with curl and parsed locally (no
47
47
  // model; POST /api/products answers creditsUsed: 0). Each says "Free" / "0 credits" in its own description.
48
48
  'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product',
49
+ // Story films (2026-11-11): the catalog, one card's recipe and starting a run (the outline) are store reads and writes;
50
+ // each says "Free" in its own description. advance_story_film runs the paid stages and stays a model tool.
51
+ 'list_story_cards', 'get_story_card', 'start_story_film',
49
52
  ]);
50
53
  // Reads inside `research`. `find_competitors` says "0 credits" in its own description (it is the discovery model,
51
54
  // billed to us, not a paid ad-data call); the watch tools only write and read a stored preference — the weekly
@@ -81,10 +81,29 @@ export function videoChoiceDraftCall(tool, d) {
81
81
  // then try to upsell the plans/credits?"). The agent says plainly that the account does not have enough credits and offers
82
82
  // credits and plans; the image and the light draft stay listed LAST, for a user who asks for something cheaper themselves,
83
83
  // never as the agent's own pick. A video-plus-static ask is priced whole (`needCredits`, `alsoImage`) and neither half is made.
84
+ // ── THE TWO PRICED DOORS, AS AN AGENT RELAYS THEM (2026-11-12) ──────────────────────────────────────────────────────
85
+ // A balance refusal carries `buy` (the server's buyDoorsFor): a monthly plan and a credit pack sized to the ask, each with
86
+ // its price, its credits and a checkout link that needs no sign-in, plus the same offer as ready-made `lines`. The old
87
+ // answer explained how buy_credits works and left the agent to make a second call before the user saw a price. Now the
88
+ // prices and the links are in the refusal itself. Never on a widget host (ChatGPT may not link to a checkout): the
89
+ // callers pass `buy` only when the host is not one. Pure, no imports: the twin rule.
90
+ export function buyDoorsOf(e, widgetHost) {
91
+ const b = e && e.buy;
92
+ return !widgetHost && b && typeof b === 'object' && b.pack && Array.isArray(b.lines) && b.lines.length ? b : null;
93
+ }
94
+ export function buyDoorsText(buy, tool) {
95
+ if (!buy) return '';
96
+ const lines = buy.lines.map((x) => String(x)).filter(Boolean);
97
+ const doors = lines.filter((l) => l.startsWith('•'));
98
+ return [buy.plan ? `Recommend the ${buy.plan.name} plan to the user, with its link; a one-time credit pack is the other option:` : 'Give the user this way to continue, with its link:', ...doors,
99
+ `Each link opens a secure checkout page for this account, no sign-in needed. Once they have paid, call ${tool || 'the same tool'} again with the same arguments. A card already on file: buy_credits({pack:"${buy.pack.id}"}) quotes it and charges on confirm.`].join('\n');
100
+ }
101
+ export const buyDoorsHints = (buy, tool) => (buy ? [{ do: `${buy.plan ? `recommend the ${buy.plan.name} plan from this message with its price and link, and mention the one-time pack as the other option` : 'show the user the pack from this message with its price and link'}; after they pay, call ${tool || 'the same tool'} again with the same arguments`, why: 'the account cannot cover this call; each link is a checkout for this account that needs no sign-in, and nothing was charged' }] : []);
84
102
  export function videoChoiceHints(tool, choice, opts = {}) {
85
103
  const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
86
104
  const out = opts.widgetHost
87
105
  ? [{ do: `tell the user they do not have enough credits: it needs ${c.short ?? '?'} more than the account has; they add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }]
106
+ : opts.buy ? buyDoorsHints(opts.buy, tool)
88
107
  : [
89
108
  { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short; a pack covers it, then the same call makes it as asked` },
90
109
  { do: 'upgrade_plan({})', why: 'a monthly plan (Starter, Pro, Studio) includes monthly credits and no markup on models' },
@@ -108,6 +127,7 @@ export function videoChoiceText(tool, choice, opts = {}) {
108
127
  // feature needs more credits and link an informational page, never a checkout. `o.topup.url` is a checkout link.
109
128
  ...(opts.widgetHost
110
129
  ? [` 1. The user can add credits or pick a plan in their Hermoso account (${ACCOUNT_INFO_URL}); then the same call makes it as asked. Nothing can be bought from here.`]
130
+ : opts.buy ? [buyDoorsText(opts.buy, tool)]
111
131
  : [` 1. 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 it goes ahead as asked.`,
112
132
  ` 2. Or a monthly plan: upgrade_plan({}) lists them; Starter, Pro and Studio include monthly credits and no markup on models, so credits go further.`]),
113
133
  ];
@@ -156,8 +176,28 @@ export function withWidgetResultNote(tool, r, widgetHost) {
156
176
  // buy_credits / upgrade_plan (WITHHELD_FROM_WIDGET_HOSTS), so its out-of-credits reply must not name them either, and
157
177
  // the server's own sentence ("Upgrade or top up to keep going.") is rewritten to say where the account is managed.
158
178
  // The link is the pricing page, which describes the options and starts nothing. Pure, no imports — the twin rule.
159
- export const ACCOUNT_INFO_URL = 'https://hermoso.ai/pricing';
160
- 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.`;
179
+ // TAGGED src=chatgpt-credits (2026-11-12, lib/buy-page.mjs): the pricing page shows a visitor with this tag one button, "Buy credits
180
+ // or a plan", to the app's buy page. Still the informational page ChatGPT's rules allow, and the only link this host is ever
181
+ // given; the buy page itself is never linked from here. The tag rides only the refusal text, never a tool definition.
182
+ export const ACCOUNT_INFO_URL = 'https://hermoso.ai/pricing?src=chatgpt-credits';
183
+ // …AND THE PLAN TIER THE ACCOUNT IS ON, AND NOTHING ELSE (2026-11-12, owner: "shouldnt we only show plans higher than the plan
184
+ // they are on?"). The pricing page is static and cannot see the account, so the link says which tier it is on (&from=starter)
185
+ // and the page hides the plans at or below it. A tier is not personal data; no id, email or balance ever rides the link. An
186
+ // unknown or free tier is the plain link (every plan is above Free). Pure; tools.mjs reads the tier once per refusal.
187
+ export const PLAN_TIERS = Object.freeze(['free', 'basic', 'starter', 'pro', 'studio']);
188
+ export function accountInfoUrl(tier) {
189
+ const t = String(tier || '').trim().toLowerCase();
190
+ return PLAN_TIERS.includes(t) && t !== 'free' ? `${ACCOUNT_INFO_URL}&from=${t}` : ACCOUNT_INFO_URL;
191
+ }
192
+ /** A refusal written for a widget host, with the tier put on its info link (text and hints). The text is otherwise untouched. */
193
+ export function withInfoTier(text, hints, tier) {
194
+ const u = accountInfoUrl(tier);
195
+ if (u === ACCOUNT_INFO_URL) return { text, hints };
196
+ const sw = (x) => String(x).split(ACCOUNT_INFO_URL).join(u);
197
+ return { text: sw(text), hints: (hints || []).map((h) => (h && typeof h === 'object' ? { ...h, ...(typeof h.do === 'string' ? { do: sw(h.do) } : {}), ...(typeof h.why === 'string' ? { why: sw(h.why) } : {}) } : h)) };
198
+ }
199
+ export const ACCOUNT_CREDITS_LINE = `The user can add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}, then ask again.`; // the neutral fact every widget-host refusal carries (owner, 2026-11-12): what to do and where, never a pitch
200
+ export const CREDITS_LOW_NOTE = `The Hermoso account does not have enough credits for this. Tell the user plainly. ${ACCOUNT_CREDITS_LINE} Nothing can be bought from here.`;
161
201
  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|monthly\s+plans\s+have\s+no\s+markup\s+on\s+models)\b[^.!\n]*[.!]?/gi; // the monthly-plan upsell (2026-10-20) is a promotion too: a widget host (ChatGPT) never hears it
162
202
  // ── FREE CREDITS NOT CLAIMED YET (2026-10-25, ChatGPT funnel) ─────────────────────────────────────────────────────
163
203
  // An account whose welcome credits were never added (the one-time check that a person is behind it did not pass
@@ -165,8 +205,73 @@ const UPSELL_SENTENCE_RE = /\s*(?:please\s+)?(?:upgrade(?:\s+your\s+plan)?\s+or\
165
205
  // said on every host, ChatGPT included. The server marks the refusal (meta.welcomeClaim = {credits, url}).
166
206
  export const welcomeClaimSentence = (w) => `This account has not received its ${Number(w?.credits) || 0} free credits yet. The user opens ${w?.url} once in a browser (one click, no sign-in and no card) and they arrive in a few seconds after a quick check that a person is there.`;
167
207
  export const welcomeClaimHint = (w) => ({ do: `ask the user to open ${w?.url} once in a browser (one click, no sign-in) to receive the ${Number(w?.credits) || 0} free credits; then run the same call again`, why: 'the account never received its free credits: they wait behind a one-time check that a person is behind the account, which runs in the browser' });
168
- export function neutralCreditsText(msg) {
169
- const base = String(msg ?? '').replace(UPSELL_SENTENCE_RE, '').replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
170
- return `${base}${base ? '\n' : ''}${CREDITS_LOW_NOTE}`;
208
+ export function neutralCreditsText(msg, nums = {}) {
209
+ const base = stripPlanPromo(String(msg ?? '').replace(UPSELL_SENTENCE_RE, '')).replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
210
+ // The numbers, when the server's sentence did not carry them ("You’re out of credits." does not): read from the refusal's
211
+ // structured block (e.buy.need / .balance), never parsed from prose. A count, not a price.
212
+ const need = Math.round(Number(nums?.need) || 0), bal = Number(nums?.balance);
213
+ const numLine = need > 0 && !/\bneeds?\b[^.\n]*\d/i.test(base) ? `This needs about ${need} credits${Number.isFinite(bal) && nums?.balance != null ? ` and the account has ${Math.max(0, Math.floor(bal))}` : ''}.` : '';
214
+ return [base, numLine, CREDITS_LOW_NOTE].filter(Boolean).join('\n');
215
+ }
216
+ // ── NO PLAN PITCH ON A WIDGET HOST, WHEREVER IT CAME FROM (2026-11-12) ────────────────────────────────────────────
217
+ // Measured on production: a ChatGPT out-of-credits refusal carried no price and no checkout link and STILL ended
218
+ // "Starter, Pro and Studio have no markup on models, so your credits go twice as far." — the server's withPlanUpsell
219
+ // sentence (server.js PLAN_UPSELL_LINE, or its "on one of those plans this needs about N" form), which UPSELL_SENTENCE_RE
220
+ // above did not know. OpenAI's app guidelines: apps "may not … promote upgrades". So the rule is a SENTENCE filter, not a
221
+ // list of known sentences: on a widget host any sentence (or list item) that pitches a plan, names a purchase tool, a
222
+ // price, a checkout or a top-up is dropped, and the neutral line (ACCOUNT_CREDITS_LINE) says what to do and where.
223
+ // Two strengths. PLAN_PROMO_RE is for text that is ABOUT credits or the account (a credits refusal, the capability map),
224
+ // where "$" or "upgrade" can only be selling. PLAN_PITCH_RE is narrow enough for any reply on a widget host (a budget
225
+ // error says "$5", ad copy says "go further"): only our own pitch phrases and the purchase tools' names.
226
+ export const PLAN_PROMO_RE = /\b(?:buy_credits|upgrade_plan|set_auto_reload|billing_status)\b|\bupgrad(?:e|es|ed|ing)\b|no markup|twice as far|\bgo(?:es)? further\b|checkout|\btop[- ]?ups?\b|\$\s?\d|WHY HERMOSO|\bcredit packs?\b|\bper month\b|\/mo\b/i;
227
+ export const PLAN_PITCH_RE = /no markup on (?:image, video and voice )?models|credits? go(?:es)? (?:twice as far|further)|\bStarter, Pro and Studio\b|\b(?:buy_credits|upgrade_plan|set_auto_reload|billing_status)\b/i;
228
+ // Split `s` at every `sep` that sits outside parentheses/brackets, so "(a, b; c)" is never cut in half.
229
+ function splitTop(s, sep) {
230
+ const out = []; let depth = 0, from = 0;
231
+ for (let i = 0; i < s.length; i++) {
232
+ const ch = s[i];
233
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
234
+ else if ((ch === ')' || ch === ']' || ch === '}') && depth > 0) depth--;
235
+ else if (depth === 0) {
236
+ if (sep === 'sentence') { if (/[.!?]/.test(ch) && /\s/.test(s[i + 1] || '')) { out.push(s.slice(from, i + 1)); from = i + 1; } }
237
+ else if (s.startsWith(sep, i)) { out.push(s.slice(from, i)); from = i + sep.length; i += sep.length - 1; }
238
+ }
239
+ }
240
+ out.push(s.slice(from));
241
+ return out;
242
+ }
243
+ const PROMO_LEVELS = [['sentence', ' '], ['; ', '; '], [' · ', ' · ']];
244
+ export function stripPlanPromo(text, re = PLAN_PROMO_RE) {
245
+ const prune = (s, levels) => {
246
+ if (!re.test(s)) return s;
247
+ for (let k = 0; k < levels.length; k++) {
248
+ const [sep, join] = levels[k];
249
+ const parts = splitTop(s, sep);
250
+ if (parts.length < 2) continue;
251
+ return parts.map((p) => prune(sep === 'sentence' ? p.trim() : p, levels.slice(k + 1))).filter((p) => p.trim()).join(join);
252
+ }
253
+ return '';
254
+ };
255
+ const src = String(text ?? '');
256
+ if (!re.test(src)) return src;
257
+ return src.split('\n').map((line) => (re.test(line) ? { line: prune(line, PROMO_LEVELS), cut: true } : { line, cut: false }))
258
+ .filter((x) => !(x.cut && !x.line.trim())).map((x) => x.line).join('\n');
259
+ }
260
+ // A reply on a widget host, with every pitch sentence out of its text blocks (a JSON block is data and left whole) and
261
+ // out of its hints. The result object is copied, never edited in place.
262
+ export function withoutPlanPitch(r, re = PLAN_PITCH_RE) {
263
+ if (!r || typeof r !== 'object') return r;
264
+ let out = r;
265
+ if (Array.isArray(r.content) && r.content.some((b) => b && b.type === 'text' && typeof b.text === 'string' && re.test(b.text) && !/^\s*[[{]/.test(b.text))) {
266
+ out = { ...out, content: r.content.map((b) => (b && b.type === 'text' && typeof b.text === 'string' && !/^\s*[[{]/.test(b.text) ? { ...b, text: stripPlanPromo(b.text, re) } : b)) };
267
+ }
268
+ const h = out._meta && Array.isArray(out._meta[HINTS_KEY]) ? out._meta[HINTS_KEY] : null;
269
+ if (h && h.some((x) => PLAN_PROMO_RE.test(`${x.do} ${x.why}`))) {
270
+ const kept = h.filter((x) => !PLAN_PROMO_RE.test(`${x.do} ${x.why}`));
271
+ const meta = { ...out._meta };
272
+ if (kept.length) meta[HINTS_KEY] = kept; else delete meta[HINTS_KEY];
273
+ out = { ...out, _meta: meta };
274
+ }
275
+ return out;
171
276
  }
172
- 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' }];
277
+ export const creditsLowHints = () => [{ do: `tell the user the account does not have enough credits for this; they can add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}, then ask again`, why: 'this host does not offer purchases, so nothing is bought from here' }];