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 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,
@@ -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/, 'google_business'],
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.197",
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",