hermoso 0.1.197 → 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/mcp/http.mjs +19 -6
- package/mcp/roster-scope.mjs +12 -3
- package/package.json +1 -1
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/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",
|