hermoso 0.1.43 → 0.1.63

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
@@ -5,9 +5,24 @@ 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
- **262 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ <<<<<<< HEAD
9
+ <<<<<<< HEAD
10
+ **344 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
11
+ =======
12
+ **344 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
13
+ >>>>>>> worktree-agent-a78b27d27f3710d65
14
+ =======
15
+ **344 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
16
+ >>>>>>> worktree-agent-a74cec7ae929aa73c
9
17
  catalog with exact per-render credit costs plus the full capability map.
10
18
 
19
+ **It is not all-or-nothing.** Research, creation, publishing/scheduling and ads management are four *independent*
20
+ areas — no tool requires that you used another one first. Publish or schedule creative you already have and
21
+ generate nothing here (`upload_file` turns any local or external file into a URL every publish, schedule and
22
+ ad-build tool accepts); build and read campaigns on your own ad accounts with your own creative; research
23
+ competitors with no brand drafted and no channel connected; or generate a file with nothing connected at all and
24
+ just download it. Use the one piece you need, or all of it together.
25
+
11
26
  ## Instant: the hosted Claude.ai connector
12
27
 
13
28
  Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
@@ -46,7 +61,15 @@ Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
46
61
 
47
62
  Then ask your agent: *“Generate an image ad with Hermoso.”*
48
63
 
49
- ### What the 262 tools cover
64
+ <<<<<<< HEAD
65
+ <<<<<<< HEAD
66
+ ### What the 344 tools cover
67
+ =======
68
+ ### What the 344 tools cover
69
+ >>>>>>> worktree-agent-a78b27d27f3710d65
70
+ =======
71
+ ### What the 344 tools cover
72
+ >>>>>>> worktree-agent-a74cec7ae929aa73c
50
73
 
51
74
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
52
75
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -78,8 +101,8 @@ publish). `schedule_post` / `list_scheduled` / `cancel_scheduled` give you one c
78
101
  *X posting bills credits per API call (X charges per request); a post containing a link costs 13× one without.*
79
102
 
80
103
  **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**, **Pinterest Ads**, **Microsoft Advertising**
82
- and **ChatGPT Ads** (OpenAI's Advertiser API). Each has list + report + create + budget/status tools
104
+ change confirm-gated, on **Meta**, **Google Ads**, **LinkedIn Ads**, **Reddit Ads**, **Pinterest Ads**,
105
+ **Microsoft Advertising** and **ChatGPT Ads** (OpenAI's Advertiser API). Each has list + report + create + budget/status tools
83
106
  (e.g. `list_google_ads_campaigns`, `google_ads_report`, `create_google_ads_campaign`, `set_google_ads_budget`,
84
107
  `set_google_ads_status`). *X Ads are not supported — X posting only.*
85
108
 
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 h = { 'Content-Type': 'application/json', ...(prof ? { 'x-hermoso-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...extra };
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
- throw Object.assign(new Error(msg), { status: res.status });
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
@@ -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(server);
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
- async function serveAnonDiscovery(req, res) {
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)) return serveAnonDiscovery(req, res).catch(() => { try { challenge(res); } catch {} });
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 — but here every /api call they make carries this user's token
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, ''),