hermoso 0.1.139 → 0.1.141

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
@@ -27,17 +27,32 @@ just download it. Use the one piece you need, or all of it together.
27
27
  Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
28
28
  your Hermoso account, done — the full toolset with your saved brand context, billed to your plan.
29
29
 
30
- ## Quickstart for Claude Code / Cursor / scripts (2 minutes)
30
+ ## Quickstart for Claude Code (one line)
31
31
 
32
32
  1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
33
33
  same ones the web Studio uses.
34
- 2. **Create an agent key**: app.hermoso.ai **Settings Agents & API** Create API key (`hmk_…`).
35
- 3. **Connect** — no clone needed, `npx` runs the published `hermoso` package (Claude Code shown; any MCP client works):
34
+ 2. **Run one line.** Your browser opens once to sign in. Nothing to paste, and no key lands in `.claude.json`:
35
+
36
+ ```bash
37
+ npm install -g hermoso && hermoso auth login && claude mcp add hermoso -- npx -y hermoso mcp
38
+ ```
39
+
40
+ 3. **Ask for what you want**, in your normal prompts. Claude Code reaches for a tool, or runs the `hermoso`
41
+ command in your terminal, whichever the job needs. You type neither.
42
+
43
+ Ad campaign and analytics tools stay out of the tool list until you switch them on with `enable_tools`, which
44
+ keeps it small. On a machine with no browser, sign in with `hermoso auth login --token hmk_…` using a key from
45
+ **Settings → Agents & API**, or skip the sign-in and pass the key to the client instead:
36
46
 
37
47
  ```bash
38
48
  claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
39
49
  ```
40
50
 
51
+ The hosted URL works in Claude Code too, but it is the worse path there and it is worth knowing why:
52
+ `claude mcp add --transport http hermoso https://app.hermoso.ai/mcp` is accepted, and then `claude mcp list`
53
+ reports `! Needs authentication` because the client will not start the OAuth flow by itself — you have to open a
54
+ session, run `/mcp`, find the server and press Authenticate. Measured against Claude Code 2.1.241 on 2026-08-23.
55
+
41
56
  Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
42
57
  learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
43
58
  `plan_ad`/`plan_variations` to use it). Renders bill your Hermoso credits — same prices as the Studio.
