hermoso 0.1.43 → 0.1.57
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 +11 -4
- package/mcp/client.mjs +36 -2
- package/mcp/hermoso-mcp.mjs +8 -2
- package/mcp/http.mjs +21 -5
- package/mcp/tools.mjs +1201 -80
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,9 +5,16 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**316 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
|
+
**It is not all-or-nothing.** Research, creation, publishing/scheduling and ads management are four *independent*
|
|
12
|
+
areas — no tool requires that you used another one first. Publish or schedule creative you already have and
|
|
13
|
+
generate nothing here (`upload_file` turns any local or external file into a URL every publish, schedule and
|
|
14
|
+
ad-build tool accepts); build and read campaigns on your own ad accounts with your own creative; research
|
|
15
|
+
competitors with no brand drafted and no channel connected; or generate a file with nothing connected at all and
|
|
16
|
+
just download it. Use the one piece you need, or all of it together.
|
|
17
|
+
|
|
11
18
|
## Instant: the hosted Claude.ai connector
|
|
12
19
|
|
|
13
20
|
Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
|
|
@@ -46,7 +53,7 @@ Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
|
|
|
46
53
|
|
|
47
54
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
48
55
|
|
|
49
|
-
### What the
|
|
56
|
+
### What the 316 tools cover
|
|
50
57
|
|
|
51
58
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
52
59
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -78,8 +85,8 @@ publish). `schedule_post` / `list_scheduled` / `cancel_scheduled` give you one c
|
|
|
78
85
|
*X posting bills credits per API call (X charges per request); a post containing a link costs 13× one without.*
|
|
79
86
|
|
|
80
87
|
**Run the ads** — full campaign trees, built paused and read back before anything is reported, with every spend
|
|
81
|
-
change confirm-gated, on **Meta**, **Google Ads**, **LinkedIn Ads**, **
|
|
82
|
-
and **ChatGPT Ads** (OpenAI's Advertiser API). Each has list + report + create + budget/status tools
|
|
88
|
+
change confirm-gated, on **Meta**, **Google Ads**, **LinkedIn Ads**, **Reddit Ads**, **Pinterest Ads**,
|
|
89
|
+
**Microsoft Advertising** and **ChatGPT Ads** (OpenAI's Advertiser API). Each has list + report + create + budget/status tools
|
|
83
90
|
(e.g. `list_google_ads_campaigns`, `google_ads_report`, `create_google_ads_campaign`, `set_google_ads_budget`,
|
|
84
91
|
`set_google_ads_status`). *X Ads are not supported — X posting only.*
|
|
85
92
|
|
package/mcp/client.mjs
CHANGED
|
@@ -32,6 +32,13 @@ export const OWNER = process.env.HERMOSO_OWNER || '';
|
|
|
32
32
|
// hardcode either name when it tells a user which variables to set — it asks its own client.
|
|
33
33
|
export const ENV_PREFIX = 'HERMOSO';
|
|
34
34
|
|
|
35
|
+
// WHICH TOOL IS RUNNING. The error ledger groups on the OP, and a path alone cannot name the tool: `plan_ad`,
|
|
36
|
+
// `render_ad` and `make_template_ad` all fail through POST /api/create, so without this every MCP defect would be
|
|
37
|
+
// filed under one row called "POST /api/create" and be unfixable. wrap() sets it around each tool call; headers()
|
|
38
|
+
// stamps it on every /api request that call makes, so route() records the tool NAME and nothing has to be reported
|
|
39
|
+
// twice. Deliberately a per-call AsyncLocalStorage and not a module variable — concurrent tool calls interleave.
|
|
40
|
+
export const toolCtx = new AsyncLocalStorage();
|
|
41
|
+
|
|
35
42
|
function headers(extra = {}) {
|
|
36
43
|
const ctx = mcpCtx.getStore();
|
|
37
44
|
// A HOSTED-CONNECTOR request (mcp/http.mjs) is a DIFFERENT TENANT from the process serving it, so its ctx is the
|
|
@@ -41,7 +48,8 @@ function headers(extra = {}) {
|
|
|
41
48
|
// same person. Presence of the ctx store IS "remote" (see isRemote below).
|
|
42
49
|
const prof = ctx ? (ctx.profile || '') : PROFILE; // omitted when unpinned so the key's saved brand wins server-side
|
|
43
50
|
const own = ctx ? (ctx.owner || '') : OWNER; // the wire name is x-hermoso-owner on BOTH twins — it is the server's header, not a brand
|
|
44
|
-
const
|
|
51
|
+
const tool = toolCtx.getStore()?.tool || '';
|
|
52
|
+
const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-hermoso-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...(tool ? { 'x-hermoso-tool': tool } : {}), ...extra };
|
|
45
53
|
const tok = ctx?.token || TOKEN;
|
|
46
54
|
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
47
55
|
return h;
|
|
@@ -53,11 +61,37 @@ async function unwrap(res) {
|
|
|
53
61
|
try { body = await res.json(); } catch {}
|
|
54
62
|
if (!res.ok) {
|
|
55
63
|
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}`;
|
|
56
|
-
|
|
64
|
+
// `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
|
|
65
|
+
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
66
|
+
// throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
|
|
67
|
+
throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}) });
|
|
57
68
|
}
|
|
58
69
|
return body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Report an error that never reached our API. Fire-and-forget, bounded, and it can NEVER throw or recurse: it uses
|
|
74
|
+
* plain fetch (not apiPost, whose own failure would report itself forever) and swallows everything.
|
|
75
|
+
*/
|
|
76
|
+
let _reportedThisProcess = 0;
|
|
77
|
+
export function reportToolError(tool, err) {
|
|
78
|
+
try {
|
|
79
|
+
if (_reportedThisProcess++ > 200) return; // a client stuck in a retry loop must not become the traffic
|
|
80
|
+
const e = err && typeof err === 'object' ? err : {};
|
|
81
|
+
fetch(`${API_BASE}/api/errors/report`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: headers(),
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
op: String(tool || 'unknown').slice(0, 60),
|
|
86
|
+
errorClass: String(e.name || 'Error').slice(0, 40),
|
|
87
|
+
status: Number(e.status) || 0,
|
|
88
|
+
message: String(e.message || e).slice(0, 300),
|
|
89
|
+
...(e.connector ? { connector: String(e.connector).slice(0, 32) } : {}),
|
|
90
|
+
}),
|
|
91
|
+
}).catch(() => {});
|
|
92
|
+
} catch { /* an instrument never breaks the thing it measures */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
export async function apiGet(p, query) {
|
|
62
96
|
// URLSearchParams stringifies undefined/null as the LITERAL "undefined"/"null" — so an omitted optional param
|
|
63
97
|
// arrives as a truthy string and silently changes server behaviour. Live 2026-07-27: list_google_ads_campaigns
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// stdout is the JSON-RPC channel — NEVER print to it. All logging goes to stderr (console.error).
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
-
import { registerTools, MCP_INSTRUCTIONS } from './tools.mjs';
|
|
11
|
+
import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
|
|
12
12
|
import { API_BASE } from './client.mjs';
|
|
13
13
|
|
|
14
14
|
// instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
|
|
@@ -17,7 +17,13 @@ const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
|
17
17
|
instructions: MCP_INSTRUCTIONS,
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
registerTools
|
|
20
|
+
// Optional roster scoping, same groups as the hosted connector's ?tools= (see registerTools). A client that
|
|
21
|
+
// loads every tool definition eagerly spends ~154k tokens on the full roster; HERMOSO_TOOLS=channels,ads narrows it.
|
|
22
|
+
// An unknown group EXITS rather than silently serving all of them — a scoped connection you did not get is
|
|
23
|
+
// worse than one you were told you could not have.
|
|
24
|
+
const _scope = parseToolScope(process.env.HERMOSO_TOOLS);
|
|
25
|
+
if (_scope.error) { console.error(`[hermoso-mcp] ${_scope.error}`); process.exit(1); }
|
|
26
|
+
registerTools(server, { only: _scope.groups });
|
|
21
27
|
|
|
22
28
|
const transport = new StdioServerTransport();
|
|
23
29
|
await server.connect(transport);
|
package/mcp/http.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
18
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
|
-
import { registerTools, MCP_INSTRUCTIONS } from './tools.mjs';
|
|
19
|
+
import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
|
|
20
20
|
import { mcpCtx } from './client.mjs';
|
|
21
21
|
|
|
22
22
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
@@ -107,9 +107,19 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
107
107
|
const methods = arr.map((m) => m && m.method).filter((v) => typeof v === 'string');
|
|
108
108
|
return methods.length > 0 && methods.every((m) => PREAUTH_METHODS.has(m)); // a batch mixing in tools/call is NOT pre-auth
|
|
109
109
|
};
|
|
110
|
-
|
|
110
|
+
// `?tools=research,create` narrows the roster this connection advertises (see registerTools). Read here rather
|
|
111
|
+
// than inside registerTools so BOTH the anonymous discovery handshake and a real session honour the same query,
|
|
112
|
+
// and so an unknown group is refused at the door with the valid list instead of silently serving all 301.
|
|
113
|
+
// The scope is fixed at initialize and stored on the session: tools/list must not change under a live client.
|
|
114
|
+
function scopeFor(req, res) {
|
|
115
|
+
const { groups, error } = parseToolScope(req.query?.tools ?? req.headers['x-hermoso-tools']);
|
|
116
|
+
if (error) { res.status(400).json({ jsonrpc: '2.0', error: { code: -32602, message: error }, id: null }); return false; }
|
|
117
|
+
return { groups };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function serveAnonDiscovery(req, res, scope) {
|
|
111
121
|
const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
112
|
-
registerTools(server); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
122
|
+
registerTools(server, { only: scope?.groups }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
113
123
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
114
124
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
115
125
|
await server.connect(transport);
|
|
@@ -122,7 +132,11 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
122
132
|
const user = token ? await verifyBearer(token).catch(() => null) : null;
|
|
123
133
|
if (!user) {
|
|
124
134
|
// No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
|
|
125
|
-
if (req.method === 'POST' && isAllPreauth(req.body))
|
|
135
|
+
if (req.method === 'POST' && isAllPreauth(req.body)) {
|
|
136
|
+
const scope = scopeFor(req, res);
|
|
137
|
+
if (scope === false) return; // unknown group — already answered 400
|
|
138
|
+
return serveAnonDiscovery(req, res, scope).catch(() => { try { challenge(res); } catch {} });
|
|
139
|
+
}
|
|
126
140
|
return challenge(res);
|
|
127
141
|
}
|
|
128
142
|
|
|
@@ -140,9 +154,11 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
140
154
|
// Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
|
|
141
155
|
// answered here so we never pay 36 MB to build a server whose only job would be to reject the request.
|
|
142
156
|
if (!init) return needSession(res);
|
|
157
|
+
const scope = scopeFor(req, res);
|
|
158
|
+
if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
|
|
143
159
|
sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
|
|
144
160
|
const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
145
|
-
registerTools(server); // the SAME tools as stdio —
|
|
161
|
+
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
|
|
146
162
|
const transport = new StreamableHTTPServerTransport({
|
|
147
163
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
148
164
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|