hermoso 0.1.449 → 0.1.450

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.
@@ -0,0 +1,111 @@
1
+ // AN ARGUMENT A TOOL DOES NOT TAKE IS NEVER SILENTLY DROPPED (2026-10-26, customer report).
2
+ //
3
+ // meta_post_insights was called with `platform: 'instagram'` instead of `target`. The MCP SDK parses arguments with
4
+ // the tool's zod object, which STRIPS unknown keys, so the handler never saw `platform`, defaulted to Facebook and read
5
+ // Facebook metrics for an Instagram media — a confident wrong answer, with nothing saying an argument was ignored.
6
+ // The same strip happens on /v1 (z.object) and the Studio agent reads `input.<name>` and nothing else.
7
+ //
8
+ // ONE RULE FOR EVERY SURFACE, applied where each surface parses: an argument the schema does not declare is named
9
+ // back to the caller, with the declared argument it most likely meant (modes: argGuardMode). The default refuses only a
10
+ // probable typo/synonym of a real argument and otherwise runs the call with the note first in its answer, because hosts
11
+ // and agent frameworks were never measured for what they add to `arguments` (the advertised schemas already say
12
+ // additionalProperties:false, and call_tool has refused every undeclared key since 2026-09-30). Every unknown key is
13
+ // logged by NAME (never value) so the blast radius of full `reject` is measured from production before anyone flips it.
14
+ // Keys starting with `_` are host metadata (`_meta`, `_hermoso…`) and are never named.
15
+ //
16
+ // Byte-identical in mcp/ and cli/mcp/ (the CLI ships rooted at cli/), and imported by server.js for /v1 and the Studio.
17
+
18
+ export const UNKNOWN_ARGS_SYM = Symbol.for('hermoso.unknownArgs');
19
+
20
+ /**
21
+ * 'typo' (default) | 'reject' | 'warn' | 'off' from the environment value. Anything unrecognised is `typo`:
22
+ * typo — REFUSED when an unknown key is a likely misspelling or synonym of a declared one (the customer's
23
+ * `platform` for `target`: running without it gives a confidently wrong answer); otherwise the call runs
24
+ * and its answer starts with the note;
25
+ * reject — every unknown key refuses the call (what call_tool has done since 2026-09-30);
26
+ * warn — never refuses, always notes; off — the old silent strip.
27
+ */
28
+ export function argGuardMode(v = (typeof process !== 'undefined' ? process.env?.HERMOSO_UNKNOWN_ARGS : '')) {
29
+ const s = String(v || '').trim().toLowerCase();
30
+ return s === 'reject' || s === 'off' || s === 'warn' ? s : 'typo';
31
+ }
32
+ /** Whether this mode refuses this set of unknown keys. */
33
+ export function argGuardRefuses(mode, unknown = [], declared = []) {
34
+ if (!unknown.length || mode === 'off' || mode === 'warn') return false;
35
+ if (mode === 'reject') return true;
36
+ return unknown.some((k) => !!didYouMean(k, declared));
37
+ }
38
+
39
+ /**
40
+ * The argument names a schema declares, or null when it accepts any key (so nothing can be called unknown).
41
+ * Accepts a zod raw shape ({name: zodType}), a zod object, or a JSON Schema object (the Studio's input_schema).
42
+ */
43
+ export function declaredArgKeys(schema) {
44
+ if (!schema || typeof schema !== 'object') return null;
45
+ // JSON Schema (checked AFTER zod: a zod v4 object also carries `type: 'object'`)
46
+ if (typeof schema.safeParse !== 'function' && (schema.type === 'object' || (schema.properties && typeof schema.properties === 'object'))) {
47
+ if (schema.additionalProperties === true || (schema.additionalProperties && typeof schema.additionalProperties === 'object')) return null;
48
+ return Object.keys(schema.properties || {});
49
+ }
50
+ // zod object
51
+ if (typeof schema.safeParse === 'function') {
52
+ const def = schema._zod?.def || schema._def || {};
53
+ const shape = typeof schema.shape === 'object' ? schema.shape : (typeof def.shape === 'function' ? def.shape() : def.shape);
54
+ if (!shape || typeof shape !== 'object') return null;
55
+ const cat = def.catchall;
56
+ const loose = (cat && (cat._zod?.def?.type || cat._def?.typeName) && !/never/i.test(String(cat._zod?.def?.type || cat._def?.typeName))) || def.unknownKeys === 'passthrough';
57
+ return loose ? null : Object.keys(shape);
58
+ }
59
+ // raw shape: every value is a zod type
60
+ const vals = Object.values(schema);
61
+ if (vals.length && vals.every((v) => v && typeof v === 'object' && typeof v.safeParse === 'function')) return Object.keys(schema);
62
+ if (!vals.length) return [];
63
+ return null;
64
+ }
65
+
66
+ /** The keys of `args` the schema does not declare (host `_` metadata excluded). [] when there are none or none can be judged. */
67
+ export function unknownArgs(declared, args) {
68
+ if (!Array.isArray(declared) || !args || typeof args !== 'object' || Array.isArray(args)) return [];
69
+ const known = new Set(declared);
70
+ return Object.keys(args).filter((k) => !known.has(k) && !k.startsWith('_'));
71
+ }
72
+
73
+ function lev(a, b) {
74
+ a = a.toLowerCase(); b = b.toLowerCase();
75
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
76
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
77
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
78
+ return d[a.length][b.length];
79
+ }
80
+ // Words agents reach for that name the same idea under a different argument (the customer's case is the first row).
81
+ const ALIASES = {
82
+ platform: ['target', 'platforms', 'channel', 'channels', 'network', 'source', 'sources'],
83
+ network: ['platform', 'target', 'channel'],
84
+ channel: ['channels', 'platform', 'target'],
85
+ count: ['limit', 'max', 'n'], max: ['limit'], max_results: ['limit'], maxResults: ['limit'], page_size: ['limit'], pageSize: ['limit'],
86
+ page: ['cursor'], next: ['cursor'], nextCursor: ['cursor'], after: ['cursor'],
87
+ text: ['message', 'caption', 'body'], message: ['text', 'caption'], caption: ['message', 'text'],
88
+ url: ['imageUrl', 'videoUrl', 'link', 'image', 'video'], image: ['imageUrl'], video: ['videoUrl'],
89
+ id: ['postId', 'mediaId', 'commentId', 'videoId', 'conversationId'],
90
+ };
91
+ /** The declared argument `key` most likely meant, or ''. */
92
+ export function didYouMean(key, declared = []) {
93
+ const k = String(key);
94
+ const alias = (ALIASES[k] || []).find((a) => declared.includes(a));
95
+ if (alias) return alias;
96
+ const lower = declared.find((d) => d.toLowerCase() === k.toLowerCase() || d.toLowerCase().replace(/_/g, '') === k.toLowerCase().replace(/_/g, ''));
97
+ if (lower) return lower;
98
+ let best = '', bd = Infinity;
99
+ for (const d of declared) { const x = lev(k, d); if (x < bd) { bd = x; best = d; } }
100
+ return bd <= Math.max(2, Math.floor(k.length / 3)) ? best : '';
101
+ }
102
+
103
+ /** The one sentence every surface prints. `rejected` words it as a refusal; otherwise as a note on a call that ran. */
104
+ export function unknownArgsNote(tool, unknown = [], declared = [], { rejected = false } = {}) {
105
+ if (!unknown.length) return '';
106
+ const named = unknown.map((k) => { const m = didYouMean(k, declared); return `\`${k}\`${m ? ` (did you mean \`${m}\`?)` : ''}`; }).join(', ');
107
+ const takes = declared.length ? ` ${tool || 'This tool'} takes: ${declared.join(', ')}.` : ` ${tool || 'This tool'} takes no arguments.`;
108
+ return rejected
109
+ ? `Unknown argument${unknown.length > 1 ? 's' : ''} ${named} for ${tool || 'this tool'} — nothing was run.${takes}`
110
+ : `⚠ Ignored unknown argument${unknown.length > 1 ? 's' : ''} ${named}: ${tool || 'this tool'} does not take ${unknown.length > 1 ? 'them' : 'it'}, so the call ran WITHOUT ${unknown.length > 1 ? 'them' : 'it'}. Check the answer below still matches what you asked, or call again with the right name.${takes}`;
111
+ }
package/mcp/client.mjs CHANGED
@@ -146,7 +146,7 @@ async function unwrap(res, { read = false } = {}) {
146
146
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
147
147
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
148
148
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
149
- throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}) }); // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
149
+ throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}) }); // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
150
150
  }
151
151
  // WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
152
152
  // sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
package/mcp/tools.mjs CHANGED
@@ -12,6 +12,7 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
12
12
  // The UTF-16 well-formedness law — see wellFormedServer() below for why it is applied here, and
13
13
  // ./well-formed.mjs for why that specifier is the only one that can work in a byte-identical twin.
14
14
  import { wellFormedValue, wellFormedString } from './well-formed.mjs';
15
+ import { UNKNOWN_ARGS_SYM, argGuardMode, argGuardRefuses, declaredArgKeys, didYouMean, unknownArgs, unknownArgsNote } from './arg-guard.mjs'; // an undeclared argument is named back, never silently stripped (2026-10-26)
15
16
  // WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
16
17
  // re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
17
18
  // twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
@@ -2085,6 +2086,15 @@ export function inboxReplyTarget(id) {
2085
2086
  * eight vendors disagree about all of them; what comes OUT is always the same, so an agent writes one loop.
2086
2087
  * Returns null when there is no id or no text — an item you can neither quote nor answer is noise.
2087
2088
  */
2089
+ // THE SENTENCE A PARTLY-READ SOURCE CONTRIBUTES TO list_inbox `notes`, or '' when it read whole. A route reports a
2090
+ // part it could not read as `unreadable: [...]` beside its own `note` (the Meta DM inbox, the conversation list); the
2091
+ // note is the route's words, and the label says which source they belong to. PURE, so a check runs it.
2092
+ export function inboxShortNote(src, d) {
2093
+ const bad = Array.isArray(d?.unreadable) ? d.unreadable : [];
2094
+ if (!bad.length) return '';
2095
+ const said = typeof d?.note === 'string' && d.note.trim() ? d.note.trim() : bad.map((u) => String(u?.error || 'could not be read')).join('; ');
2096
+ return `${src?.label || 'A source'}s were not read in full: ${said.slice(0, 600)}`;
2097
+ }
2088
2098
  export function normalizeInboxItem(source, row = {}) {
2089
2099
  const src = BY_SOURCE.get(source);
2090
2100
  if (!src) return null;
@@ -2805,7 +2815,7 @@ export const undeclaredArgsText = (name, extra, h) => {
2805
2815
  const shape = h && h.inputSchema && h.inputSchema.shape;
2806
2816
  const takes = shape && typeof shape === 'object' ? Object.keys(shape) : [];
2807
2817
  const brandNote = extra.includes('brand') ? ` ${name} acts on the profile this connection is on — pin another with use_brand first, then call it again.` : '';
2808
- return `${name} does not take ${extra.map((k) => `\`${k}\``).join(', ')}, so nothing was run: an argument a tool does not declare would be silently ignored.${brandNote} It takes: ${takes.length ? takes.join(', ') : '(no arguments)'}.`;
2818
+ return `${name} does not take ${extra.map((k) => { const m = didYouMean(k, takes); return `\`${k}\`${m ? ` (did you mean \`${m}\`?)` : ''}`; }).join(', ')}, so nothing was run: an argument a tool does not declare would be silently ignored.${brandNote} It takes: ${takes.length ? takes.join(', ') : '(no arguments)'}.`;
2809
2819
  };
2810
2820
  export async function legacyToolAnswer(name, request, extra, ctx) {
2811
2821
  const spec = LEGACY_TOOL_NAMES[name];
@@ -3574,14 +3584,43 @@ function registerWidgetHostSplits(rawServer, ctx, opts) {
3574
3584
  const _wfWrapped = new WeakMap();
3575
3585
  // EXPORTED so tools/well-formed-law-check.mjs can RUN the real seam over real fixtures rather than read it. It is
3576
3586
  // not part of the module's public surface in any other sense — `registerTools` is the only caller.
3587
+ // UNKNOWN ARGUMENTS ON A HOST'S OWN tools/call (2026-10-26, customer report — see mcp/arg-guard.mjs). The SDK parses
3588
+ // `arguments` with the tool's zod object, which strips an undeclared key before any handler sees it, so the only place
3589
+ // the raw keys exist is the SDK's validateToolInput. It is wrapped ONCE per server instance: a refusal throws (the SDK
3590
+ // turns that into an isError result), and a key that is noted rather than refused rides the parsed args under a
3591
+ // symbol until withUnknownArgsNote puts the sentence at the top of the answer.
3592
+ export function guardUnknownArgs(server) {
3593
+ if (!server || typeof server.validateToolInput !== 'function' || server[UNKNOWN_ARGS_SYM]) return server;
3594
+ const orig = server.validateToolInput;
3595
+ const guarded = async function (tool, args, toolName) {
3596
+ const declared = declaredArgKeys(tool?.inputSchema);
3597
+ const unknown = unknownArgs(declared, args);
3598
+ const mode = argGuardMode();
3599
+ if (unknown.length && mode !== 'off') {
3600
+ try { console.error(`[mcp-unknown-arg] tool=${toolName} keys=${unknown.join(',')} mode=${mode}`); } catch {}
3601
+ if (argGuardRefuses(mode, unknown, declared)) throw new Error(unknownArgsNote(toolName, unknown, declared, { rejected: true }));
3602
+ }
3603
+ const out = await orig.call(this, tool, args, toolName);
3604
+ if (unknown.length && mode !== 'off' && out && typeof out === 'object') { try { Object.defineProperty(out, UNKNOWN_ARGS_SYM, { value: unknownArgsNote(toolName, unknown, declared), enumerable: false, configurable: true }); } catch {} }
3605
+ return out;
3606
+ };
3607
+ try { Object.defineProperty(server, 'validateToolInput', { value: guarded, configurable: true, writable: true }); Object.defineProperty(server, UNKNOWN_ARGS_SYM, { value: true }); } catch {}
3608
+ return server;
3609
+ }
3610
+ export function withUnknownArgsNote(result, args) {
3611
+ const note = args && typeof args === 'object' ? args[UNKNOWN_ARGS_SYM] : '';
3612
+ if (!note || !result || typeof result !== 'object' || !Array.isArray(result.content)) return result;
3613
+ return { ...result, content: [{ type: 'text', text: note }, ...result.content] };
3614
+ }
3577
3615
  export function wellFormedServer(rawServer) {
3616
+ guardUnknownArgs(rawServer);
3578
3617
  return new Proxy(rawServer, {
3579
3618
  get(t, p) {
3580
3619
  if (p === 'registerTool') return (name, def, handler) => {
3581
3620
  let safe = _wfWrapped.get(handler);
3582
3621
  if (!safe) {
3583
3622
  safe = async (args, extra) => {
3584
- try { return wellFormedValue(await handler(args, extra)); }
3623
+ try { return wellFormedValue(withUnknownArgsNote(await handler(args, extra), args)); }
3585
3624
  catch (e) {
3586
3625
  try { const m = wellFormedString(e?.message); if (e && m !== e.message) e.message = m; } catch {}
3587
3626
  throw e;
@@ -4477,6 +4516,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
4477
4516
  try {
4478
4517
  const d = await apiGet(whole ? src.readAll : src.read, q);
4479
4518
  const rows = Array.isArray(d) ? d : (d.comments || d.replies || d.mentions || d.reviews || d.messages || d.events || d.items || d.data || []);
4519
+ // A SOURCE THAT READ ONLY PART OF ITSELF SAYS SO (2026-10-26, customer report: Instagram DMs at limit >= 32 came
4520
+ // back as 0 items and NO note, because the DM inbox route reported its failure in `unreadable` + `note` and this
4521
+ // loop only ever read the rows). Any route answering `unreadable` has its sentence carried into `notes`, on both
4522
+ // branches, so a short list is never mistaken for a quiet inbox.
4523
+ const short = inboxShortNote(src, d);
4524
+ if (short) notes.push(short);
4480
4525
  if (whole) { for (const r of rows) { const it = normalizeInboxItem(src.source, r); if (it) items.push(it); } continue; } // already one row per conversation
4481
4526
  // A DM FOLDS TO ONE ITEM AND CARRIES THE ID YOU ANSWER TO — see foldConversationRows. Deriving this from
4482
4527
  // `kind` rather than naming the two sources means a third DM channel inherits it by declaring its kind.
@@ -4488,6 +4533,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
4488
4533
  const use = src.dmReplyId ? foldConversationRows(rows, d?.[src.dmReplyId]) : rows;
4489
4534
  for (const r of use) { const it = normalizeInboxItem(src.source, src.dmReplyId && typeof d?.conversationId === 'string' && !r.conversationId ? { ...r, conversationId: d.conversationId } : r); if (it) items.push(it); }
4490
4535
  } catch (e) {
4536
+ // AN ID THAT DOES NOT RESOLVE ENDS THE CALL AS AN ERROR (2026-10-26, customer report: an unconnected pageId
4537
+ // answered "0 items"). The caller named a Page; reading nothing from it is not a quiet inbox, it is a wrong id.
4538
+ if (e?.apiCode === 'meta_page_not_connected') return { content: [{ type: 'text', text: String(e.message) }], isError: true };
4491
4539
  // NAMED, never swallowed. A connector that is simply not linked is not worth a line; anything else is.
4492
4540
  const msg = String(e?.message || e);
4493
4541
  if (e?.status === 401 || /not connected/i.test(msg)) continue;
@@ -5057,7 +5105,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5057
5105
  inputSchema: {
5058
5106
  ...MANAGE_BRAND,
5059
5107
  postId: z.string().describe('post/media id returned by post_to_meta'),
5060
- target: z.enum(['facebook', 'instagram']).optional().describe('which metric set to ask for (default facebook)'),
5108
+ target: z.enum(['facebook', 'instagram']).optional().describe('which metric set to ask for; an Instagram media id is detected and read as Instagram either way (default: Facebook for a Facebook id)'),
5061
5109
  account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
5062
5110
  pageId: z.string().optional().describe('Page id — omit when only one Page is connected'),
5063
5111
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.449",
3
+ "version": "0.1.450",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 933 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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",