hermoso 0.1.196 → 0.1.198
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 +2 -2
- package/mcp/http.mjs +19 -6
- package/mcp/roster-scope.mjs +12 -3
- package/mcp/tools.mjs +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ the routes.
|
|
|
116
116
|
|
|
117
117
|
## Instant: the hosted Claude.ai connector
|
|
118
118
|
|
|
119
|
-
Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, pick
|
|
119
|
+
Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Settings → Connectors → *Add custom connector*, pick
|
|
120
120
|
**Always required** when Claude asks about authentication (its detector suggests "None" because our discovery
|
|
121
121
|
handshake is open; "None" would leave every tool call unauthenticated), approve with your Hermoso account, done — the full toolset with your saved brand context, billed to your plan.
|
|
122
122
|
|
|
@@ -143,7 +143,7 @@ claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
|
|
|
143
143
|
```
|
|
144
144
|
|
|
145
145
|
The hosted URL works in Claude Code too, but it is the worse path there and it is worth knowing why:
|
|
146
|
-
`claude mcp add --transport http hermoso https://app.hermoso.ai/mcp` is accepted, and then `claude mcp list`
|
|
146
|
+
`claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"` is accepted, and then `claude mcp list`
|
|
147
147
|
reports `! Needs authentication` because the client will not start the OAuth flow by itself — you have to open a
|
|
148
148
|
session, run `/mcp`, find the server and press Authenticate. Measured against Claude Code 2.1.241 on 2026-08-23.
|
|
149
149
|
|
package/mcp/http.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { mcpCtx, connectedProviders } from './client.mjs';
|
|
|
20
20
|
|
|
21
21
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
22
22
|
// `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
|
|
23
|
-
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
23
|
+
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null } = {}) {
|
|
24
24
|
if ((process.env.HERMOSO_MCP_REMOTE ?? process.env.HEIST_MCP_REMOTE) !== '1') return false; // gate 1: off by default
|
|
25
25
|
if (typeof verifyBearer !== 'function') { // gate 2: refuse without real auth
|
|
26
26
|
console.error('[mcp-remote] REFUSING to mount: no token verifier wired. A remote, money-spending MCP must authenticate every caller (no-anon-spend). Wire Firebase Auth → verifyBearer first.');
|
|
@@ -103,6 +103,9 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
103
103
|
const e = sessions.get(id);
|
|
104
104
|
if (!e) return false;
|
|
105
105
|
sessions.delete(id);
|
|
106
|
+
// What the session DID, handed to the host once, at the end, best-effort: listed-but-never-called is the dead-end
|
|
107
|
+
// signal the roster work is measured by. Never throws into the teardown.
|
|
108
|
+
if (typeof onSessionEnd === 'function') { try { onSessionEnd({ accountId: e.user?.accountId || null, userId: e.user?.userId || null, client: e.client || '', ua: e.ua || '', src: e.src || null, listed: !!e.listed, calls: e.calls || 0, ms: Date.now() - (e.startedAt || Date.now()), why }); } catch {} }
|
|
106
109
|
try { e.transport.close(); } catch {}
|
|
107
110
|
try { e.server.close(); } catch {}
|
|
108
111
|
if (why) console.error(`[mcp-remote] session ${id} closed (${why}); ${sessions.size} live`);
|
|
@@ -198,10 +201,15 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
198
201
|
// was the right instinct for a scope the SERVER changes silently, and the wrong one for a change the CLIENT
|
|
199
202
|
// asked for and is told about.
|
|
200
203
|
function scopeFor(req, res) {
|
|
201
|
-
const { groups, error } = parseToolScope(req.query?.tools ?? req.headers['x-hermoso-tools']);
|
|
204
|
+
const { groups, error, directory } = parseToolScope(req.query?.tools ?? req.headers['x-hermoso-tools']);
|
|
202
205
|
if (error) { res.status(400).json({ jsonrpc: '2.0', error: { code: -32602, message: error }, id: null }); return false; }
|
|
203
|
-
return { groups };
|
|
206
|
+
return { groups, directory: directory || false }; // `directory` = the Claude Connectors Directory cage (see DIRECTORY_GROUPS in tools.mjs)
|
|
204
207
|
}
|
|
208
|
+
// `?src=<surface>` on the published URL (registry / glama / smithery / cursor / readme …) — attribution only. It may
|
|
209
|
+
// not change auth, scope or spend, and an unrecognisable value is simply dropped. The OAuth resource metadata is
|
|
210
|
+
// path-based (RFC 9728), so a query on the pasted URL changes nothing about the handshake.
|
|
211
|
+
const srcOf = (req) => { const v = String(req.query?.src || '').trim().toLowerCase(); return /^[a-z0-9][a-z0-9_.-]{0,39}$/.test(v) ? v : null; };
|
|
212
|
+
const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
|
|
205
213
|
|
|
206
214
|
async function serveAnonDiscovery(req, res, scope) {
|
|
207
215
|
const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
@@ -212,7 +220,8 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
212
220
|
// scope to and nothing honest to read — and a registry crawler or an agent deciding whether to connect MUST
|
|
213
221
|
// see the real catalog, not a zero-connector one. registerTools treats an absent `connectors` exactly like a
|
|
214
222
|
// failed read: full roster. Do not "fix" this by reading the workspace off the request; it is forgeable.
|
|
215
|
-
registerTools(server, { only: scope?.groups, widgetHost: isWidgetHost(clientInfoOf(req.body), req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
223
|
+
registerTools(server, { only: scope?.groups, directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
224
|
+
if (typeof onAnonDiscovery === 'function' && methodsOf(req.body).includes('tools/list')) { try { onAnonDiscovery({ client: clientInfoOf(req.body), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req) }); } catch {} }
|
|
216
225
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
217
226
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
218
227
|
await server.connect(transport);
|
|
@@ -265,7 +274,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
265
274
|
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
266
275
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
267
276
|
const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
268
|
-
registerTools(server, { only: scope.groups, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
277
|
+
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
269
278
|
const transport = new StreamableHTTPServerTransport({
|
|
270
279
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
271
280
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|
|
@@ -275,7 +284,8 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
275
284
|
// WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
|
|
276
285
|
// request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
|
|
277
286
|
// tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
|
|
278
|
-
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req) };
|
|
287
|
+
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 };
|
|
288
|
+
if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
|
|
279
289
|
// LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
|
|
280
290
|
// ever read is a guess. One line per session (not per call) so a new host identifies itself once and
|
|
281
291
|
// the predicate can be corrected from evidence instead of from a hunch.
|
|
@@ -291,6 +301,9 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
291
301
|
entry.lastSeen = Date.now();
|
|
292
302
|
sessions.delete(sid); sessions.set(sid, entry);
|
|
293
303
|
}
|
|
304
|
+
// Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
|
|
305
|
+
// the SDK answered — a refused tools/call is still a call the roster earned.
|
|
306
|
+
for (const m of methodsOf(req.body)) { if (m === 'tools/list') entry.listed = true; else if (m === 'tools/call') entry.calls = (entry.calls || 0) + 1; }
|
|
294
307
|
// The caller's bearer rides into every /api call the tools make — spend bills THEIR account. `remote: true`
|
|
295
308
|
// says what this store IS: a per-request tenant scope on a shared, multi-tenant process. client.mjs treats the
|
|
296
309
|
// presence of this store as the signal to STOP falling back to the process's own HERMOSO_PROFILE / HERMOSO_OWNER,
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -90,19 +90,28 @@ export const TOOL_PROVIDER_RULES = [
|
|
|
90
90
|
[/amplitude/, 'amplitude'],
|
|
91
91
|
// ── posting-only channels ──
|
|
92
92
|
[/youtube/, 'youtube'],
|
|
93
|
-
[/google_business|business_location
|
|
94
|
-
[/_drive_|^list_drive|^get_drive|^create_drive|^save_to_drive$|_doc$|^read_doc|^create_doc|^update_doc|^append_to_doc|sheet/, 'google_drive'],
|
|
93
|
+
[/google_business|business_location|^list_business_(categories|attributes)$|^business_google_updated$/, 'google_business'],
|
|
94
|
+
[/_drive_|^list_drive|^get_drive|^create_drive|^save_to_drive$|^export_swipefile_deck$|_doc$|^read_doc|^create_doc|^update_doc|^append_to_doc|sheet/, 'google_drive'],
|
|
95
95
|
[/onedrive/, 'microsoft_onedrive'],
|
|
96
96
|
[/bluesky/, 'bluesky'],
|
|
97
97
|
[/telegram/, 'telegram'],
|
|
98
98
|
[/^post_to_tiktok$|^tiktok_|_tiktok_/, 'tiktok'],
|
|
99
|
-
[/^post_to_x$|^delete_x_post$|^send_x_dm$|^list_x_dms$|^x_(mentions|post)/, 'x'],
|
|
99
|
+
[/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
|
|
100
100
|
[/pinterest/, 'pinterest'],
|
|
101
101
|
[/reddit/, 'reddit'],
|
|
102
102
|
[/snapchat/, 'snapchat'],
|
|
103
103
|
];
|
|
104
104
|
|
|
105
105
|
// The provider a tool needs, or null when this module cannot attribute it. null ⇒ NEVER dropped (property 2).
|
|
106
|
+
// ── PROVIDERS HERMOSO DOES NOT OFFER, AND AS OF NOW WILL NOT (Dave, 2026-09-03) ────────────────────────────────
|
|
107
|
+
// "make sure posting to reddit or snapchat are never offered, never described, never wasting context in a tools
|
|
108
|
+
// list". Reddit organic posting needs Reddit's API approval we do not have; Snapchat posting is not even built. Both
|
|
109
|
+
// have ADS connectors that are live and are NOT in this set (`reddit_ads`, `snapchat_ads`). A tool whose provider
|
|
110
|
+
// is here is not registered on any roster, not listed by find_tools, not offered by the Studio agent, and not
|
|
111
|
+
// counted. Gating at call time was not enough: a disabled tool still costs its schema in every canon and its name
|
|
112
|
+
// in every listing. If Reddit ever approves the app, remove it from this set and the whole surface returns.
|
|
113
|
+
export const UNOFFERED_PROVIDERS = new Set(['reddit', 'snapchat']);
|
|
114
|
+
export function toolUnoffered(name) { const p = toolProvider(name); return !!p && UNOFFERED_PROVIDERS.has(p); }
|
|
106
115
|
export function toolProvider(name) {
|
|
107
116
|
const n = String(name || '');
|
|
108
117
|
if (!n) return null;
|
package/mcp/tools.mjs
CHANGED
|
@@ -2135,8 +2135,8 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
2135
2135
|
const held = heldBack
|
|
2136
2136
|
? ` ${heldBack} more tool${heldBack === 1 ? ' is' : 's are'} built and ready but not listed because their account is not connected in this workspace yet — Hermoso supports them all; connect the account under Workspace ▸ Connectors (https://app.hermoso.ai/?connect=<provider>, or list_connectors to see what is linked) and they appear.`
|
|
2137
2137
|
: '';
|
|
2138
|
-
const route = '
|
|
2139
|
-
+ ' tool with no roster at all.';
|
|
2138
|
+
const route = 'call find_tools to locate the tool and call_tool to run it by name — that needs no reload and works on every host; or reconnect'
|
|
2139
|
+
+ ' with `?tools=all` on the server URL, or run the `hermoso` CLI, which reaches every tool with no roster at all.';
|
|
2140
2140
|
// A CACHED CLIENT AND AN UNCONNECTED ACCOUNT ARE DIFFERENT DIAGNOSES, AND ONLY ONE OF THEM IS EVER TRUE HERE
|
|
2141
2141
|
// (2026-08-26). The two branches below tell an agent that if the tools do not appear, its client cached the
|
|
2142
2142
|
// roster — correct when we really did enable something. When nothing was enabled BECAUSE nothing is connected,
|
|
@@ -2148,7 +2148,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
2148
2148
|
: (n === 0 && heldBack
|
|
2149
2149
|
? `Switched on ${added.join(', ')} server-side, but nothing new is listed:${held} Active groups: ${enabled.join(', ')}.`
|
|
2150
2150
|
: fixedRoster
|
|
2151
|
-
? `Switched on ${added.join(', ')} server-side — but THIS host fixed its tool list when the connection was made and will not pick up the ${n} new tool${n === 1 ? '' : 's'} until it reconnects,
|
|
2151
|
+
? `Switched on ${added.join(', ')} server-side — but THIS host fixed its tool list when the connection was made and will not pick up the ${n} new tool${n === 1 ? '' : 's'} until it reconnects. USE THEM NOW ANYWAY: find_tools({query}) finds the tool and call_tool({name, args}) runs it through this same connection — no reconnect needed. Do not expect to see them in this conversation's list. To use them, ${route}${held} Active groups: ${enabled.join(', ')}.`
|
|
2152
2152
|
: `Switched on ${added.join(', ')} — ${n} more tool${n === 1 ? '' : 's'} are callable now. If they do not appear your client has cached its tool list, in which case ${route}${held} Active groups: ${enabled.join(', ')}.`);
|
|
2153
2153
|
// The agent took the route our own instructions name, on a host where it provably cannot show anything. That is
|
|
2154
2154
|
// our guidance failing, not the agent, so it is recorded on our side of the board.
|
|
@@ -10932,7 +10932,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10932
10932
|
inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
10933
10933
|
}, wrap(async () => oaiNote(await apiGet('/api/openai-ads/spend-windows'))));
|
|
10934
10934
|
server.registerTool('set_openai_ads_spend_window', {
|
|
10935
|
-
title: 'Create or edit a ChatGPT Ads spend-limit window', description: 'Create (no windowId) or edit (windowId) an account-level spend-limit window: startDate YYYY-MM-DD inclusive, endDate YYYY-MM-DD EXCLUSIVE, amount in the account currency, optional name and insertion-order id. A window is a CEILING;
|
|
10935
|
+
title: 'Create or edit a ChatGPT Ads spend-limit window', description: 'Create (no windowId) or edit (windowId) an account-level spend-limit window on ChatGPT Ads: startDate YYYY-MM-DD inclusive, endDate YYYY-MM-DD EXCLUSIVE (the window ends the day before), amount in the account currency (sent as micros), optional name and insertion-order id (ioId). A window is a CEILING on what the whole account may spend in that range; it never makes anything spend, and campaign budgets still apply underneath it. On edit, pass only the fields that change; a window whose canEdit is false (already started, per OpenAI) cannot be edited. Raising the amount lets campaigns spend up to their budgets, so show the user the old and new amounts. Reply carries the stored window (amount, spent so far, status). MEASURED 2026-09-03: OpenAI\'s live API answered "Invalid URL" for this path although it is in their published spec; the tool reports that honestly until they ship it.',
|
|
10936
10936
|
inputSchema: { windowId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), amount: z.number().optional(), name: z.string().optional(), ioId: z.string().optional() },
|
|
10937
10937
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10938
10938
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/spend-window', a))));
|
|
@@ -10970,7 +10970,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10970
10970
|
inputSchema: { feedId: z.string() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
10971
10971
|
}, wrap(async (a) => oaiNote(await apiGet('/api/openai-ads/feed/sftp', a))));
|
|
10972
10972
|
server.registerTool('set_openai_ads_feed_sftp', {
|
|
10973
|
-
title: 'Set up, activate or pause feed SFTP delivery', description: 'action "create" (default): create or REPLACE the SFTP credentials
|
|
10973
|
+
title: 'Set up, activate or pause feed SFTP delivery', description: 'Manage SFTP bulk delivery for a ChatGPT Ads product feed (the alternative to pushing products through update_openai_ads_feed_products). action "create" (default): create or REPLACE the feed\'s SFTP credentials — authenticationMethod "password" (OpenAI returns the password ONCE in this reply; store it) or "ssh_key" (pass the public key as sshPublicKey). Replacing credentials invalidates the previous ones immediately, so re-run only when the user means to rotate. action "activate" / "pause": switch delivery on or off without touching credentials. The reply carries the connection URI and whether delivery is enabled; get_openai_ads_feed_sftp reads the same state without changing it. Files delivered over SFTP show up in list_openai_ads_feed_uploads with accepted / rejected / ads-eligible row counts.',
|
|
10974
10974
|
inputSchema: { feedId: z.string(), action: z.enum(['create', 'activate', 'pause']).optional(), authenticationMethod: z.enum(['password', 'ssh_key']).optional(), sshPublicKey: z.string().optional() },
|
|
10975
10975
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10976
10976
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/sftp', a))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.198",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI ad studio and marketing MCP server with 768 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|