hermoso 0.1.213 → 0.1.215

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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +19 -2
  2. package/package.json +1 -1
package/mcp/tools.mjs CHANGED
@@ -2578,10 +2578,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
2578
2578
  if (g && !TOOL_GROUP_NAMES.includes(g)) return { content: [{ type: 'text', text: `Unknown group "${g}". Groups: ${TOOL_GROUP_NAMES.join(', ')}.` }], isError: true };
2579
2579
  const rows = [];
2580
2580
  const tokenWeight = tokenWeightFor(ctx);
2581
+ const _offGroup = new Map(); // name → { grp, h } for tools the group filter excluded (see below)
2581
2582
  for (const [name, h] of Object.entries(ctx.handleOf)) {
2582
2583
  if (!h) continue;
2583
2584
  const grp = ctx.groupOf[name] || 'core';
2584
- if (g && grp !== g) continue;
2585
+ // A GROUP FILTER NARROWS, IT NEVER HIDES (2026-09-07). `find_tools({query:'tiktok_creator_info', group:'channels'})` reported a
2586
+ // dead end because the tool lives in channel_admin — the exact-name hit was thrown away by the filter, and the agent
2587
+ // was told we have no such tool. Off-group matches are kept aside; if the scoped search finds nothing, they are
2588
+ // returned WITH their real group so the caller learns which group to enable instead of giving up.
2589
+ if (g && grp !== g) { if (!_offGroup.has(name)) _offGroup.set(name, { grp, h }); continue; }
2585
2590
  const desc = String(h.description || '');
2586
2591
  let score = 0;
2587
2592
  if (q) {
@@ -2615,6 +2620,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
2615
2620
  // THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
2616
2621
  // not name. Unquoted and lowercased on purpose — the ledger collapses quoted strings to <q>, and one group per
2617
2622
  // distinct ask is exactly what we want to read.
2623
+ if (!total && g && _offGroup.size) {
2624
+ // Re-score the excluded tools by NAME only (the cheap, unambiguous half): an exact or token hit outside the
2625
+ // asked-for group is an answer, not a dead end — "it exists, in channel_admin; enable that group".
2626
+ const qw = String(q || '').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
2627
+ const off = [..._offGroup.entries()].filter(([name]) => qw.some((w) => name === w || name.includes(w))).slice(0, cap)
2628
+ .map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
2629
+ if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable it with enable_tools, or call them by name through run_tool):\n${off.join('\n')}`, { query: q, group: g, offGroup: off.length });
2630
+ }
2618
2631
  if (!total) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
2619
2632
  for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
2620
2633
  const lines = top.map((r) => `• ${r.name} [${r.group}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] — ${r.description}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
@@ -3914,7 +3927,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3914
3927
 
3915
3928
  server.registerTool('list_threads_profile_posts', {
3916
3929
  title: 'Read a public Threads account\u2019s posts',
3917
- description: 'The recent PUBLIC posts of any Threads account \u2014 the raw material for a teardown, for mine_angles, and for ad copy in the language the market actually uses. Use it after threads_profile, or on its own when you already know the handle, and then USE what it returns: draft the brand\u2019s next post or plan_ad off the angles you found. AN EMPTY LIST IS NEVER PROOF THEY HAVE NOT POSTED \u2014 Meta returns nothing for a private account, for any account under 100 followers, and for handles our app is not yet approved to read (the `threads_profile_discovery` permission); the note says which of those applies. Read-only, 0 credits. Needs Threads connected.',
3930
+ description: 'The recent PUBLIC posts of any Threads account \u2014 the raw material for a teardown, for mine_angles, and for ad copy in the language the market actually uses. Use it after threads_profile, or on its own when you already know the handle, and then USE what it returns: draft the brand\u2019s next post or plan_ad off the angles you found. AN EMPTY LIST IS NEVER PROOF THEY HAVE NOT POSTED \u2014 Meta returns nothing for a private account, for any account under 100 followers, and when the Threads connection predates the `threads_profile_discovery` permission (approved for the app 2026-09-05; reconnect Threads to grant it); the note says which of those applies. Read-only, 0 credits. Needs Threads connected.',
3918
3931
  inputSchema: {
3919
3932
  username: z.string().describe('the Threads handle \u2014 "nike", "@nike", or a threads.net profile link'),
3920
3933
  limit: z.number().optional().describe('how many posts (1\u201350, default 25)'),
@@ -6779,6 +6792,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
6779
6792
  // onboarding (user token + marketing_messages_messenger) rides the existing Meta connection. App-Review gated:
6780
6793
  // until Meta grants the scope it works for app-role holders only, and list_messenger_subscribers says which.
6781
6794
  // Every endpoint, limit and error code: lib/messenger-marketing.mjs (read live 2026-09-05).
6795
+ // MESSENGER MARKETING MESSAGES ARE A CHANNEL CAPABILITY, NOT PAID ADS (2026-09-07). This block was registered inside
6796
+ // the ads section, so it rode the heaviest opt-in group while dispatching to no paid-advertising route — unreachable
6797
+ // for anyone who had not switched on 'ads', and counted as 'ads' tools on an account with no ad platform at all.
6798
+ server.group('channel_admin'); // Messenger marketing — the marker at the end of this block re-opens 'ads'
6782
6799
  server.registerTool('list_messenger_subscribers', {
6783
6800
  title: 'List Messenger marketing-message subscribers',
6784
6801
  description: 'The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether this Meta connection carries the marketing_messages_messenger permission; a connection made before it was added must be reconnected. Free.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.213",
3
+ "version": "0.1.215",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI ad studio and marketing MCP server with 801 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",