hermoso 0.1.140 → 0.1.141

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 CHANGED
@@ -27,17 +27,32 @@ just download it. Use the one piece you need, or all of it together.
27
27
  Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
28
28
  your Hermoso account, done — the full toolset with your saved brand context, billed to your plan.
29
29
 
30
- ## Quickstart for Claude Code / Cursor / scripts (2 minutes)
30
+ ## Quickstart for Claude Code (one line)
31
31
 
32
32
  1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
33
33
  same ones the web Studio uses.
34
- 2. **Create an agent key**: app.hermoso.ai **Settings Agents & API** Create API key (`hmk_…`).
35
- 3. **Connect** — no clone needed, `npx` runs the published `hermoso` package (Claude Code shown; any MCP client works):
34
+ 2. **Run one line.** Your browser opens once to sign in. Nothing to paste, and no key lands in `.claude.json`:
35
+
36
+ ```bash
37
+ npm install -g hermoso && hermoso auth login && claude mcp add hermoso -- npx -y hermoso mcp
38
+ ```
39
+
40
+ 3. **Ask for what you want**, in your normal prompts. Claude Code reaches for a tool, or runs the `hermoso`
41
+ command in your terminal, whichever the job needs. You type neither.
42
+
43
+ Ad campaign and analytics tools stay out of the tool list until you switch them on with `enable_tools`, which
44
+ keeps it small. On a machine with no browser, sign in with `hermoso auth login --token hmk_…` using a key from
45
+ **Settings → Agents & API**, or skip the sign-in and pass the key to the client instead:
36
46
 
37
47
  ```bash
38
48
  claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
39
49
  ```
40
50
 
51
+ The hosted URL works in Claude Code too, but it is the worse path there and it is worth knowing why:
52
+ `claude mcp add --transport http hermoso https://app.hermoso.ai/mcp` is accepted, and then `claude mcp list`
53
+ reports `! Needs authentication` because the client will not start the OAuth flow by itself — you have to open a
54
+ session, run `/mcp`, find the server and press Authenticate. Measured against Claude Code 2.1.241 on 2026-08-23.
55
+
41
56
  Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
42
57
  learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
43
58
  `plan_ad`/`plan_variations` to use it). Renders bill your Hermoso credits — same prices as the Studio.
