hermoso 0.1.452 → 0.1.453

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/mcp/arg-guard.mjs CHANGED
@@ -16,6 +16,14 @@
16
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
17
 
18
18
  export const UNKNOWN_ARGS_SYM = Symbol.for('hermoso.unknownArgs');
19
+ // The same facts as data (2026-10-27, customer ask: the note was only in the text). Rides the parsed args beside the
20
+ // sentence; a refused call carries it under REFUSED so the handler wrapper answers without running the tool.
21
+ export const UNKNOWN_ARGS_DETAIL_SYM = Symbol.for('hermoso.unknownArgsDetail');
22
+ export const UNKNOWN_ARGS_REFUSED_SYM = Symbol.for('hermoso.unknownArgsRefused');
23
+ /** [{name, didYouMean}] — didYouMean is a key THIS tool declares, or null. The structured twin of unknownArgsNote. */
24
+ export function unknownArgsDetail(unknown = [], declared = []) {
25
+ return unknown.map((k) => ({ name: k, didYouMean: didYouMean(k, declared) || null }));
26
+ }
19
27
 
20
28
  /**
21
29
  * 'typo' (default) | 'reject' | 'warn' | 'off' from the environment value. Anything unrecognised is `typo`:
package/mcp/tools.mjs CHANGED
@@ -12,7 +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
+ import { UNKNOWN_ARGS_SYM, UNKNOWN_ARGS_DETAIL_SYM, UNKNOWN_ARGS_REFUSED_SYM, argGuardMode, argGuardRefuses, declaredArgKeys, didYouMean, unknownArgs, unknownArgsNote, unknownArgsDetail } from './arg-guard.mjs'; // an undeclared argument is named back, never silently stripped (2026-10-26)
16
16
  // WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
17
17
  // re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
18
18
  // twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
@@ -2811,6 +2811,7 @@ export const undeclaredArgs = (h, input) => {
2811
2811
  if (catType && !/never/i.test(catType)) return [];
2812
2812
  return keys.filter((k) => !Object.prototype.hasOwnProperty.call(shape, k));
2813
2813
  };
2814
+ export const undeclaredArgsStructured = (extra, h) => ({ error: 'unknown_arguments', unknownArguments: unknownArgsDetail(extra, h && h.inputSchema && typeof h.inputSchema.shape === 'object' ? Object.keys(h.inputSchema.shape) : []) });
2814
2815
  export const undeclaredArgsText = (name, extra, h) => {
2815
2816
  const shape = h && h.inputSchema && h.inputSchema.shape;
2816
2817
  const takes = shape && typeof shape === 'object' ? Object.keys(shape) : [];
@@ -2830,7 +2831,7 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
2830
2831
  const fn = h && (h.handler || h.callback);
2831
2832
  if (typeof fn !== 'function') return withStaleNote({ content: [{ type: 'text', text: `${name} was renamed to ${spec.to}, which is not part of this session's roster.` }], isError: true }, ctx);
2832
2833
  let input = request?.params?.arguments && typeof request.params.arguments === 'object' ? request.params.arguments : {};
2833
- { const extraKeys = undeclaredArgs(h, input); if (extraKeys.length) return withStaleNote({ content: [{ type: 'text', text: undeclaredArgsText(spec.to, extraKeys, h) }], isError: true }, ctx); }
2834
+ { const extraKeys = undeclaredArgs(h, input); if (extraKeys.length) return withStaleNote({ content: [{ type: 'text', text: undeclaredArgsText(spec.to, extraKeys, h) }], structuredContent: undeclaredArgsStructured(extraKeys, h), isError: true }, ctx); }
2834
2835
  if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
2835
2836
  const parsed = h.inputSchema.safeParse(input);
2836
2837
  if (!parsed.success) {
@@ -3240,7 +3241,7 @@ export function installHeldToolCalls(mcp, ctx) {
3240
3241
  const fn = h.handler || h.callback;
3241
3242
  if (typeof fn === 'function') {
3242
3243
  let input = request?.params?.arguments && typeof request.params.arguments === 'object' ? request.params.arguments : {};
3243
- { const extraKeys = undeclaredArgs(h, input); if (extraKeys.length) return { content: [{ type: 'text', text: undeclaredArgsText(name, extraKeys, h) }], isError: true }; }
3244
+ { const extraKeys = undeclaredArgs(h, input); if (extraKeys.length) return { content: [{ type: 'text', text: undeclaredArgsText(name, extraKeys, h) }], structuredContent: undeclaredArgsStructured(extraKeys, h), isError: true }; }
3244
3245
  if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
3245
3246
  const parsed = h.inputSchema.safeParse(input);
3246
3247
  if (!parsed.success) {
@@ -3598,19 +3599,35 @@ export function guardUnknownArgs(server) {
3598
3599
  const mode = argGuardMode();
3599
3600
  if (unknown.length && mode !== 'off') {
3600
3601
  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
+ // REFUSED WITHOUT RUNNING, answered by withUnknownArgsNote so the refusal carries `unknownArguments` as data
3603
+ // (a throw here would reach the caller as text only). The SDK's own validation still runs first, so a call
3604
+ // that is malformed in another way is refused for that too.
3605
+ if (argGuardRefuses(mode, unknown, declared)) {
3606
+ await orig.call(this, tool, Object.fromEntries(Object.entries(args || {}).filter(([k]) => !unknown.includes(k))), toolName);
3607
+ const marker = {};
3608
+ Object.defineProperty(marker, UNKNOWN_ARGS_REFUSED_SYM, { value: { text: unknownArgsNote(toolName, unknown, declared, { rejected: true }), detail: unknownArgsDetail(unknown, declared) }, enumerable: false });
3609
+ return marker;
3610
+ }
3602
3611
  }
3603
3612
  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 {} }
3613
+ 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 }); Object.defineProperty(out, UNKNOWN_ARGS_DETAIL_SYM, { value: unknownArgsDetail(unknown, declared), enumerable: false, configurable: true }); } catch {} }
3605
3614
  return out;
3606
3615
  };
3607
3616
  try { Object.defineProperty(server, 'validateToolInput', { value: guarded, configurable: true, writable: true }); Object.defineProperty(server, UNKNOWN_ARGS_SYM, { value: true }); } catch {}
3608
3617
  return server;
3609
3618
  }
3619
+ // THE NOTE IS TEXT AND DATA (2026-10-27): `ignoredArguments` [{name, didYouMean}] rides structuredContent on a call
3620
+ // that ran, and `unknownArguments` on one that was refused, so a program reads it without parsing the sentence.
3610
3621
  export function withUnknownArgsNote(result, args) {
3611
3622
  const note = args && typeof args === 'object' ? args[UNKNOWN_ARGS_SYM] : '';
3612
3623
  if (!note || !result || typeof result !== 'object' || !Array.isArray(result.content)) return result;
3613
- return { ...result, content: [{ type: 'text', text: note }, ...result.content] };
3624
+ const detail = args[UNKNOWN_ARGS_DETAIL_SYM] || [];
3625
+ const sc = result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent) ? result.structuredContent : null;
3626
+ return { ...result, content: [{ type: 'text', text: note }, ...result.content], ...(sc || !result.isError ? { structuredContent: { ...(sc || {}), ignoredArguments: detail } } : {}) };
3627
+ }
3628
+ export function unknownArgsRefusal(args) {
3629
+ const r = args && typeof args === 'object' ? args[UNKNOWN_ARGS_REFUSED_SYM] : null;
3630
+ return r ? { content: [{ type: 'text', text: r.text }], structuredContent: { error: 'unknown_arguments', unknownArguments: r.detail }, isError: true } : null;
3614
3631
  }
3615
3632
  export function wellFormedServer(rawServer) {
3616
3633
  guardUnknownArgs(rawServer);
@@ -3620,7 +3637,7 @@ export function wellFormedServer(rawServer) {
3620
3637
  let safe = _wfWrapped.get(handler);
3621
3638
  if (!safe) {
3622
3639
  safe = async (args, extra) => {
3623
- try { return wellFormedValue(withUnknownArgsNote(await handler(args, extra), args)); }
3640
+ try { const refused = unknownArgsRefusal(args); if (refused) return refused; return wellFormedValue(withUnknownArgsNote(await handler(args, extra), args)); }
3624
3641
  catch (e) {
3625
3642
  try { const m = wellFormedString(e?.message); if (e && m !== e.message) e.message = m; } catch {}
3626
3643
  throw e;
@@ -4257,7 +4274,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4257
4274
  const extraKeys = undeclaredArgs(h, input);
4258
4275
  if (extraKeys.length) {
4259
4276
  const params = Object.entries(compactParams(h)).map(([k, v]) => `${k}: ${v}`).join(' | ');
4260
- return { content: [{ type: 'text', text: `${undeclaredArgsText(n, extraKeys, h)}${params ? ` Expected params: ${params}` : ''}` }], isError: true };
4277
+ return { content: [{ type: 'text', text: `${undeclaredArgsText(n, extraKeys, h)}${params ? ` Expected params: ${params}` : ''}` }], structuredContent: undeclaredArgsStructured(extraKeys, h), isError: true };
4261
4278
  }
4262
4279
  if (h.inputSchema) {
4263
4280
  const parsed = h.inputSchema.safeParse(input);
@@ -7426,14 +7443,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
7426
7443
  }));
7427
7444
  server.registerTool('list_youtube_comment_replies', {
7428
7445
  title: 'Read the replies under one YouTube comment',
7429
- description: 'Every reply under ONE comment on the connected channel, newest pages first via cursor: check that a reply the brand posted landed, or whether someone already answered a viewer before replying. Pass the top-level comment id from list_youtube_comments (each comment line shows its id and reply count). Read-only, free.',
7446
+ description: 'Every reply under ONE comment on the connected channel, newest pages first via cursor: check that a reply the brand posted landed, or whether someone already answered a viewer before replying. Pass the top-level comment id from list_youtube_comments (each comment line shows its id and reply count); a REPLY id is recognised and its whole thread is returned, with `note` and `requestedId` saying so. Read-only, free.',
7430
7447
  inputSchema: { commentId: z.string().describe('the top-level comment id from list_youtube_comments'), limit: z.number().optional().describe('max replies per page, default 25, cap 100'), cursor: z.string().optional().describe('nextCursor from the previous page') },
7431
- outputSchema: { commentId: z.string().optional(), count: z.number().optional(), replies: z.array(z.object({ id: z.string().optional(), author: z.string().optional(), authorChannelId: z.string().optional(), text: z.string().optional(), likes: z.number().optional(), at: z.string().optional(), editedAt: z.string().optional() })).optional(), nextCursor: z.string().optional() },
7448
+ outputSchema: { commentId: z.string().optional(), count: z.number().optional(), replies: z.array(z.object({ id: z.string().optional(), author: z.string().optional(), authorChannelId: z.string().optional(), text: z.string().optional(), likes: z.number().optional(), at: z.string().optional(), editedAt: z.string().optional() })).optional(), nextCursor: z.string().optional(), requestedId: z.string().optional(), resolvedFromReply: z.boolean().optional(), note: z.string().optional() },
7432
7449
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
7433
7450
  }, wrap(async (a) => {
7434
7451
  const d = await apiGet('/api/youtube/comment-replies', { commentId: a.commentId, ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) });
7435
7452
  const rl = (d.replies || []).map(r => ` • ${r.author || 'someone'}${r.likes ? ` (${r.likes} like${r.likes === 1 ? '' : 's'})` : ''}: ${String(r.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}${r.at ? ` · ${r.at}` : ''}${r.id ? ` (id ${r.id})` : ''}`);
7436
- return ok(`${d.count} repl${d.count === 1 ? 'y' : 'ies'} under comment ${d.commentId}:\n${rl.join('\n') || ' (none yet)'}${d.nextCursor ? `\nMore: pass cursor "${d.nextCursor}".` : ''}`, d);
7453
+ return ok(`${d.note ? `${d.note}\n` : ''}${d.count} repl${d.count === 1 ? 'y' : 'ies'} under comment ${d.commentId}:\n${rl.join('\n') || ' (none yet)'}${d.nextCursor ? `\nMore: pass cursor "${d.nextCursor}".` : ''}`, d);
7437
7454
  }));
7438
7455
  server.registerTool('reply_to_youtube_comment', {
7439
7456
  title: 'Reply to a YouTube comment',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.452",
3
+ "version": "0.1.453",
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",