hermoso 0.1.25 → 0.1.33

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
- **247 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **262 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
  ## Instant: the hosted Claude.ai connector
@@ -46,7 +46,7 @@ Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
46
46
 
47
47
  Then ask your agent: *“Generate an image ad with Hermoso.”*
48
48
 
49
- ### What the 247 tools cover
49
+ ### What the 262 tools cover
50
50
 
51
51
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
52
52
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -55,6 +55,9 @@ social (`search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`,
55
55
 
56
56
  **Create** — `draft_brand` → `plan_ad` → `render_ad` (the Studio quality pipeline: composited text, clean speech,
57
57
  music, brand end card), or `generate_image` / `generate_video` / `generate_avatar` (UGC creators + lip-sync).
58
+ The workspace's **saved cast** is reusable: `list_creators` returns every saved creator with their portrait url,
59
+ `save_creator` adds one, `delete_creator` drops one — re-pass a portrait to `generate_avatar` / `generate_video` /
60
+ `recast_motion` and the SAME person stars in every ad, instead of a new face each render.
58
61
  Also `make_template_ad` (native HTML ad formats), `make_explainer`, `product_sizzle`, `make_thumbnail`,
59
62
  `remix_static`, `recast_motion`, `reframe_video`, `upscale_video`, `dub_video`, `change_voice`, `finish_video`,
60
63
  `fix_beat`, `stitch_video`, `clip_video`, `post_edit`, plus `plan_variations` + `score_ad` to fan out and rank.
package/mcp/client.mjs CHANGED
@@ -34,8 +34,13 @@ export const ENV_PREFIX = 'HERMOSO';
34
34
 
35
35
  function headers(extra = {}) {
36
36
  const ctx = mcpCtx.getStore();
37
- const prof = ctx?.profile || PROFILE; // omitted when unpinned so the key's saved brand wins server-side
38
- const own = ctx?.owner || OWNER; // the wire name is x-hermoso-owner on BOTH twins it is the server's header, not a brand
37
+ // A HOSTED-CONNECTOR request (mcp/http.mjs) is a DIFFERENT TENANT from the process serving it, so its ctx is the
38
+ // ONLY scope it may carry: falling through to this process's HERMOSO_PROFILE / HERMOSO_OWNER would scope one
39
+ // customer's tool call to whatever workspace the SERVER's environment happens to name — a cross-tenant leak that
40
+ // is invisible because it succeeds. stdio/CLI keeps the env fallback: there the process and the caller are the
41
+ // same person. Presence of the ctx store IS "remote" (see isRemote below).
42
+ const prof = ctx ? (ctx.profile || '') : PROFILE; // omitted when unpinned so the key's saved brand wins server-side
43
+ const own = ctx ? (ctx.owner || '') : OWNER; // the wire name is x-hermoso-owner on BOTH twins — it is the server's header, not a brand
39
44
  const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-hermoso-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...extra };
40
45
  const tok = ctx?.token || TOKEN;
41
46
  if (tok) h.Authorization = `Bearer ${tok}`;
@@ -69,6 +74,14 @@ export async function apiPost(p, body = {}) {
69
74
  return unwrap(res);
70
75
  }
71
76
 
77
+ // PATCH — a PARTIAL update, and the distinction is load-bearing on the schedule routes: an omitted key means
78
+ // "leave that field exactly as it was", so sending a whole object where a patch was meant would blank the fields
79
+ // the caller never mentioned. Only ever send the keys that are actually changing.
80
+ export async function apiPatch(p, body = {}) {
81
+ const res = await fetch(`${API_BASE}${p}`, { method: 'PATCH', headers: headers(), body: JSON.stringify(body) });
82
+ return unwrap(res);
83
+ }
84
+
72
85
  export async function apiDelete(p) {
73
86
  const res = await fetch(`${API_BASE}${p}`, { method: 'DELETE', headers: headers() });
74
87
  return unwrap(res);
@@ -82,6 +95,42 @@ export async function apiPut(p, body = {}) {
82
95
  // A hosted-connector call (via mcp/http.mjs) has an mcpCtx store; local stdio/CLI does not. Used to REFUSE local-path
83
96
  // file reads on the hosted connector (it runs on the SERVER host, not the user's machine — an LFI/exfil vector).
84
97
  export const isRemote = () => !!mcpCtx.getStore();
98
+
99
+ // ── WHICH WORKSPACE'S STORE KEYS THIS CALL WRITES ──────────────────────────────────────────────────────────────
100
+ // The suffix synced store keys carry (`` = the bare/anchor keys, `<clientSlug>` = a sub-brand). It comes from the
101
+ // SERVER (`GET /api/workspace` → resolveWs), never from this process's environment, because on the hosted twin the
102
+ // caller and the process are different tenants: HERMOSO_PROFILE names whatever workspace the SERVER's env happens to
103
+ // mention, which for a hosted connector is nothing at all. That is how `use_brand "Client X"` kept reading and
104
+ // WRITING the anchor brand (live 2026-08-01), and how draft_brand's save overwrote the default brand's profile.
105
+ let _suffixMemo = null; // stdio/CLI only: one process = one caller, so a module-level memo is honest here
106
+ async function fetchStoreSuffix() {
107
+ const w = await apiGet('/api/workspace'); // throws on failure — see storeSuffix()
108
+ if (!w || typeof w.storeSuffix !== 'string') throw new Error('Could not resolve this workspace.');
109
+ return w.storeSuffix;
110
+ }
111
+ export async function storeSuffix() {
112
+ const ctx = mcpCtx.getStore();
113
+ if (ctx) {
114
+ // HOSTED: memoize on the PER-REQUEST ctx object only. A module-level cache here would serve one customer's
115
+ // workspace suffix to the next caller on the same process — a silent cross-tenant write.
116
+ if (ctx._storeSuffix === undefined) ctx._storeSuffix = await fetchStoreSuffix();
117
+ return ctx._storeSuffix;
118
+ }
119
+ if (_suffixMemo === null) {
120
+ // stdio/CLI: the env pin is a REAL local authority (the process and the caller are the same person), so it is
121
+ // the fallback when an older server has no /api/workspace. A hosted call has no such fallback and must throw:
122
+ // guessing `bare` on a failed read is how the anchor brand gets overwritten, and a FAILED READ IS NOT EMPTY.
123
+ try { _suffixMemo = await fetchStoreSuffix(); }
124
+ catch { _suffixMemo = PROFILE && PROFILE !== 'default' ? PROFILE : ''; }
125
+ }
126
+ return _suffixMemo;
127
+ }
128
+ // use_brand / create_brand re-pin the key SERVER-SIDE, so the memo must not outlive the switch.
129
+ export function forgetWorkspaceScope() {
130
+ _suffixMemo = null;
131
+ const ctx = mcpCtx.getStore();
132
+ if (ctx) delete ctx._storeSuffix;
133
+ }
85
134
  // Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
86
135
  // the server reads the raw body. Lets an agent post ARBITRARY user files (not just Hermoso renders).
87
136
  export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
package/mcp/http.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // When the cloud step happens, the remaining work is small and explicit (see ENABLE CHECKLIST at the bottom).
15
15
  // ───────────────────────────────────────────────────────────────────────────────────────────────────────
16
+ import { randomUUID } from 'node:crypto';
16
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
18
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
18
19
  import { registerTools, MCP_INSTRUCTIONS } from './tools.mjs';
@@ -29,39 +30,148 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
29
30
  const BASE = (publicBaseUrl || process.env.HERMOSO_PUBLIC_URL || '').replace(/\/+$/, '');
30
31
 
31
32
  // RFC 9728 protected-resource metadata — tells Claude.ai where to get a token. (Authorization-server metadata
32
- // is served by the auth provider itself, e.g. Firebase/your IdP.)
33
+ // is served by the auth provider itself, e.g. Firebase/your IdP.) Scopes match the AS metadata + minted token
34
+ // (mcp/oauth.mjs): hermoso.research / hermoso.generate.
33
35
  app.get('/.well-known/oauth-protected-resource', (req, res) => res.json({
34
36
  resource: `${BASE}/mcp`,
35
37
  authorization_servers: [process.env.HERMOSO_OAUTH_ISSUER].filter(Boolean),
36
- scopes_supported: ['hermoso.generate', 'hermoso.research'],
38
+ scopes_supported: ['hermoso.research', 'hermoso.generate'],
37
39
  bearer_methods_supported: ['header'],
38
40
  }));
39
41
 
40
42
  // Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
41
- const sessions = new Map(); // mcp-session-id -> { transport, server }
43
+ //
44
+ // ── A SESSION IS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01) ───────────────────────────────────────
45
+ // registerTools() builds 248 tool definitions with their zod schemas: **~36 MB of RETAINED heap per McpServer**
46
+ // (measured, node --expose-gc, 25 instances). This map used to be unbounded and never expired — the only removal
47
+ // was transport.onclose, which never fires for a client that simply goes away. Prod took 238 `initialize`
48
+ // handshakes in 49 minutes (238 × 36 MB = ~8.6 GB) on a 4 GiB, maxScale=1 instance and died of
49
+ // `FATAL ERROR: Reached heap limit` six times in that hour, every crash a full outage.
50
+ //
51
+ // The 400-on-a-session-miss below was the ACCELERANT: a client that cannot re-initialize opens a NEW session
52
+ // instead of reusing its own, so the leak fed itself. Both are fixed here — bound + expire the map, and answer
53
+ // the one status code that obliges a client to re-initialize.
54
+ const SESSION_MAX = Math.max(2, Number(process.env.MCP_SESSION_MAX || 16)); // ~580 MB ceiling for MCP
55
+ const SESSION_IDLE_MS = Math.max(1000, Number(process.env.MCP_SESSION_IDLE_MS || 30 * 60e3)); // 1s floor so the expiry is TESTABLE; a short TTL is merely wasteful now that eviction is recoverable
56
+ const sessions = new Map(); // mcp-session-id -> { transport, server, user, lastSeen } (insertion-ordered = LRU)
42
57
  const challenge = (res) => res.status(401).set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"`).json({ error: 'Authentication required' });
43
58
 
59
+ // Tear a session down for real — dropping the map entry alone would leave the 36 MB McpServer reachable from
60
+ // the transport's own callbacks. Deleting FIRST makes this re-entrant-safe: transport.close() fires onclose,
61
+ // which calls back in here, and the second pass is a no-op.
62
+ function dropSession(id, why) {
63
+ const e = sessions.get(id);
64
+ if (!e) return false;
65
+ sessions.delete(id);
66
+ try { e.transport.close(); } catch {}
67
+ try { e.server.close(); } catch {}
68
+ if (why) console.error(`[mcp-remote] session ${id} closed (${why}); ${sessions.size} live`);
69
+ return true;
70
+ }
71
+ // Idle expiry + hard LRU cap. Evicting a LIVE session is safe now and only now: the evicted client's next call
72
+ // gets the 404 that makes it start a fresh session, instead of the 400 that bricked it forever.
73
+ function sweepSessions() {
74
+ const now = Date.now();
75
+ for (const [id, e] of sessions) if (now - e.lastSeen > SESSION_IDLE_MS) dropSession(id, 'idle');
76
+ while (sessions.size > SESSION_MAX) dropSession(sessions.keys().next().value, 'over cap');
77
+ }
78
+ const sweeper = setInterval(sweepSessions, 60e3);
79
+ sweeper.unref?.(); // never hold the process open for this
80
+
81
+ // MCP spec 2025-06-18, Transports § Session Management: a server that no longer has a session MUST answer 404,
82
+ // and on 404 the client MUST start a new session with a fresh InitializeRequest. 400 carries no such obligation,
83
+ // so a 400 here is permanent death for that connection — which is exactly what every deploy used to do to every
84
+ // hosted customer. -32001 "Session not found" is the SDK's own code for this case; we mint it ourselves because
85
+ // the SDK's branch is unreachable when there is no initialized transport left to compare the id against.
86
+ const sessionGone = (res) => res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null });
87
+ // The SDK's own answer for a non-initialize request that names no session at all. We reply BEFORE constructing
88
+ // anything: building a 36 MB McpServer purely to have it reject the request is what turned a retry storm into
89
+ // an OOM. Status and message are byte-identical to the SDK's, so no client sees a behaviour change.
90
+ const needSession = (res) => res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' }, id: null });
91
+ const hasInitialize = (body) => (Array.isArray(body) ? body : [body]).some((m) => m && m.method === 'initialize');
92
+
93
+ // ── PRE-AUTH DISCOVERY (registry crawlers + evaluating agents) ────────────────────────────────────────────────
94
+ // A tokenless caller may run the ZERO-SPEND MCP handshake — initialize, notifications/initialized, ping, tools/list
95
+ // — so a directory (registry.modelcontextprotocol.io) or an agent deciding whether to connect sees the REAL tool
96
+ // catalog first. Everything that spends (tools/call) stays strictly bearer-gated below: no anonymous spend, ever.
97
+ // Served from a fresh anonymous MCP server per request in the SDK's stateless mode (sessionIdGenerator undefined +
98
+ // JSON responses). Per-request instances are the supported shape — a single shared stateless transport 500s on the
99
+ // second standalone request (initialize and tools/list arrive as separate HTTP calls with no session between them).
100
+ const PREAUTH_METHODS = new Set(['initialize', 'notifications/initialized', 'ping', 'tools/list',
101
+ // Other zero-spend capability lists directory scanners (Smithery/Glama) probe anonymously. The only registered
102
+ // resources are the ChatGPT Apps SDK ui:// widget templates (static HTML, zero spend) — so resources/read is
103
+ // pre-auth too, letting ChatGPT/scanners fetch the templates. tools/call remains strictly bearer-gated.
104
+ 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'triggers/list']);
105
+ const isAllPreauth = (body) => {
106
+ const arr = Array.isArray(body) ? body : [body];
107
+ const methods = arr.map((m) => m && m.method).filter((v) => typeof v === 'string');
108
+ return methods.length > 0 && methods.every((m) => PREAUTH_METHODS.has(m)); // a batch mixing in tools/call is NOT pre-auth
109
+ };
110
+ async function serveAnonDiscovery(req, res) {
111
+ const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
112
+ registerTools(server); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
113
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
114
+ res.on('close', () => { try { transport.close(); server.close(); } catch {} });
115
+ await server.connect(transport);
116
+ await transport.handleRequest(req, res, req.body);
117
+ }
118
+
44
119
  app.all('/mcp', async (req, res) => {
45
- // EVERY call must carry a valid bearer — fail CLOSED (Claude.ai has historically made tokenless probe calls).
46
120
  const auth = req.headers.authorization || '';
47
121
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
48
122
  const user = token ? await verifyBearer(token).catch(() => null) : null;
49
- if (!user) return challenge(res);
123
+ if (!user) {
124
+ // No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
125
+ if (req.method === 'POST' && isAllPreauth(req.body)) return serveAnonDiscovery(req, res).catch(() => { try { challenge(res); } catch {} });
126
+ return challenge(res);
127
+ }
50
128
 
51
129
  const sid = req.headers['mcp-session-id'];
52
- let entry = sid && sessions.get(sid);
130
+ let entry = sid ? sessions.get(sid) : null;
131
+
53
132
  if (!entry) {
133
+ const init = req.method === 'POST' && hasInitialize(req.body);
134
+ // A session id we do not have → 404, and WITHOUT allocating. This is the whole of D1b: 404 is the only status
135
+ // that obliges a client to re-initialize, so a restart, a deploy or an LRU eviction becomes a reconnect
136
+ // instead of a permanently bricked connector. It applies to every method — a stale id is stale for GET and
137
+ // DELETE too. The one exception is an `initialize`, which IS a client starting over: strict in what we send,
138
+ // liberal in what we accept, because the entire point of this fix is that nothing here bricks a client.
139
+ if (sid && !init) return sessionGone(res);
140
+ // Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
141
+ // answered here so we never pay 36 MB to build a server whose only job would be to reject the request.
142
+ if (!init) return needSession(res);
143
+ sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
54
144
  const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
55
145
  registerTools(server); // the SAME tools as stdio — but here every /api call they make carries this user's token
56
146
  const transport = new StreamableHTTPServerTransport({
57
- sessionIdGenerator: () => 'sess_' + Math.random().toString(36).slice(2),
58
- onsessioninitialized: (id) => sessions.set(id, entry),
147
+ // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
148
+ sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
149
+ onsessioninitialized: (id) => { entry.lastSeen = Date.now(); sessions.set(id, entry); sweepSessions(); },
59
150
  });
60
- transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId); };
61
- entry = { transport, server, user };
151
+ transport.onclose = () => { if (transport.sessionId) dropSession(transport.sessionId, 'transport closed'); };
152
+ entry = { transport, server, user, lastSeen: Date.now() };
62
153
  await server.connect(transport);
154
+ // If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
155
+ // objects are otherwise reachable only from this request's still-open response — close them explicitly
156
+ // rather than leaving 36 MB pinned by a dead socket.
157
+ res.on('close', () => { if (!transport.sessionId || !sessions.has(transport.sessionId)) { try { transport.close(); } catch {} try { server.close(); } catch {} } });
158
+ } else {
159
+ // Touch = LRU. Re-inserting moves the key to the end of the Map's insertion order, so the cap evicts the
160
+ // genuinely coldest session rather than the oldest-established one (which is often the most active).
161
+ entry.lastSeen = Date.now();
162
+ sessions.delete(sid); sessions.set(sid, entry);
63
163
  }
64
- await mcpCtx.run({ token }, () => entry.transport.handleRequest(req, res, req.body)); // the caller's bearer rides into every /api call the tools make — spend bills THEIR account
164
+ // The caller's bearer rides into every /api call the tools make — spend bills THEIR account. `remote: true`
165
+ // says what this store IS: a per-request tenant scope on a shared, multi-tenant process. client.mjs treats the
166
+ // presence of this store as the signal to STOP falling back to the process's own HERMOSO_PROFILE / HERMOSO_OWNER,
167
+ // which belong to whoever runs the box, not to whoever is calling.
168
+ //
169
+ // NOTE WHAT IS DELIBERATELY *NOT* HERE: a profile or an owner read off the request. There is nowhere honest to
170
+ // read them FROM — a header on the MCP POST would be caller-supplied, and a shared workspace resolved from a
171
+ // forgeable value is exactly the hole resolveWs exists to close. The workspace a hosted connector acts in is
172
+ // pinned SERVER-SIDE on the agent key (use_brand → /api/keys/brand, membership-checked) and re-authorized by
173
+ // resolveWs on every request, so it resolves identically here and over stdio without this transport naming it.
174
+ await mcpCtx.run({ token, remote: true }, () => entry.transport.handleRequest(req, res, req.body));
65
175
  });
66
176
 
67
177
  console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);