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 +23 -6
- package/mcp/client.mjs +12 -0
- package/mcp/http.mjs +53 -8
- package/mcp/tools.mjs +571 -403
- package/package.json +1 -1
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
|
|
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. **
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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`);
|