@@ -45,13 +60,15 @@ learned memory you set up in the web app apply automatically (`get_brand` shows
45
60
  ## 1. MCP server (stdio) — Claude Code / Cursor / Codex
46
61
 
47
62
  `hermoso mcp` runs a stdio MCP server exposing the full toolset. The published `hermoso` package means no clone —
48
- `npx -y hermoso mcp` fetches and runs it:
63
+ `npx -y hermoso mcp` fetches and runs it. Sign in once with the CLI and no key goes into any client config,
64
+ because `hermoso mcp` reads the bearer `hermoso auth login` stored:
49
65
 
50
66
  ```bash
51
- claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
67
+ npm install -g hermoso && hermoso auth login && claude mcp add hermoso -- npx -y hermoso mcp
52
68
  ```
53
69
 
54
- Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
70
+ Cursor / Codex — sign in the same way, then add to `mcp.json` (Codex uses the TOML equivalent). Drop the `env`
71
+ block entirely if you signed in above; it is there for CI, where the process cannot read your home directory:
55
72
 
56
73
  ```json
57
74
  { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"],
package/mcp/client.mjs CHANGED
@@ -130,6 +130,18 @@ export async function apiPut(p, body = {}) {
130
130
  // file reads on the hosted connector (it runs on the SERVER host, not the user's machine — an LFI/exfil vector).
131
131
  export const isRemote = () => !!mcpCtx.getStore();
132
132
 
133
+ // DOES THIS HOST RENDER OUR WIDGETS ITSELF?
134
+ // ChatGPT (the Apps SDK) draws every finished render in `ui://widget/ad-result.html`, hydrated from
135
+ // `structuredContent`. For that host the inline base64 image block in `content` is not just redundant, it is
136
+ // actively harmful: a finished 1:1 render is ~1 MB of base64, and a result that size arrived in ChatGPT with an
137
+ // EMPTY toolOutput — the card drew its "No media in this result yet" empty state while the model narrated success
138
+ // from the text block. Measured 2026-08-23: structuredContent, outputSchema, the tools/list binding and the widget
139
+ // itself were each verified correct in isolation, and the oversized `content` was the only thing left.
140
+ // Claude has no widget, so it KEEPS the inline block — that is the only reason it renders an image in chat at all.
141
+ // Advisory and fail-open: an unrecognised or absent client behaves exactly as before.
142
+ const WIDGET_HOSTS = /openai|chatgpt/i;
143
+ export const hostRendersWidgets = () => WIDGET_HOSTS.test(mcpCtx.getStore()?.client || '');
144
+
133
145
  // ── WHICH WORKSPACE'S STORE KEYS THIS CALL WRITES ──────────────────────────────────────────────────────────────
134
146
  // The suffix synced store keys carry (`` = the bare/anchor keys, `<clientSlug>` = a sub-brand). It comes from the
135
147
  // SERVER (`GET /api/workspace` → resolveWs), never from this process's environment, because on the hosted twin the
package/mcp/http.mjs CHANGED
@@ -113,6 +113,16 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
113
113
  // anything: building a 36 MB McpServer purely to have it reject the request is what turned a retry storm into
114
114
  // an OOM. Status and message are byte-identical to the SDK's, so no client sees a behaviour change.
115
115
  const needSession = (res) => res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' }, id: null });
116
+ // The one place the host name is turned into a decision, so the anon and session paths cannot diverge.
117
+ const isWidgetHost = (name) => /openai|chatgpt/i.test(String(name || ''));
118
+ // The client's own name, off the initialize params. Never trusted for anything but presentation.
119
+ const clientInfoOf = (body) => {
120
+ try {
121
+ const msgs = Array.isArray(body) ? body : [body];
122
+ for (const m of msgs) if (m && m.method === 'initialize') return String(m.params?.clientInfo?.name || '').slice(0, 64);
123
+ } catch {}
124
+ return '';
125
+ };
116
126
  const hasInitialize = (body) => (Array.isArray(body) ? body : [body]).some((m) => m && m.method === 'initialize');
117
127
 
118
128
  // ── PRE-AUTH DISCOVERY (registry crawlers + evaluating agents) ────────────────────────────────────────────────
@@ -149,7 +159,10 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
149
159
 
150
160
  async function serveAnonDiscovery(req, res, scope) {
151
161
  const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
152
- registerTools(server, { only: scope?.groups }); // metadata only tools/list never invokes a handler, and tools/call can't reach here
162
+ // `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
163
+ // as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
164
+ // only the authenticated path would leave both tools listed in the submission.
165
+ registerTools(server, { only: scope?.groups, widgetHost: isWidgetHost(clientInfoOf(req.body)) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
153
166
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
154
167
  res.on('close', () => { try { transport.close(); server.close(); } catch {} });
155
168
  await server.connect(transport);
@@ -188,14 +201,21 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
188
201
  if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
189
202
  sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
190
203
  const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
191
- registerTools(server, { only: scope.groups }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
204
+ registerTools(server, { only: scope.groups, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body)) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
192
205
  const transport = new StreamableHTTPServerTransport({
193
206
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
194
207
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
195
208
  onsessioninitialized: (id) => { entry.lastSeen = Date.now(); sessions.set(id, entry); sweepSessions(); },
196
209
  });
197
210
  transport.onclose = () => { if (transport.sessionId) dropSession(transport.sessionId, 'transport closed'); };
198
- entry = { transport, server, user, lastSeen: Date.now() };
211
+ // WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
212
+ // request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
213
+ // tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
214
+ entry = { transport, server, user, lastSeen: Date.now(), client: clientInfoOf(req.body) };
215
+ // LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
216
+ // ever read is a guess. One line per session (not per call) so a new host identifies itself once and
217
+ // the predicate can be corrected from evidence instead of from a hunch.
218
+ if (entry.client) console.error(`[mcp-remote] client: ${entry.client}`);
199
219
  await server.connect(transport);
200
220
  // If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
201
221
  // objects are otherwise reachable only from this request's still-open response — close them explicitly
@@ -217,7 +237,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
217
237
  // forgeable value is exactly the hole resolveWs exists to close. The workspace a hosted connector acts in is
218
238
  // pinned SERVER-SIDE on the agent key (use_brand → /api/keys/brand, membership-checked) and re-authorized by
219
239
  // resolveWs on every request, so it resolves identically here and over stdio without this transport naming it.
220
- await mcpCtx.run({ token, remote: true }, () => entry.transport.handleRequest(req, res, req.body));
240
+ await mcpCtx.run({ token, remote: true, client: entry.client || '' }, () => entry.transport.handleRequest(req, res, req.body));
221
241
  });
222
242
 
223
243
  console.error(`[mcp-remote] mounted at ${BASE || '(set HEIST_PUBLIC_URL)'}/mcp`);
package/mcp/tools.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
5
5
  // needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
6
6
  import { z } from 'zod';
7
- import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError } from './client.mjs';
7
+ import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, hostRendersWidgets } from './client.mjs';
8
8
  import { readFile } from 'node:fs/promises';
9
9
 
10
10
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
@@ -139,6 +139,7 @@ export const MCP_INSTRUCTIONS = [
139
139
  // Claude can't play video inline — attach the FIRST FRAME as an image block next to the link so the spot is
140
140
  // visible in chat (0 credits; ffmpeg still via /api/video/frames).
141
141
  async function videoPosterBlock(videoUrl) {
142
+ if (hostRendersWidgets()) return null; // the host draws the clip itself — see hostRendersWidgets()
142
143
  try {
143
144
  const d = await apiGet('/api/video/frames', { url: videoUrl, n: 1 });
144
145
  const f = (d.frames || [])[0]; if (!f || !/^data:image\//.test(f)) return null;
@@ -147,6 +148,10 @@ async function videoPosterBlock(videoUrl) {
147
148
  } catch (e) { console.error('[mcp] video poster failed:', String(e?.message || e).slice(0, 160)); return null; } // silent-null keeps the link usable; log so a missing poster is diagnosable (Dave hit this on Claude.ai)
148
149
  }
149
150
  async function imageBlock(url) {
151
+ // A HOST WITH A WIDGET DOES NOT NEED A MEGABYTE OF BASE64, AND IS HARMED BY IT (2026-08-23).
152
+ // Skipped here, at the one place both builders live, rather than at the 11 call sites — a new tool that shows a
153
+ // render inherits the behaviour instead of having to remember it. Fails OPEN: an unknown client keeps the block.
154
+ if (hostRendersWidgets()) return null;
150
155
  try {
151
156
  const r = await fetch(url); if (!r.ok) return null;
152
157
  const ct = (r.headers.get('content-type') || 'image/jpeg').split(';')[0];
@@ -892,6 +897,10 @@ export function parseToolScope(raw) {
892
897
  return { groups: asked };
893
898
  }
894
899
 
900
+ // Tools an Apps-SDK host (ChatGPT) must not be offered. See the seam in registerTools for why, and note this is
901
+ // a HOST rule, not a capability we removed: every other surface still offers both.
902
+ const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan']);
903
+
895
904
  export function registerTools(rawServer, opts = {}) {
896
905
  // SCOPING (2026-08-05). The full roster is 301 tools ≈ 602 KB / ~154k tokens of JSON Schema. Clients that defer
897
906
  // tool schemas (Claude Code, claude.ai) never pay that, but one that loads every definition eagerly spends most
@@ -953,6 +962,16 @@ export function registerTools(rawServer, opts = {}) {
953
962
  handleOf[name] = h;
954
963
  // DISABLED, NOT SKIPPED — see (1) above. `disable()` is the SDK's own call and removes it from tools/list.
955
964
  if (h && !enabledGroups.has(group)) { try { h.disable(); } catch {} }
965
+ // WITHHELD FROM ONE HOST, for that host's rules rather than ours (2026-08-23).
966
+ // OpenAI's plugin policy permits commerce only in PHYSICAL goods: "selling digital products or services,
967
+ // including subscriptions, digital content, tokens, or credits, is not allowed." buy_credits hands back a
968
+ // one-click charge on the saved card or a Stripe link, and upgrade_plan changes a paid plan — both are
969
+ // exactly that. The same policy explicitly ALLOWS a user to "sign in to an existing paid account and access
970
+ // features already included in their subscription", which is why nothing else here is affected.
971
+ // Deliberately NOT a group: both live in `core`, which every roster force-adds, and moving them would take
972
+ // them away from Claude, Cursor and the CLI too. Dave's position is that a customer controls their own
973
+ // billing wherever they use Hermoso; this is OpenAI's constraint on OpenAI's surface, nothing wider.
974
+ if (h && WITHHELD_FROM_WIDGET_HOSTS.has(name) && opts.widgetHost) { try { h.disable(); } catch {} }
956
975
  return h;
957
976
  };
958
977
  const v = Reflect.get(t, p);
@@ -3917,10 +3936,14 @@ export function registerTools(rawServer, opts = {}) {
3917
3936
  return ok(d.summary, d); // print the READ-BACK sentence verbatim — never narrate an object we did not read back
3918
3937
  }));
3919
3938
  // ── META LEAD ADS (2026-08-10) — instant forms + on-demand lead retrieval ──────────────────────────────────
3920
- // Needs pages_manage_ads + leads_retrieval, both at Meta STANDARD access on this app: they work for people
3921
- // holding a role on it and 403 for everyone else until App Review grants Advanced Access. A connection made
3922
- // before those permissions were requested cannot gain them by retrying — the server's 403 says so and names
3923
- // the reconnect, so it is printed verbatim rather than summarised.
3939
+ // Needs pages_manage_ads + leads_retrieval, both of which Hermoso's Meta consent screen asks for. A connection
3940
+ // made before those permissions were added cannot gain them by retrying the server's 403 says so and names the
3941
+ // reconnect, so it is printed verbatim rather than summarised.
3942
+ // NO ACCESS-TIER SENTENCE HERE, DELIBERATELY (2026-08-23). Meta's tier is a moving state it moved once already
3943
+ // this year, when Meta renamed Standard -> LIMITED and Advanced -> FULL on 2026-05-05 — and a moving state
3944
+ // written into a tool description goes stale the day it changes, at which point an agent reads it and REFUSES a
3945
+ // capability we ship ([[prompt-rosters-go-stale]]). The tier belongs in the runtime refusal, which is computed;
3946
+ // see lib/meta-access.mjs. Dave 2026-08-23: "advertise itself as having access to those meta scopes".
3924
3947
  server.registerTool('list_meta_pixels', {
3925
3948
  title: 'List Meta Pixels on an ad account',
3926
3949
  description: 'List the META PIXELS on one of the brand’s ad accounts — id, name, when it was created, and WHEN IT LAST FIRED. This is where the pixelId every conversion tool needs comes from: create_meta_ad takes it (with conversionEvent) to optimise an ad set for OFFSITE_CONVERSIONS instead of link clicks, and create_meta_audience needs it to build a website retargeting audience. Without this tool that id could only be read off a screen in Events Manager. READ lastFiredAt BEFORE YOU TRUST A PIXEL: one that has NEVER FIRED is not installed on the site, so an ad optimising against it will spend and never learn. Pass includeCode:true to get the <script> snippet for installation (it is long, so it is off by default). Read-only, free.',
@@ -3949,7 +3972,7 @@ export function registerTools(rawServer, opts = {}) {
3949
3972
  }));
3950
3973
  server.registerTool('list_meta_lead_forms', {
3951
3974
  title: 'List Meta instant (lead) forms',
3952
- description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which is at Standard Access on the Hermoso app: it works for people who hold a role on the app and Meta refuses it for everyone else until App Review grants Advanced Access. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta — a connection made before that permission was requested cannot gain it by retrying.',
3975
+ description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta under Settings ▸ Connectors ▸ Meta — a connection made before that permission was added cannot gain it by retrying. Call the tool rather than pre-refusing: any refusal comes from Meta and names the one thing that fixes it.',
3953
3976
  inputSchema: {
3954
3977
  pageId: z.string().optional().describe('which connected Page (from list_meta_pages) — required only if the brand has more than one'),
3955
3978
  limit: z.number().optional().describe('max rows (1–100, default 25)'),
@@ -3964,7 +3987,7 @@ export function registerTools(rawServer, opts = {}) {
3964
3987
  }));
3965
3988
  server.registerTool('create_meta_lead_form', {
3966
3989
  title: 'Create a Meta instant (lead) form',
3967
- description: 'Create an INSTANT LEAD FORM on a connected Facebook Page — the in-app form a Meta lead ad opens instead of sending someone to a website, which is why lead ads convert far better than a landing page on mobile. `questions` takes Meta’s own type names, e.g. ["FULL_NAME","EMAIL","PHONE"]; for anything bespoke pass {type:"CUSTOM", label:"What size fleet do you run?"} and add options:[…] to make it a dropdown. Ask FEWER questions than you think — every extra field costs completions. privacyPolicyUrl is REQUIRED: the form collects real people’s contact details and has to say where their data goes. Optional: headline, contextCard {title, bullets[], buttonText} for the why-should-I screen, thankYou {title, body, buttonType, buttonText, websiteUrl}, locale, followUpActionUrl. CREATING A FORM SPENDS NOTHING and publishes nothing — it is invisible to the public until an ad points at it (create_meta_ad with objective:"OUTCOME_LEADS" + leadFormId), and that ad is created PAUSED. Read the submissions with read_meta_leads. NEEDS pages_manage_ads (Standard Access — see list_meta_lead_forms).',
3990
+ description: 'Create an INSTANT LEAD FORM on a connected Facebook Page — the in-app form a Meta lead ad opens instead of sending someone to a website, which is why lead ads convert far better than a landing page on mobile. `questions` takes Meta’s own type names, e.g. ["FULL_NAME","EMAIL","PHONE"]; for anything bespoke pass {type:"CUSTOM", label:"What size fleet do you run?"} and add options:[…] to make it a dropdown. Ask FEWER questions than you think — every extra field costs completions. privacyPolicyUrl is REQUIRED: the form collects real people’s contact details and has to say where their data goes. Optional: headline, contextCard {title, bullets[], buttonText} for the why-should-I screen, thankYou {title, body, buttonType, buttonText, websiteUrl}, locale, followUpActionUrl. CREATING A FORM SPENDS NOTHING and publishes nothing — it is invisible to the public until an ad points at it (create_meta_ad with objective:"OUTCOME_LEADS" + leadFormId), and that ad is created PAUSED. Read the submissions with read_meta_leads. NEEDS pages_manage_ads — see list_meta_lead_forms for what to do if Meta refuses it.',
3968
3991
  inputSchema: {
3969
3992
  name: z.string().describe('internal name for the form — not shown to the person filling it in'),
3970
3993
  questions: z.array(z.any()).describe('Meta question types as strings ("FULL_NAME","EMAIL","PHONE","COMPANY_NAME","JOB_TITLE","CITY","ZIP","WORK_EMAIL","DATE_TIME"…) or objects {type,label,key,options[]} for CUSTOM'),
@@ -4003,7 +4026,7 @@ export function registerTools(rawServer, opts = {}) {
4003
4026
  }));
4004
4027
  server.registerTool('read_meta_leads', {
4005
4028
  title: 'Read Meta lead-ad submissions',
4006
- description: 'Read the LEADS a form (formId) or a single ad (adId) has collected — the real answers people submitted, fetched live from Meta. THIS RETURNS REAL PEOPLE’S CONTACT DETAILS (names, emails, phone numbers). Treat it as the user’s own customer data: show only what they asked for, never post a lead list anywhere public, and do not copy it into an unrelated document. Hermoso PULLS these on demand and keeps no copy — nothing here watches, polls or files leads anywhere, so if the user wants this batch kept, put it somewhere THEY own in the same turn (a Google Sheet with create_sheet / append_to_sheet, a doc, or their own CRM). Meta makes each lead available for 90 days after it is submitted; anything older is handled in Meta’s own Leads Center and CRM integrations. Narrow with since/until (ISO dates), page with cursor, or pass redact:true to see counts, timestamps and which ad produced each lead WITHOUT the personal details. NEEDS leads_retrieval (Standard Access — see list_meta_lead_forms).',
4029
+ description: 'Read the LEADS a form (formId) or a single ad (adId) has collected — the real answers people submitted, fetched live from Meta. THIS RETURNS REAL PEOPLE’S CONTACT DETAILS (names, emails, phone numbers). Treat it as the user’s own customer data: show only what they asked for, never post a lead list anywhere public, and do not copy it into an unrelated document. Hermoso PULLS these on demand and keeps no copy — nothing here watches, polls or files leads anywhere, so if the user wants this batch kept, put it somewhere THEY own in the same turn (a Google Sheet with create_sheet / append_to_sheet, a doc, or their own CRM). Meta makes each lead available for 90 days after it is submitted; anything older is handled in Meta’s own Leads Center and CRM integrations. Narrow with since/until (ISO dates), page with cursor, or pass redact:true to see counts, timestamps and which ad produced each lead WITHOUT the personal details. NEEDS leads_retrieval — see list_meta_lead_forms for what to do if Meta refuses it.',
4007
4030
  inputSchema: {
4008
4031
  formId: z.string().optional().describe('every lead this form has ever collected (from list_meta_lead_forms)'),
4009
4032
  adId: z.string().optional().describe('just the leads this ONE ad produced (from list_meta_ads) — pass this OR formId, never both'),
@@ -11690,7 +11713,7 @@ export function registerTools(rawServer, opts = {}) {
11690
11713
  server.group('channels');
11691
11714
  server.registerTool('manage_meta_post', {
11692
11715
  title: 'Edit or delete a published post',
11693
- description: 'Edit the text of, or delete, a published post. target:"facebook" → edit the message (action:"edit", message:…) OR delete (action:"delete"); target:"threads" → delete only (Threads has no edit API); target:"instagram" → DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT AND IS PENDING APP REVIEW: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings ▸ Connectors ▸ Meta) and until Meta App Review clears, Meta grants that permission only to admins, developers and testers of the app. Tell the user that rather than retrying.',
11716
+ description: 'Edit the text of, or delete, a published post. target:"facebook" → edit the message (action:"edit", message:…) OR delete (action:"delete"); target:"threads" → delete only (Threads has no edit API); target:"instagram" → DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings ▸ Connectors ▸ Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing every refusal it can raise names the one thing that fixes it.',
11694
11717
  inputSchema: {
11695
11718
  postId: z.string().describe('the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media'),
11696
11719
  action: z.enum(['edit', 'delete']).describe('edit the text (FB only) or delete the post'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.140",
3
+ "version": "0.1.141",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI ad studio and marketing MCP server with 681 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",