@@ -45,13 +60,15 @@ learned memory you set up in the web app apply automatically (`get_brand` shows
45
60
  ## 1. MCP server (stdio) — Claude Code / Cursor / Codex
46
61
 
47
62
  `hermoso mcp` runs a stdio MCP server exposing the full toolset. The published `hermoso` package means no clone —
48
- `npx -y hermoso mcp` fetches and runs it:
63
+ `npx -y hermoso mcp` fetches and runs it. Sign in once with the CLI and no key goes into any client config,
64
+ because `hermoso mcp` reads the bearer `hermoso auth login` stored:
49
65
 
50
66
  ```bash
51
- claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
67
+ npm install -g hermoso && hermoso auth login && claude mcp add hermoso -- npx -y hermoso mcp
52
68
  ```
53
69
 
54
- Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
70
+ Cursor / Codex — sign in the same way, then add to `mcp.json` (Codex uses the TOML equivalent). Drop the `env`
71
+ block entirely if you signed in above; it is there for CI, where the process cannot read your home directory:
55
72
 
56
73
  ```json
57
74
  { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"],
package/mcp/client.mjs CHANGED
@@ -130,6 +130,18 @@ export async function apiPut(p, body = {}) {
130
130
  // file reads on the hosted connector (it runs on the SERVER host, not the user's machine — an LFI/exfil vector).
131
131
  export const isRemote = () => !!mcpCtx.getStore();
132
132
 
133
+ // DOES THIS HOST RENDER OUR WIDGETS ITSELF?
134
+ // ChatGPT (the Apps SDK) draws every finished render in `ui://widget/ad-result.html`, hydrated from
135
+ // `structuredContent`. For that host the inline base64 image block in `content` is not just redundant, it is
136
+ // actively harmful: a finished 1:1 render is ~1 MB of base64, and a result that size arrived in ChatGPT with an
137
+ // EMPTY toolOutput — the card drew its "No media in this result yet" empty state while the model narrated success
138
+ // from the text block. Measured 2026-08-23: structuredContent, outputSchema, the tools/list binding and the widget
139
+ // itself were each verified correct in isolation, and the oversized `content` was the only thing left.
140
+ // Claude has no widget, so it KEEPS the inline block — that is the only reason it renders an image in chat at all.
141
+ // Advisory and fail-open: an unrecognised or absent client behaves exactly as before.
142
+ const WIDGET_HOSTS = /openai|chatgpt/i;
143
+ export const hostRendersWidgets = () => WIDGET_HOSTS.test(mcpCtx.getStore()?.client || '');
144
+
133
145
  // ── WHICH WORKSPACE'S STORE KEYS THIS CALL WRITES ──────────────────────────────────────────────────────────────
134
146
  // The suffix synced store keys carry (`` = the bare/anchor keys, `<clientSlug>` = a sub-brand). It comes from the
135
147
  // SERVER (`GET /api/workspace` → resolveWs), never from this process's environment, because on the hosted twin the
package/mcp/http.mjs CHANGED
@@ -32,12 +32,37 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
32
32
  // RFC 9728 protected-resource metadata — tells Claude.ai where to get a token. (Authorization-server metadata
33
33
  // is served by the auth provider itself, e.g. Firebase/your IdP.) Scopes match the AS metadata + minted token
34
34
  // (mcp/oauth.mjs): hermoso.research / hermoso.generate.
35
- app.get('/.well-known/oauth-protected-resource', (req, res) => res.json({
36
- resource: `${BASE}/mcp`,
35
+ // ── SERVED AT BOTH URL FORMS, FROM ONE HANDLER (2026-08-20) ──────────────────────────────────────────────────
36
+ // The resource identifier is `${BASE}/mcp`, which HAS a path component — so RFC 9728 §3.1 derives its metadata
37
+ // URL by INSERTING the well-known suffix before that path ("any terminating slash (/) following the host
38
+ // component MUST be removed before inserting /.well-known/ and the well-known URI path suffix between the host
39
+ // component and the path"), i.e. /.well-known/oauth-protected-resource/mcp. We served only the ROOT form, and
40
+ // that was not a cosmetic gap — it was a DEAD END for any client that does not use our WWW-Authenticate hint,
41
+ // because RFC 9728 §3.3 applies a DIFFERENT validation rule depending on how the client arrived:
42
+ // • via the `resource_metadata` hint → `resource` MUST equal the URL used to reach the resource server
43
+ // (`${BASE}/mcp`). Our document satisfies this, which is why Claude connects today.
44
+ // • by CONSTRUCTING the well-known URL → `resource` MUST equal the identifier the suffix was inserted into.
45
+ // From the ROOT form that identifier is `${BASE}` (no /mcp) while our document says `${BASE}/mcp`, and the
46
+ // rule is "the data contained in the response MUST NOT be used". So a hint-less client had nowhere to land:
47
+ // the suffixed URL 404'd, and the root URL it fell back to failed validation.
48
+ // Serving BOTH is explicitly sanctioned — the MCP spec's fallback order is suffixed-then-root ("Serve metadata
49
+ // at a well-known URI … either: At the path of the server's MCP endpoint … or At the root"). ONE handler, never
50
+ // a second copy of the document: two copies drift, and a stale one is worse than a 404.
51
+ //
52
+ // DELIBERATELY ASYMMETRIC with /.well-known/oauth-authorization-server (mcp/oauth.mjs), which is NOT aliased:
53
+ // its `issuer` is BASE with NO path component, so RFC 8414 §3.1 puts its metadata at the root URL, and §3.3
54
+ // would force a client to REJECT the identical document served under a /mcp suffix (it would have constructed
55
+ // that URL from issuer identifier `${BASE}/mcp`, which is not what the document says). The 404 there is
56
+ // CORRECT and is what lets a probing client fall through cleanly. Alias it only if `issuer` ever grows a path.
57
+ const MCP_PATH = '/mcp'; // the resource's path component — app.all(MCP_PATH) below
58
+ const protectedResourceMetadata = (req, res) => res.json({
59
+ resource: `${BASE}${MCP_PATH}`,
37
60
  authorization_servers: [process.env.HEIST_OAUTH_ISSUER].filter(Boolean),
38
61
  scopes_supported: ['hermoso.research', 'hermoso.generate'],
39
62
  bearer_methods_supported: ['header'],
40
- }));
63
+ });
64
+ app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
65
+ app.get(`/.well-known/oauth-protected-resource${MCP_PATH}`, protectedResourceMetadata);
41
66
 
42
67
  // Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
43
68
  //
@@ -88,6 +113,16 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
88
113
  // anything: building a 36 MB McpServer purely to have it reject the request is what turned a retry storm into
89
114
  // an OOM. Status and message are byte-identical to the SDK's, so no client sees a behaviour change.
90
115
  const needSession = (res) => res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' }, id: null });
116
+ // The one place the host name is turned into a decision, so the anon and session paths cannot diverge.
117
+ const isWidgetHost = (name) => /openai|chatgpt/i.test(String(name || ''));
118
+ // The client's own name, off the initialize params. Never trusted for anything but presentation.
119
+ const clientInfoOf = (body) => {
120
+ try {
121
+ const msgs = Array.isArray(body) ? body : [body];
122
+ for (const m of msgs) if (m && m.method === 'initialize') return String(m.params?.clientInfo?.name || '').slice(0, 64);
123
+ } catch {}
124
+ return '';
125
+ };
91
126
  const hasInitialize = (body) => (Array.isArray(body) ? body : [body]).some((m) => m && m.method === 'initialize');
92
127
 
93
128
  // ── PRE-AUTH DISCOVERY (registry crawlers + evaluating agents) ────────────────────────────────────────────────
@@ -124,14 +159,17 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
124
159
 
125
160
  async function serveAnonDiscovery(req, res, scope) {
126
161
  const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
127
- registerTools(server, { only: scope?.groups }); // metadata only tools/list never invokes a handler, and tools/call can't reach here
162
+ // `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
163
+ // as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
164
+ // only the authenticated path would leave both tools listed in the submission.
165
+ registerTools(server, { only: scope?.groups, widgetHost: isWidgetHost(clientInfoOf(req.body)) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
128
166
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
129
167
  res.on('close', () => { try { transport.close(); server.close(); } catch {} });
130
168
  await server.connect(transport);
131
169
  await transport.handleRequest(req, res, req.body);
132
170
  }
133
171
 
134
- app.all('/mcp', async (req, res) => {
172
+ app.all(MCP_PATH, async (req, res) => {
135
173
  const auth = req.headers.authorization || '';
136
174
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
137
175
  const user = token ? await verifyBearer(token).catch(() => null) : null;
@@ -163,14 +201,21 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
163
201
  if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
164
202
  sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
165
203
  const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
166
- registerTools(server, { only: scope.groups }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
204
+ registerTools(server, { only: scope.groups, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body)) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
167
205
  const transport = new StreamableHTTPServerTransport({
168
206
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
169
207
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
170
208
  onsessioninitialized: (id) => { entry.lastSeen = Date.now(); sessions.set(id, entry); sweepSessions(); },
171
209
  });
172
210
  transport.onclose = () => { if (transport.sessionId) dropSession(transport.sessionId, 'transport closed'); };
173
- entry = { transport, server, user, lastSeen: Date.now() };
211
+ // WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
212
+ // request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
213
+ // tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
214
+ entry = { transport, server, user, lastSeen: Date.now(), client: clientInfoOf(req.body) };
215
+ // LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
216
+ // ever read is a guess. One line per session (not per call) so a new host identifies itself once and
217
+ // the predicate can be corrected from evidence instead of from a hunch.
218
+ if (entry.client) console.error(`[mcp-remote] client: ${entry.client}`);
174
219
  await server.connect(transport);
175
220
  // If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
176
221
  // objects are otherwise reachable only from this request's still-open response — close them explicitly
@@ -192,7 +237,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
192
237
  // forgeable value is exactly the hole resolveWs exists to close. The workspace a hosted connector acts in is
193
238
  // pinned SERVER-SIDE on the agent key (use_brand → /api/keys/brand, membership-checked) and re-authorized by
194
239
  // resolveWs on every request, so it resolves identically here and over stdio without this transport naming it.
195
- await mcpCtx.run({ token, remote: true }, () => entry.transport.handleRequest(req, res, req.body));
240
+ await mcpCtx.run({ token, remote: true, client: entry.client || '' }, () => entry.transport.handleRequest(req, res, req.body));
196
241
  });
197
242
 
198
243
  console.error(`[mcp-remote] mounted at ${BASE || '(set HEIST_PUBLIC_URL)'}/mcp`);