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 +23 -6
- package/mcp/client.mjs +12 -0
- package/mcp/http.mjs +24 -4
- package/mcp/tools.mjs +32 -9
- package/package.json +1 -1
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
|
|
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. **
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3921
|
-
//
|
|
3922
|
-
//
|
|
3923
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|