hermoso 0.1.113 → 0.1.139
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 +18 -2
- package/bin/hermoso.mjs +204 -1
- package/mcp/hermoso-mcp.mjs +3 -3
- package/mcp/http.mjs +2 -2
- package/mcp/registry.mjs +172 -0
- package/mcp/tools.mjs +3176 -102
- package/package.json +16 -3
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
|
+
**681 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
|
+
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
12
|
+
Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in
|
|
13
|
+
Google Merchant Center. Publishing and scheduling: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn,
|
|
14
|
+
Pinterest, Bluesky and Telegram. Ad research: the Meta, Google and LinkedIn ad libraries plus organic TikTok,
|
|
15
|
+
Instagram, YouTube, Threads and Reddit. Analytics: Google Analytics 4, Google Search Console and every
|
|
16
|
+
connected platform's own post and campaign insights. Files: Google Drive, Sheets, Docs and OneDrive.
|
|
17
|
+
|
|
11
18
|
**It is not all-or-nothing.** Research, creation, publishing/scheduling and ads management are four *independent*
|
|
12
19
|
areas — no tool requires that you used another one first. Publish or schedule creative you already have and
|
|
13
20
|
generate nothing here (`upload_file` turns any local or external file into a URL every publish, schedule and
|
|
@@ -53,7 +60,7 @@ Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
|
|
|
53
60
|
|
|
54
61
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
55
62
|
|
|
56
|
-
### What the
|
|
63
|
+
### What the 681 tools cover
|
|
57
64
|
|
|
58
65
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
59
66
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -97,6 +104,15 @@ change confirm-gated, on **eleven** platforms: **Meta**, **Google Ads**, **Linke
|
|
|
97
104
|
`set_google_ads_status`). *Snapchat needs one extra step the others do not: an ad points at a CREATIVE, and every
|
|
98
105
|
Snapchat creative must carry a Public Profile id — build it with `upload_snapchat_ads_creative`.*
|
|
99
106
|
|
|
107
|
+
**Feed the shopping surfaces** — **Google Merchant Center** is the catalog a retail Performance Max or Shopping
|
|
108
|
+
campaign advertises (`create_google_ads_performance_max_campaign` takes a `merchantCenterId`), and you manage it
|
|
109
|
+
from here: accounts and account status, data sources, product upsert / update / delete, per-region inventory,
|
|
110
|
+
quota, `merchant_report` for product-level performance, notifications and conversion sources, plus the disapproval
|
|
111
|
+
loop — `list_merchant_issues` says what is wrong and `merchant_issue_help` returns Google's own documented fix.
|
|
112
|
+
*Promotions need the merchant's own enrolment in Google's promotions program; without it Google refuses that
|
|
113
|
+
sub-API outright.* **Microsoft Merchant Center** is covered on the same shape (stores, catalogs, products, issues)
|
|
114
|
+
for Bing Shopping.
|
|
115
|
+
|
|
100
116
|
**Measure what the ads achieved** — Google Analytics 4 closes the loop. Every other connector here reports what an
|
|
101
117
|
ad *cost*; this is the one that reports what it *did*. `analytics_report` breaks sessions, users, conversions and
|
|
102
118
|
revenue down by channel, source/medium, campaign, landing page, country, device or date, so the campaign Hermoso
|
package/bin/hermoso.mjs
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
// hermoso create --brand Flourish --product "protein pancakes" --format image
|
|
10
10
|
// hermoso generate image --prompt "…" --ref ./bag.png --wait
|
|
11
11
|
//
|
|
12
|
+
// EVERY tool the MCP server registers is also callable here. The terminal gets the same product as the connector,
|
|
13
|
+
// reached a cheaper way:
|
|
14
|
+
// hermoso tools # every tool, name + one line, grouped
|
|
15
|
+
// hermoso tools --group ads --search reddit # narrow it
|
|
16
|
+
// hermoso tools post_to_x # one tool's full schema
|
|
17
|
+
// hermoso call post_to_x --json '{"text":"hello"}' # run it
|
|
18
|
+
// The curated subcommands above stay as ergonomics for the common path; `call` is the ceiling.
|
|
19
|
+
//
|
|
12
20
|
// Auth today: none locally (the server resolves the dev account). `hermoso auth login --token <t>` stores a Bearer
|
|
13
21
|
// for when real auth lands — the seam, not a requirement.
|
|
14
22
|
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
|
|
@@ -100,7 +108,12 @@ async function main() {
|
|
|
100
108
|
console.log(`recipes: ${(d.recipes || []).map(r => r.id).join(', ')}`);
|
|
101
109
|
return;
|
|
102
110
|
}
|
|
103
|
-
case 'credits': { const d = await api.apiGet('/api/credits');
|
|
111
|
+
case 'credits': { const d = await api.apiGet('/api/credits');
|
|
112
|
+
// accountBalance = the caller's Hermoso credits (authoritative when authed); balance = the local-dev usage
|
|
113
|
+
// pill. Reading only `balance` printed "Balance: undefined credits" against prod for every signed-in user
|
|
114
|
+
// (measured 2026-08-19). Same expression the hermoso_credits MCP tool uses, so the two cannot disagree.
|
|
115
|
+
const bal = d.accountBalance ?? d.balance;
|
|
116
|
+
return out(`Balance: ${bal ?? '—'} credits`, d); }
|
|
104
117
|
case 'brand': {
|
|
105
118
|
if (sub !== 'draft') return die('usage: hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
|
|
106
119
|
const body = flags.domain ? { domain: flags.domain } : flags.description ? { description: flags.description } : flags.social ? { socialHandle: flags.social, platform: flags.platform || 'instagram' } : null;
|
|
@@ -157,7 +170,89 @@ async function main() {
|
|
|
157
170
|
}
|
|
158
171
|
case 'research': { const q = sub || flags.query; if (!q) return die('usage: hermoso research "<request>"'); const d = await api.apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: q }] }); if (flags.json) return console.log(JSON.stringify(d, null, 2)); console.log(d.reply || ''); console.log(`\n(${(d.results || []).length} ads found)`); return; }
|
|
159
172
|
case 'fetch': { const url = sub; if (!url) return die('usage: hermoso fetch <url> [--out <name>]'); const r = await fetch(`${api.API_BASE}/api/download?url=${encodeURIComponent(url)}`); if (!r.ok) return die(`download failed (HTTP ${r.status})`); const buf = Buffer.from(await r.arrayBuffer()); const name = flags.out || path.basename(url.split(/[?#]/)[0]) || 'asset'; await writeFile(name, buf); return console.log(`✓ saved ${name} (${buf.length} bytes)`); }
|
|
173
|
+
// ── TELLING US SOMETHING IS BROKEN OR MISSING ──────────────────────────────────────────────────────────
|
|
174
|
+
// `report_bug` / `request_feature` are reachable through the generic passthrough like everything else, and
|
|
175
|
+
// these two shortcuts exist anyway because of WHEN they get reached for: mid-task, right after something has
|
|
176
|
+
// just failed. `hermoso call report_bug --json '{"summary":"…","details":"…"}'` is a lot of ceremony at that
|
|
177
|
+
// moment, and a report that does not get written is the one case this whole channel exists to prevent.
|
|
178
|
+
//
|
|
179
|
+
// NOTHING IS INVENTED. With no --details, the text you typed becomes the details and its FIRST SENTENCE
|
|
180
|
+
// becomes the summary — your own words in both fields, under a rule stated here and in --help. It never
|
|
181
|
+
// writes a sentence you did not.
|
|
182
|
+
case 'bug': case 'feature': {
|
|
183
|
+
const tool = group === 'bug' ? 'report_bug' : 'request_feature';
|
|
184
|
+
const text = [sub, ...pos.slice(2)].filter(Boolean).join(' ').trim() || String(flags.summary || '').trim();
|
|
185
|
+
if (!text) return die(`usage: hermoso ${group} "what happened" [--details "…"]${group === 'bug' ? ' [--severity low|medium|high]' : ''}`);
|
|
186
|
+
const firstSentence = (text.split(/(?<=[.!?])\s/)[0] || text).trim();
|
|
187
|
+
const preset = {
|
|
188
|
+
summary: String(flags.summary || firstSentence).slice(0, 200),
|
|
189
|
+
details: String(flags.details || text),
|
|
190
|
+
...(group === 'bug' && flags.severity ? { severity: String(flags.severity) } : {}),
|
|
191
|
+
};
|
|
192
|
+
const reg = await import('../mcp/registry.mjs');
|
|
193
|
+
return await runTool(reg, tool, flags, [], preset);
|
|
194
|
+
}
|
|
195
|
+
// ── FULL TOOL SURFACE ──────────────────────────────────────────────────────────────────────────────────
|
|
196
|
+
// `tools` is what replaces a tool manifest: the agent greps this list for the one tool it needs and reads
|
|
197
|
+
// that schema alone, instead of carrying several hundred schemas before the user has said anything.
|
|
198
|
+
case 'tools': {
|
|
199
|
+
const reg = await import('../mcp/registry.mjs');
|
|
200
|
+
const name = sub;
|
|
201
|
+
if (name) return await printToolSchema(reg, name, flags);
|
|
202
|
+
const inv = reg.inventory();
|
|
203
|
+
const q = String(flags.search || flags.grep || '').toLowerCase();
|
|
204
|
+
const wantGroup = flags.group ? String(flags.group).toLowerCase() : '';
|
|
205
|
+
if (wantGroup && !reg.TOOL_GROUP_NAMES.includes(wantGroup)) {
|
|
206
|
+
// Refused by name, never quietly ignored — a filter that silently matched everything would hand back the
|
|
207
|
+
// whole roster to someone who asked for one slice and believed they got it.
|
|
208
|
+
return die(`Unknown group "${wantGroup}". Groups: ${reg.TOOL_GROUP_NAMES.join(', ')}.`);
|
|
209
|
+
}
|
|
210
|
+
// Sorted by group (in the order enable_tools names them) then by name, so a group heads its own block
|
|
211
|
+
// exactly once. Registration order interleaves the sections and prints `core` four times.
|
|
212
|
+
const order = (g) => { const i = reg.TOOL_GROUP_NAMES.indexOf(g); return i < 0 ? 99 : i; };
|
|
213
|
+
const rows = inv.filter((t) => (!wantGroup || t.group === wantGroup)
|
|
214
|
+
&& (!q || t.name.includes(q) || t.description.toLowerCase().includes(q) || (t.title || '').toLowerCase().includes(q)))
|
|
215
|
+
.sort((a, b) => order(a.group) - order(b.group) || a.name.localeCompare(b.name));
|
|
216
|
+
if (flags.json === true) return console.log(JSON.stringify(rows, null, 2));
|
|
217
|
+
if (flags.names) return console.log(rows.map((t) => t.name).join('\n'));
|
|
218
|
+
if (!rows.length) return console.log(`No tool matches${wantGroup ? ` in ${wantGroup}` : ''}${q ? ` "${q}"` : ''}. Try: hermoso tools`);
|
|
219
|
+
const counts = {};
|
|
220
|
+
for (const t of inv) counts[t.group] = (counts[t.group] || 0) + 1;
|
|
221
|
+
console.log(`${inv.length} tools · ${reg.TOOL_GROUP_NAMES.map((g) => `${g}(${counts[g] || 0})`).join(' ')}`);
|
|
222
|
+
if (rows.length !== inv.length) console.log(`showing ${rows.length}`);
|
|
223
|
+
const width = Math.max(40, (process.stdout.columns || 100) - 30);
|
|
224
|
+
let last = null;
|
|
225
|
+
for (const t of rows) {
|
|
226
|
+
if (t.group !== last) {
|
|
227
|
+
// The group's own one-line purpose, straight from the table `enable_tools` uses, so an agent scanning
|
|
228
|
+
// for where to look is reading the same description the connector shows.
|
|
229
|
+
const blurb = reg.TOOL_GROUPS[t.group] || '';
|
|
230
|
+
console.log(`\n${t.group}${blurb ? ` ${blurb}` : ''}`);
|
|
231
|
+
last = t.group;
|
|
232
|
+
}
|
|
233
|
+
const one = t.description.split(/(?<=[.!?])\s|\n/)[0].trim();
|
|
234
|
+
console.log(` ${t.name.padEnd(34)} ${one.length > width ? one.slice(0, width - 1) + '…' : one}`);
|
|
235
|
+
}
|
|
236
|
+
console.log(`\nhermoso tools <name> one tool's full schema`);
|
|
237
|
+
console.log(`hermoso call <name> --json '{…}' run it`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// `call` runs ANY registered tool through the same handler, the same argument validation and the same
|
|
241
|
+
// confirm/spend gates the MCP twins use. There is no second implementation here to drift or to bypass.
|
|
242
|
+
case 'call': {
|
|
243
|
+
if (!sub) return die(`usage: hermoso call <tool_name> --json '{"key":"value"}' · hermoso tools lists them`);
|
|
244
|
+
const reg = await import('../mcp/registry.mjs');
|
|
245
|
+
return await runTool(reg, sub, flags, pos.slice(2));
|
|
246
|
+
}
|
|
160
247
|
default:
|
|
248
|
+
{
|
|
249
|
+
// A BARE TOOL NAME IS A CALL. `hermoso post_to_x --text hi` is the same thing as
|
|
250
|
+
// `hermoso call post_to_x --text hi`, so an agent that read a name out of `hermoso tools` can just run it.
|
|
251
|
+
// Checked against the REAL registry, so nothing has to be listed here and kept in step.
|
|
252
|
+
if (group) {
|
|
253
|
+
const reg = await import('../mcp/registry.mjs');
|
|
254
|
+
if (reg.inventory().some((t) => t.name === group)) return await runTool(reg, group, flags, pos.slice(1));
|
|
255
|
+
}
|
|
161
256
|
console.log(`hermoso <command>
|
|
162
257
|
auth login [--url <base>] [--token <t>] credits capabilities
|
|
163
258
|
brand draft (--domain|--description|--social …) create --brand --product [--format]
|
|
@@ -165,12 +260,120 @@ async function main() {
|
|
|
165
260
|
jobs list | jobs get <id> [--wait] competitors <domain>
|
|
166
261
|
ads pull (--company|--domain) research "<request>"
|
|
167
262
|
fetch <url> [--out] mcp (run the stdio MCP server)
|
|
263
|
+
bug "what broke" [--details] [--severity] feature "what you need" [--details]
|
|
168
264
|
version
|
|
265
|
+
|
|
266
|
+
The full tool surface (every tool the MCP server has):
|
|
267
|
+
tools [--group <g>] [--search <q>] [--names] list every tool, name + one line
|
|
268
|
+
tools <name> that tool's full input schema
|
|
269
|
+
call <name> --json '{"key":"value"}' run it
|
|
270
|
+
<name> --key value same thing, shorter
|
|
169
271
|
add --json to any command for machine output.`);
|
|
272
|
+
if (group) console.error(`\n✗ Unknown command "${group}". It is not a subcommand and not a tool name. Try: hermoso tools --search ${JSON.stringify(String(group).slice(0, 24))}`);
|
|
273
|
+
if (group) process.exitCode = 1;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
170
276
|
}
|
|
171
277
|
} catch (e) { die(e?.message || String(e)); }
|
|
172
278
|
}
|
|
173
279
|
|
|
280
|
+
// ---- the full-tool-surface commands ---------------------------------------------------------------------------
|
|
281
|
+
// Flags THIS command consumes. Everything else is a tool argument, so the list stays as short as it can be:
|
|
282
|
+
// `--raw` is a real property on generate_image / generate_text / generate_video, and reserving it would make three
|
|
283
|
+
// tools uncallable from the shorthand. A tool that ever needs a `--json` or `--args` argument can still pass it
|
|
284
|
+
// inside --args '{"json":…}'.
|
|
285
|
+
const CALL_FLAGS = new Set(['json', 'args', 'args-file', 'structured']);
|
|
286
|
+
|
|
287
|
+
async function readArgsPayload(flags) {
|
|
288
|
+
let src = null;
|
|
289
|
+
if (typeof flags.args === 'string') src = flags.args;
|
|
290
|
+
else if (typeof flags.json === 'string') src = flags.json; // `--json '{…}'`; a bare `--json` means machine output
|
|
291
|
+
if (flags['args-file']) src = await readFile(String(flags['args-file']), 'utf8');
|
|
292
|
+
if (src === '-') src = await new Promise((res, rej) => { let b = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { b += c; }); process.stdin.on('end', () => res(b)); process.stdin.on('error', rej); });
|
|
293
|
+
if (src == null || String(src).trim() === '') return {};
|
|
294
|
+
let parsed;
|
|
295
|
+
try { parsed = JSON.parse(src); }
|
|
296
|
+
catch (e) { throw new Error(`arguments are not valid JSON: ${e.message}. Wrap the whole object in single quotes: --json '{"key":"value"}'`); }
|
|
297
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('arguments must be a JSON object, e.g. --json \'{"key":"value"}\'');
|
|
298
|
+
return parsed;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function printToolSchema(reg, name, flags) {
|
|
302
|
+
const { client, close } = await reg.openRegistry();
|
|
303
|
+
try {
|
|
304
|
+
const all = await reg.listTools(client);
|
|
305
|
+
const tool = all.find((t) => t.name === name);
|
|
306
|
+
if (!tool) return die(suggestTool(reg, name));
|
|
307
|
+
const group = reg.inventory().find((t) => t.name === name)?.group || '?';
|
|
308
|
+
if (flags.json === true) return console.log(JSON.stringify({ ...tool, group }, null, 2));
|
|
309
|
+
console.log(`${tool.name} [${group}]${tool.title ? ' · ' + tool.title : ''}`);
|
|
310
|
+
console.log(`\n${tool.description || ''}\n`);
|
|
311
|
+
const fields = reg.schemaFields(tool.inputSchema);
|
|
312
|
+
if (!fields.length) console.log('Arguments: none.');
|
|
313
|
+
else {
|
|
314
|
+
console.log('Arguments:');
|
|
315
|
+
const width = Math.max(40, (process.stdout.columns || 100) - 40);
|
|
316
|
+
for (const f of fields) {
|
|
317
|
+
const head = ` ${f.required ? '*' : ' '} ${f.name}${f.type ? ` <${f.type}>` : ''}`;
|
|
318
|
+
const d = f.enum ? `one of: ${f.enum.join(' | ')}` : f.description;
|
|
319
|
+
console.log(`${head.padEnd(38)} ${d.length > width ? d.slice(0, width - 1) + '…' : d}`);
|
|
320
|
+
}
|
|
321
|
+
console.log(' (* = required)');
|
|
322
|
+
}
|
|
323
|
+
const req = fields.filter((f) => f.required);
|
|
324
|
+
const example = Object.fromEntries(req.map((f) => [f.name, f.enum ? f.enum[0] : f.type === 'number' || f.type === 'integer' ? 0 : f.type === 'boolean' ? true : f.type === 'array' ? [] : '…']));
|
|
325
|
+
console.log(`\nhermoso call ${tool.name} --json '${JSON.stringify(example)}'`);
|
|
326
|
+
console.log(`hermoso tools ${tool.name} --json the raw JSON Schema`);
|
|
327
|
+
} finally { await close(); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function suggestTool(reg, name) {
|
|
331
|
+
const inv = reg.inventory();
|
|
332
|
+
const needle = String(name).toLowerCase();
|
|
333
|
+
const near = inv.filter((t) => t.name.includes(needle) || needle.includes(t.name.split('_')[0])).slice(0, 6).map((t) => t.name);
|
|
334
|
+
return `No tool named "${name}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Run: hermoso tools --search ${JSON.stringify(needle.slice(0, 24))}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// RUN A TOOL. The arguments are assembled here and passed through UNTOUCHED — nothing is defaulted, injected or
|
|
338
|
+
// dropped on the way. That is what keeps a confirm-gated tool confirm-gated from a terminal: the gate lives in the
|
|
339
|
+
// handler, and the CLI is not allowed to answer it on the caller's behalf.
|
|
340
|
+
// `preset` is the ONLY way anything reaches the arguments other than the caller's own --json/--flags, and it is
|
|
341
|
+
// supplied by exactly two call sites: the `bug` and `feature` shortcuts, which shape text the user typed into the
|
|
342
|
+
// two fields those tools require. A caller's own value always wins over it. It is deliberately not a general
|
|
343
|
+
// mechanism — every other command either passes the arguments through untouched or does not use runTool at all,
|
|
344
|
+
// which is what keeps a confirm-gated tool something only the caller can answer.
|
|
345
|
+
async function runTool(reg, name, flags, extraPos = [], preset = null) {
|
|
346
|
+
const { client, close } = await reg.openRegistry();
|
|
347
|
+
try {
|
|
348
|
+
const all = await reg.listTools(client);
|
|
349
|
+
const tool = all.find((t) => t.name === name);
|
|
350
|
+
if (!tool) return die(suggestTool(reg, name));
|
|
351
|
+
const args = { ...(preset || {}), ...(await readArgsPayload(flags)) };
|
|
352
|
+
// Per-flag arguments, coerced against the TOOL'S OWN schema and refused by name when unrecognised. The SDK
|
|
353
|
+
// builds a non-strict object, so an unknown key would otherwise be stripped in silence — a typo'd `--confrim`
|
|
354
|
+
// would send an unconfirmed call and read as a clean success.
|
|
355
|
+
const bad = [];
|
|
356
|
+
for (const [k, v] of Object.entries(flags)) {
|
|
357
|
+
if (CALL_FLAGS.has(k)) continue;
|
|
358
|
+
const c = reg.coerceArg(tool.inputSchema, k, v);
|
|
359
|
+
if (c.ok) { args[k] = c.value; continue; }
|
|
360
|
+
bad.push(c.unknown ? `--${k} is not an argument of ${name}` : c.message);
|
|
361
|
+
}
|
|
362
|
+
if (extraPos.length) bad.push(`unexpected value${extraPos.length > 1 ? 's' : ''} ${extraPos.map((v) => JSON.stringify(v)).join(', ')} . Every argument is a --flag or goes inside --json`);
|
|
363
|
+
if (bad.length) return die(`${bad.join('\n ')}\n Run: hermoso tools ${name}`);
|
|
364
|
+
|
|
365
|
+
const res = await client.callTool({ name, arguments: args });
|
|
366
|
+
const text = (res.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
|
367
|
+
if (flags.json === true) console.log(JSON.stringify(res, null, 2));
|
|
368
|
+
else if (flags.structured) console.log(JSON.stringify(res.structuredContent ?? null, null, 2));
|
|
369
|
+
else if (text) console.log(text);
|
|
370
|
+
else console.log(JSON.stringify(res.structuredContent ?? res, null, 2));
|
|
371
|
+
// A TOOL THAT FAILED MUST EXIT NON-ZERO. `isError` is how MCP reports a refused gate, a missing connector or a
|
|
372
|
+
// provider failure, and a shelling-out agent reads the exit code before it reads the text.
|
|
373
|
+
if (res.isError) process.exitCode = 1;
|
|
374
|
+
} finally { await close(); }
|
|
375
|
+
}
|
|
376
|
+
|
|
174
377
|
// ---- browser sign-in (loopback OAuth, like gh/firebase): spin a 127.0.0.1 server, open the app's cli-auth page,
|
|
175
378
|
// which mints an agent key and redirects the browser back to our loopback with ?key=&state=. Nothing is pasted;
|
|
176
379
|
// the key transits only the user's own machine. Times out after 3 min. Returns the hmk_ key, or null. ----
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -18,9 +18,9 @@ const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
// Roster scoping, same groups as the hosted connector's ?tools= (see registerTools). The DEFAULT is every group
|
|
21
|
-
// except `ads`
|
|
22
|
-
// ~
|
|
23
|
-
// HERMOSO_TOOLS=all restores the full roster; HERMOSO_TOOLS=create,channels narrows it further.
|
|
21
|
+
// except `ads` and `analytics` (OPT_IN_TOOL_GROUPS) — together ~254k of the ~365k full roster, so the default is
|
|
22
|
+
// ~112k. Both are held out on SIZE alone, and nothing is lost: `enable_tools` switches either on mid-session with
|
|
23
|
+
// no reconnect. HERMOSO_TOOLS=all restores the full roster; HERMOSO_TOOLS=create,channels narrows it further.
|
|
24
24
|
// An unknown group EXITS rather than silently serving all of them — a scoped connection you did not get is
|
|
25
25
|
// worse than one you were told you could not have.
|
|
26
26
|
// Both env names are read: HERMOSO_TOOLS is the current prefix, HEIST_TOOLS the pre-rebrand one that is live in
|
package/mcp/http.mjs
CHANGED
|
@@ -110,8 +110,8 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
110
110
|
// `?tools=research,create` narrows the roster this connection advertises (see registerTools). Read here rather
|
|
111
111
|
// than inside registerTools so BOTH the anonymous discovery handshake and a real session honour the same query,
|
|
112
112
|
// and so an unknown group is refused at the door with the valid list instead of silently serving every group.
|
|
113
|
-
// ABSENT, the DEFAULT is every group except `ads` (
|
|
114
|
-
//
|
|
113
|
+
// ABSENT, the DEFAULT is every group except `ads` and `analytics` (OPT_IN_TOOL_GROUPS) — together ~254k of the
|
|
114
|
+
// ~365k full roster, so an eagerly-loading client gets ~112k. `?tools=all` restores the full roster.
|
|
115
115
|
// The scope fixed here is the STARTING roster, not a cage: `enable_tools` widens it mid-session and the SDK
|
|
116
116
|
// notifies the client. That is deliberate — the old comment's "tools/list must not change under a live client"
|
|
117
117
|
// was the right instinct for a scope the SERVER changes silently, and the wrong one for a change the CLIENT
|
package/mcp/registry.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// THE CLI'S ROUTE INTO THE FULL TOOL ROSTER — one implementation, shared with the stdio MCP twin.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The CLI shipped 12 hand-written subcommands against a roster of several hundred registered
|
|
4
|
+
// tools, so a terminal agent had to choose between the full surface with a fat tool manifest (MCP) and a cheap
|
|
5
|
+
// manifest with a sliver of the product (CLI). That is the [[mcp-is-the-complete-surface]] defect one level down:
|
|
6
|
+
// a capability reachable on one agent surface and not another. `hermoso tools` and `hermoso call` close it — the
|
|
7
|
+
// agent greps for the one tool it needs and pays for that schema alone, instead of carrying every schema up front.
|
|
8
|
+
//
|
|
9
|
+
// IT IS THE SAME CODE PATH, NOT A SECOND SET OF RULES. We build a real McpServer, register the real tools with the
|
|
10
|
+
// real registerTools(), and drive it with a real MCP Client over an in-memory transport. So argument validation,
|
|
11
|
+
// confirm gates, spend gates, workspace scoping, result shaping and error text are byte-identical to what a Claude
|
|
12
|
+
// Code / Cursor / claude.ai session gets. There is no CLI-side reimplementation that could drift, and in
|
|
13
|
+
// particular no CLI-side way around a gate: a confirm-gated tool is confirm-gated from a terminal too, because it
|
|
14
|
+
// is literally the same handler.
|
|
15
|
+
//
|
|
16
|
+
// FREE AND OFFLINE UNTIL A TOOL IS CALLED. Registration touches no network; only the tool's own handler does.
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
19
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
20
|
+
import { registerTools, MCP_INSTRUCTIONS, TOOL_GROUP_NAMES, TOOL_GROUPS } from './tools.mjs';
|
|
21
|
+
|
|
22
|
+
export { TOOL_GROUP_NAMES, TOOL_GROUPS };
|
|
23
|
+
|
|
24
|
+
// THE CLI ALWAYS REGISTERS EVERY GROUP, AND DELIBERATELY IGNORES HERMOSO_TOOLS.
|
|
25
|
+
//
|
|
26
|
+
// The MCP default roster leaves `ads` out because a client that loads every schema eagerly cannot afford it — that
|
|
27
|
+
// is a MANIFEST cost, and a shelling-out agent pays no manifest at all. Honouring HERMOSO_TOOLS here would mean an
|
|
28
|
+
// agent whose MCP config narrows the roster gets `hermoso call create_google_ads_campaign` → "no such tool", which
|
|
29
|
+
// is the exact parity hole this file was written to close. Scoping was never an authorization boundary either
|
|
30
|
+
// (`enable_tools` turns any group on mid-session with no reconnect), so ignoring it takes nothing away.
|
|
31
|
+
const ALL_GROUPS = [...TOOL_GROUP_NAMES];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register every tool against an in-process MCP server and return a connected client.
|
|
35
|
+
* Callers MUST await close() — the transport keeps the event loop alive otherwise.
|
|
36
|
+
*/
|
|
37
|
+
export async function openRegistry() {
|
|
38
|
+
const server = new McpServer({ name: 'hermoso-cli', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
39
|
+
registerTools(server, { only: ALL_GROUPS });
|
|
40
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
41
|
+
const client = new Client({ name: 'hermoso-cli', version: '1.0.0' }, { capabilities: {} });
|
|
42
|
+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
43
|
+
return {
|
|
44
|
+
client,
|
|
45
|
+
async close() { try { await client.close(); } catch {} try { await server.close(); } catch {} },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every tool with its full JSON Schema, exactly as an MCP client sees it. Pages through the cursor. */
|
|
50
|
+
export async function listTools(client) {
|
|
51
|
+
const out = [];
|
|
52
|
+
let cursor;
|
|
53
|
+
do {
|
|
54
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
55
|
+
out.push(...(page.tools || []));
|
|
56
|
+
cursor = page.nextCursor;
|
|
57
|
+
} while (cursor);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── GROUP MEMBERSHIP IS DERIVED BY RUNNING THE REGISTRY, NEVER BY A LIST ────────────────────────────────────────
|
|
62
|
+
// A tool's group is the `server.group()` marker it was written under, and registerTools keeps that map private. So
|
|
63
|
+
// we ask the only question it answers from outside: register once per group and see which tools come back ENABLED.
|
|
64
|
+
// A hand-kept name list would go stale the day someone adds a tool, which is the failure this repo has shipped
|
|
65
|
+
// more than once ([[prompt-rosters-go-stale]]).
|
|
66
|
+
//
|
|
67
|
+
// The stub MUST implement disable(): out-of-scope tools are registered and then disabled rather than skipped, so a
|
|
68
|
+
// stub that throws on disable() reports every group as the full roster and every answer below is silently wrong.
|
|
69
|
+
function rosterFor(only) {
|
|
70
|
+
const handles = new Map();
|
|
71
|
+
const mk = (name) => {
|
|
72
|
+
const h = { enabled: true, enable() { h.enabled = true; return h; }, disable() { h.enabled = false; return h; } };
|
|
73
|
+
if (name != null) handles.set(name, h);
|
|
74
|
+
return h;
|
|
75
|
+
};
|
|
76
|
+
registerTools({ registerTool: (n, def) => { const h = mk(n); h.def = def; return h; }, registerResource: () => mk(null) },
|
|
77
|
+
only ? { only } : {});
|
|
78
|
+
return handles;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A tool registered above the first `server.group()` marker has group `undefined` and belongs to no scoped MCP
|
|
82
|
+
// roster. It is still callable from the CLI (which registers every group), so it is REPORTED as `?` rather than
|
|
83
|
+
// quietly filed under a real group — an unreachable tool and an ungrouped one are different problems and must not
|
|
84
|
+
// look the same. Pulled out as its own function so a check can RUN it: no tool is ungrouped today
|
|
85
|
+
// (`mcp-tool-scope-check` fails if one ever is), so the branch is unreachable through `inventory()` and a check
|
|
86
|
+
// that could only assert it through the roster would be asserting nothing.
|
|
87
|
+
export const groupLabel = (g) => (g === undefined || g === null ? '?' : g);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The whole inventory in one pass-set: every registered name, its group, its title/description.
|
|
91
|
+
*
|
|
92
|
+
* ONE registration per group plus one for the full roster — 8 passes, ~0.9s, and every number in it is measured
|
|
93
|
+
* rather than declared.
|
|
94
|
+
*/
|
|
95
|
+
export function inventory() {
|
|
96
|
+
const full = rosterFor(ALL_GROUPS);
|
|
97
|
+
const groupOf = Object.create(null);
|
|
98
|
+
for (const g of ALL_GROUPS) {
|
|
99
|
+
for (const [name, h] of rosterFor([g])) {
|
|
100
|
+
// `core` rides in every scoped roster (a roster without discovery is undriveable), so the FIRST group that
|
|
101
|
+
// claims a tool wins and core is asked first — otherwise every core tool would be relabelled by the last
|
|
102
|
+
// group to include it.
|
|
103
|
+
if (h.enabled && groupOf[name] === undefined) groupOf[name] = g;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...full.entries()].map(([name, h]) => ({
|
|
107
|
+
name,
|
|
108
|
+
group: groupLabel(groupOf[name]),
|
|
109
|
+
title: h.def?.title || '',
|
|
110
|
+
description: String(h.def?.description || ''),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── ARGUMENTS ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
// Coerce a --flag string against the tool's own JSON Schema. The CLI can only carry strings, and the MCP handler
|
|
116
|
+
// is strict about types, so `--durationSeconds 30` has to become a number somewhere. Doing it from the schema
|
|
117
|
+
// means the rules come from the tool, not from a guess here.
|
|
118
|
+
//
|
|
119
|
+
// AN UNKNOWN FLAG IS REFUSED BY NAME, NEVER DROPPED. The SDK builds a non-strict z.object, so an unrecognised key
|
|
120
|
+
// is silently stripped: a typo'd `--confrim true` would send a call with no confirmation and read as success.
|
|
121
|
+
export function coerceArg(schema, key, raw) {
|
|
122
|
+
const prop = schema?.properties?.[key];
|
|
123
|
+
if (!prop) return { unknown: true };
|
|
124
|
+
const type = Array.isArray(prop.type) ? prop.type.find((t) => t !== 'null') : prop.type;
|
|
125
|
+
if (raw === true) { // a bare `--flag` with no value
|
|
126
|
+
if (type === 'boolean') return { ok: true, value: true };
|
|
127
|
+
return { message: `--${key} needs a value` };
|
|
128
|
+
}
|
|
129
|
+
const s = String(raw);
|
|
130
|
+
if (type === 'boolean') {
|
|
131
|
+
if (/^(true|yes|1|on)$/i.test(s)) return { ok: true, value: true };
|
|
132
|
+
if (/^(false|no|0|off)$/i.test(s)) return { ok: true, value: false };
|
|
133
|
+
return { message: `--${key} expects true or false, got "${s}"` };
|
|
134
|
+
}
|
|
135
|
+
if (type === 'number' || type === 'integer') {
|
|
136
|
+
const n = Number(s);
|
|
137
|
+
if (!Number.isFinite(n)) return { message: `--${key} expects a number, got "${s}"` };
|
|
138
|
+
return { ok: true, value: n };
|
|
139
|
+
}
|
|
140
|
+
if (type === 'array') {
|
|
141
|
+
// JSON first so an array of objects is still expressible; comma-splitting is the convenience for the common
|
|
142
|
+
// array-of-strings case and must not silently mangle anything richer.
|
|
143
|
+
if (s.trim().startsWith('[')) { try { return { ok: true, value: JSON.parse(s) }; } catch { /* fall through to split */ } }
|
|
144
|
+
const items = Array.isArray(prop.items) ? prop.items[0] : prop.items;
|
|
145
|
+
const itemType = items?.type;
|
|
146
|
+
const parts = s.split(',').map((v) => v.trim()).filter(Boolean);
|
|
147
|
+
if (itemType === 'number' || itemType === 'integer') {
|
|
148
|
+
const nums = parts.map(Number);
|
|
149
|
+
if (nums.some((n) => !Number.isFinite(n))) return { message: `--${key} expects a list of numbers, got "${s}"` };
|
|
150
|
+
return { ok: true, value: nums };
|
|
151
|
+
}
|
|
152
|
+
if (itemType === 'object') return { message: `--${key} takes objects. Pass it inside --json '{"${key}":[…]}'` };
|
|
153
|
+
return { ok: true, value: parts };
|
|
154
|
+
}
|
|
155
|
+
if (type === 'object') {
|
|
156
|
+
try { return { ok: true, value: JSON.parse(s) }; }
|
|
157
|
+
catch { return { message: `--${key} takes an object. Pass it inside --json '{"${key}":{…}}'` }; }
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, value: s };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The properties a caller may pass, in schema order, with the required ones marked. */
|
|
163
|
+
export function schemaFields(schema) {
|
|
164
|
+
const req = new Set(schema?.required || []);
|
|
165
|
+
return Object.entries(schema?.properties || {}).map(([name, p]) => ({
|
|
166
|
+
name,
|
|
167
|
+
required: req.has(name),
|
|
168
|
+
type: Array.isArray(p.type) ? p.type.join('|') : (p.type || (p.anyOf ? 'any' : '')),
|
|
169
|
+
enum: p.enum || null,
|
|
170
|
+
description: String(p.description || ''),
|
|
171
|
+
}));
|
|
172
|
+
}
|