hermoso 0.1.24 → 0.1.30
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 +6 -2
- package/bin/hermoso.mjs +1 -0
- package/mcp/client.mjs +67 -3
- package/mcp/http.mjs +121 -11
- package/mcp/tools.mjs +1305 -86
- package/package.json +2 -2
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
|
-
**
|
|
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
|
|
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.
|
|
@@ -135,6 +138,7 @@ Then invoke `/hermoso-ad-from-brand an ad for yourbrand.com — our hero product
|
|
|
135
138
|
| `HERMOSO_API_BASE` | The Hermoso API origin (default `https://app.hermoso.ai` — set `http://localhost:3000` if you run the app yourself) |
|
|
136
139
|
| `HERMOSO_TOKEN` | Bearer agent key (`hmk_…`) — required against the hosted app |
|
|
137
140
|
| `HERMOSO_PROFILE` | Brand-workspace id, for accounts with multiple brand profiles |
|
|
141
|
+
| `HERMOSO_OWNER` | Only for a brand **another account shared with you** (a team workspace): the owning account id. Set it together with `HERMOSO_PROFILE`, and set `HERMOSO_PROFILE` to that workspace's **profileUuid** — a brand's short slug is refused. Run `list_brands` (or `hermoso brands`) to print both values for every workspace you can enter. The server re-authorizes the pair on every request, so a wrong value is refused, never trusted. |
|
|
138
142
|
|
|
139
143
|
`mcp/http.mjs` is the hosted remote-connector transport (paste-a-URL into Claude.ai → Connectors). It ships in
|
|
140
144
|
this repo for transparency and refuses to mount without authenticated identity — no anonymous spend, ever.
|
package/bin/hermoso.mjs
CHANGED
|
@@ -78,6 +78,7 @@ async function main() {
|
|
|
78
78
|
process.env.HERMOSO_API_BASE = process.env.HERMOSO_API_BASE || cfg.apiBase || 'https://app.hermoso.ai';
|
|
79
79
|
if (cfg.token && !process.env.HERMOSO_TOKEN) process.env.HERMOSO_TOKEN = cfg.token;
|
|
80
80
|
if (cfg.profile && !process.env.HERMOSO_PROFILE) process.env.HERMOSO_PROFILE = cfg.profile;
|
|
81
|
+
if (cfg.owner && !process.env.HERMOSO_OWNER) process.env.HERMOSO_OWNER = cfg.owner; // shared team workspace: the owning account (server re-authorizes it)
|
|
81
82
|
|
|
82
83
|
// `hermoso mcp` → run the stdio MCP server (Claude Code / Cursor / Codex spawn this, e.g. `npx -y hermoso mcp`).
|
|
83
84
|
// It OWNS stdout as the JSON-RPC channel, so hand off immediately and print nothing to stdout here. The
|
package/mcp/client.mjs
CHANGED
|
@@ -17,11 +17,31 @@ const TOKEN = process.env.HERMOSO_TOKEN || '';
|
|
|
17
17
|
// header > key.keyProfileId > 'default' (adapters/auth/middleware.js), so always sending the header permanently
|
|
18
18
|
// masks the brand `use_brand` saved against the key — connectors on any non-default brand then look disconnected.
|
|
19
19
|
export const PROFILE = process.env.HERMOSO_PROFILE || '';
|
|
20
|
+
// SHARED TEAM WORKSPACE: the OWNING account. The web client sends this as x-hermoso-owner from PROFILE_OWNER
|
|
21
|
+
// (public/app.js ctxHeaders) whenever the active brand belongs to someone else's account; the MCP twins never did,
|
|
22
|
+
// so a member driving Hermoso headlessly resolved every brand-scoped read against their OWN empty account —
|
|
23
|
+
// resolveWs's `if (owner && owner !== own)` branch simply never ran and it fell through to the own-account path.
|
|
24
|
+
// Symptom (live 2026-07-31): 0 connectors over MCP on a workspace showing 10 in the browser, with NO error.
|
|
25
|
+
// SAFE TO SEND: the server RE-AUTHORIZES it against profile_members on every request (adapters/auth/middleware.js
|
|
26
|
+
// resolveWs), so a forged or stale value is 403'd, never trusted. Like the profile header it must stay UNSET by
|
|
27
|
+
// default — sending an owner for your own account would make resolveWs take the shared branch against yourself.
|
|
28
|
+
// PAIR IT WITH THE PROFILE UUID, not the slug: profile_members keys on profiles.id, so a client_slug is the one
|
|
29
|
+
// thing isMember() cannot match and it 403s. list_brands names both values for every workspace you can enter.
|
|
30
|
+
export const OWNER = process.env.HERMOSO_OWNER || '';
|
|
31
|
+
// The env-var prefix THIS build reads. tools.mjs is byte-identical across the two twins, so it cannot
|
|
32
|
+
// hardcode either name when it tells a user which variables to set — it asks its own client.
|
|
33
|
+
export const ENV_PREFIX = 'HERMOSO';
|
|
20
34
|
|
|
21
35
|
function headers(extra = {}) {
|
|
22
36
|
const ctx = mcpCtx.getStore();
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
44
|
+
const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-hermoso-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...extra };
|
|
25
45
|
const tok = ctx?.token || TOKEN;
|
|
26
46
|
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
27
47
|
return h;
|
|
@@ -54,6 +74,14 @@ export async function apiPost(p, body = {}) {
|
|
|
54
74
|
return unwrap(res);
|
|
55
75
|
}
|
|
56
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
|
+
|
|
57
85
|
export async function apiDelete(p) {
|
|
58
86
|
const res = await fetch(`${API_BASE}${p}`, { method: 'DELETE', headers: headers() });
|
|
59
87
|
return unwrap(res);
|
|
@@ -67,6 +95,42 @@ export async function apiPut(p, body = {}) {
|
|
|
67
95
|
// A hosted-connector call (via mcp/http.mjs) has an mcpCtx store; local stdio/CLI does not. Used to REFUSE local-path
|
|
68
96
|
// file reads on the hosted connector (it runs on the SERVER host, not the user's machine — an LFI/exfil vector).
|
|
69
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
|
+
}
|
|
70
134
|
// Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
|
|
71
135
|
// the server reads the raw body. Lets an agent post ARBITRARY user files (not just Hermoso renders).
|
|
72
136
|
export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
|
|
@@ -131,4 +195,4 @@ export async function toRef(srcOrPath) {
|
|
|
131
195
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
132
196
|
}
|
|
133
197
|
|
|
134
|
-
export const authState = () => ({ apiBase: API_BASE, hasToken: !!TOKEN, profile: PROFILE });
|
|
198
|
+
export const authState = () => ({ apiBase: API_BASE, hasToken: !!TOKEN, profile: PROFILE, owner: OWNER });
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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)
|
|
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
|
-
|
|
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`);
|