hermoso 0.1.113 → 0.1.114

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,7 +5,7 @@ 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
- **521 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **522 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
11
  **It is not all-or-nothing.** Research, creation, publishing/scheduling and ads management are four *independent*
@@ -53,7 +53,7 @@ Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
53
53
 
54
54
  Then ask your agent: *“Generate an image ad with Hermoso.”*
55
55
 
56
- ### What the 521 tools cover
56
+ ### What the 522 tools cover
57
57
 
58
58
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
59
59
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/tools.mjs CHANGED
@@ -2241,13 +2241,21 @@ export function registerTools(rawServer, opts = {}) {
2241
2241
  }));
2242
2242
  server.registerTool('x_post_metrics', {
2243
2243
  title: 'Read performance of a post on X',
2244
- description: 'Read the PUBLIC metrics of a post on X — impressions, likes, reposts, replies, quotes and bookmarks to judge whether a hook landed before spending more behind it. For the advertiser numbers (link clicks, video views, profile visits) use x_post_insights instead. Costs a small number of credits (X bills per API read). Needs X connected.',
2245
- inputSchema: { id: z.string().describe('the numeric X post id — the last part of the post URL') },
2246
- outputSchema: { id: z.string().optional(), text: z.string().optional(), postedAt: z.string().nullable().optional(), url: z.string().optional(), impressions: z.number().nullable().optional(), likes: z.number().nullable().optional(), reposts: z.number().nullable().optional(), replies: z.number().nullable().optional(), quotes: z.number().nullable().optional(), bookmarks: z.number().nullable().optional(), costCredits: z.number().optional() },
2244
+ description: "THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround. Prefer this over x_post_insights, whose endpoint family X has retired. If a post is deleted, protected or suspended, X answers with no data at all and this says so — that is MISSING DATA, never zero engagement, and must never be reported as a measured zero. Costs a small number of credits (X bills per API read). Needs X connected.",
2245
+ inputSchema: {
2246
+ id: z.string().describe('the numeric X post id the last part of the post URL'),
2247
+ publishedAt: z.number().optional().describe('epoch ms the post went out, if known — lets the private owned-post metrics be requested only inside X\'s 30-day window instead of costing a refused call'),
2248
+ },
2249
+ outputSchema: { id: z.string().optional(), found: z.boolean().optional(), note: z.string().optional(), text: z.string().optional(), postedAt: z.string().nullable().optional(), url: z.string().optional(), impressions: z.number().nullable().optional(), likes: z.number().nullable().optional(), reposts: z.number().nullable().optional(), replies: z.number().nullable().optional(), quotes: z.number().nullable().optional(), bookmarks: z.number().nullable().optional(), urlClicks: z.number().nullable().optional(), profileClicks: z.number().nullable().optional(), engagements: z.number().nullable().optional(), organicImpressions: z.number().nullable().optional(), organicLikes: z.number().nullable().optional(), privateMetrics: z.boolean().optional(), costCredits: z.number().optional() },
2247
2250
  annotations: { readOnlyHint: true, openWorldHint: true },
2248
2251
  }, wrap(async (a) => {
2249
- const d = await apiGet('/api/x/metrics', { id: a.id });
2250
- return ok(`${d.impressions ?? '?'} impressions, ${d.likes ?? '?'} likes, ${d.reposts ?? '?'} reposts, ${d.replies ?? '?'} replies ${d.url}`, d);
2252
+ const d = await apiGet('/api/x/metrics', { id: a.id, publishedAt: a.publishedAt });
2253
+ // The private half is reported only when X actually served it an absent link-click count means "outside the
2254
+ // 30-day window or not your post", which is not zero clicks and must not be printed as a number.
2255
+ const priv = d.privateMetrics
2256
+ ? ` · ${d.urlClicks ?? '?'} link clicks, ${d.profileClicks ?? '?'} profile clicks, ${d.engagements ?? '?'} engagements`
2257
+ : '';
2258
+ return ok(`${d.impressions ?? '?'} impressions, ${d.likes ?? '?'} likes, ${d.reposts ?? '?'} reposts, ${d.replies ?? '?'} replies${priv} — ${d.url}`, d);
2251
2259
  }));
2252
2260
  server.registerTool('x_post_insights', {
2253
2261
  title: 'Advertiser analytics for your own posts on X',
@@ -3792,6 +3800,21 @@ export function registerTools(rawServer, opts = {}) {
3792
3800
  + (d.productUrl ? `\nListing: ${d.productUrl}` : ''), d);
3793
3801
  }));
3794
3802
 
3803
+ server.registerTool('merchant_report', {
3804
+ title: 'Merchant Center reports — competitive visibility, best sellers, price benchmarks',
3805
+ description: "COMPETITOR INTELLIGENCE GOOGLE COMPUTES FOR FREE, for any retail brand with a Merchant Center. Ten report views, read with a SQL-like MCQL query. The three worth reaching for first: `competitive_visibility_competitor_view` (WHICH other domains appear beside this merchant, their rank, page-overlap and higher-position rates — i.e. who is actually beating them), `best_sellers_product_cluster_view` (what is SELLING on Google in a category right now, with an inventory_status saying whether this merchant even stocks it) and `price_insights_product_view` (Google's own suggested price plus the predicted click and conversion change). Also: competitive_visibility_benchmark_view, competitive_visibility_top_merchant_view, best_sellers_brand_view, price_competitiveness_product_view, product_view, product_performance_view, non_product_performance_view.\n\nMCQL IS NOT SQL: no OR, no subqueries, no GROUP BY, no aggregates, no JOIN, and ORDER BY may only name fields already in SELECT. Date filters use `WHERE date BETWEEN '2026-01-01' AND '2026-01-31'` or `WHERE date DURING LAST_30_DAYS`. Several views REQUIRE specific fields in SELECT and in WHERE — competitive visibility needs report_category_id + report_country_code + traffic_source, and top_merchant uniquely REQUIRES a date condition while FORBIDDING date in SELECT. An unknown view is refused by name with the list.\n\nNOT AVAILABLE ON MULTI-CLIENT (MCA) ACCOUNTS — pass a subaccount id. An empty result is often a normal state (price insights are only produced where Google predicts a substantial gain), and the note says which kind of empty it is. Read-only, free, no new permission.",
3806
+ inputSchema: {
3807
+ merchantCenterId: z.string().describe('from list_merchant_accounts — a STANDALONE account or a SUBACCOUNT, never a multi-client (MCA) account'),
3808
+ query: z.string().describe("the MCQL query, e.g. \"SELECT id, title, price, suggested_price, effectiveness FROM price_insights_product_view\""),
3809
+ pageSize: z.number().optional().describe('rows per response, 1–100000 (default 1000). 100,000 is GOOGLE\'s ceiling, not ours.'),
3810
+ pageToken: z.string().optional().describe('nextPageToken from a previous call — resend the IDENTICAL query and pageSize with it, which Google requires'),
3811
+ },
3812
+ outputSchema: { merchantCenterId: z.string().optional(), view: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), pageSize: z.number().optional(), truncated: z.boolean().optional(), nextPageToken: z.string().optional(), query: z.string().optional(), note: z.string().optional() },
3813
+ annotations: { readOnlyHint: true, openWorldHint: true },
3814
+ }, wrap(async (a) => {
3815
+ const d = await apiPost('/api/merchant/report', { merchantCenterId: a.merchantCenterId, query: a.query, pageSize: a.pageSize, pageToken: a.pageToken });
3816
+ return ok(`${d.note}\n${JSON.stringify(d.rows || []).slice(0, 4000)}`, d);
3817
+ }));
3795
3818
  server.registerTool('list_merchant_issues', {
3796
3819
  description: "Read the account-level issues Google reports on a Merchant Center — the answer to \"why is this product not showing?\", which Google Ads reporting CANNOT give you, because a disapproved product has no impressions to report on. Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
3797
3820
  inputSchema: { merchantCenterId: z.string().describe('from list_merchant_accounts') },
@@ -4398,19 +4421,25 @@ export function registerTools(rawServer, opts = {}) {
4398
4421
  dimensions: z.array(z.string()).optional().describe('GA4 dimension names to break the metrics down by — omit for a single total row'),
4399
4422
  startDate: z.string().optional().describe('YYYY-MM-DD or a GA4 relative date like "28daysAgo" (default 28daysAgo)'),
4400
4423
  endDate: z.string().optional().describe('YYYY-MM-DD or "today" (default today)'),
4401
- limit: z.number().optional().describe('rows, 1–1000 (default 50)'),
4424
+ limit: z.number().optional().describe('rows to return, 1–250000 (default 50). 250,000 is GOOGLE\'s per-request maximum, not ours — asking for more is silently capped there.'),
4425
+ offset: z.number().optional().describe('skip this many rows — how you page past `limit`. Use the nextOffset the previous call returns.'),
4402
4426
  orderByMetric: z.string().optional().describe('sort by this metric — must be one of the metrics requested'),
4403
4427
  orderDesc: z.boolean().optional().describe('default true (largest first) when orderByMetric is set'),
4404
4428
  dimensionFilter: z.record(z.any()).optional().describe('NARROW THE REPORT — a GA4 FilterExpression, exactly one of andGroup | orGroup | notExpression | filter. Without it a report is the WHOLE property. Example: {"filter":{"fieldName":"sessionSource","stringFilter":{"matchType":"EXACT","value":"google"}}}; combine with {"andGroup":{"expressions":[…]}}. METRICS CANNOT BE USED HERE — use metricFilter.'),
4405
4429
  metricFilter: z.record(z.any()).optional().describe('Filter the AGGREGATED rows, GA4\'s having-clause — same FilterExpression shape. Example: {"filter":{"fieldName":"sessions","numericFilter":{"operation":"GREATER_THAN","value":{"int64Value":"30"}}}}. DIMENSIONS CANNOT BE USED HERE — use dimensionFilter.'),
4406
4430
  },
4407
- outputSchema: { property: z.string().optional(), rows: z.array(z.any()).optional(), rowCount: z.number().optional(), dimensions: z.array(z.string()).optional(), metrics: z.array(z.string()).optional(), filtered: z.boolean().optional(), sampled: z.boolean().optional() },
4431
+ outputSchema: { property: z.string().optional(), rows: z.array(z.any()).optional(), rowCount: z.number().optional(), returned: z.number().optional(), offset: z.number().optional(), truncated: z.boolean().optional(), nextOffset: z.number().optional(), dimensions: z.array(z.string()).optional(), metrics: z.array(z.string()).optional(), filtered: z.boolean().optional(), sampled: z.boolean().optional() },
4408
4432
  annotations: { readOnlyHint: true, openWorldHint: true },
4409
4433
  }, wrap(async (a) => {
4410
4434
  const d = await apiPost('/api/analytics/report', a);
4411
4435
  // SAY WHETHER IT WAS FILTERED. A narrow answer and a small property look identical in a row list, so a reader
4412
4436
  // who did not send the filter cannot otherwise tell which one they are looking at.
4413
- return ok(`${d.rowCount || 0} row(s) for property ${d.property}${d.filtered ? ' (FILTERED this is a SUBSET of the property, not its total)' : ''}${d.sampled ? ' (SAMPLED — GA4 dropped rows to answer this; say so when reporting)' : ''}.\n${rowLines(d.rows)}`, d);
4437
+ // rowCount is GA4's TOTAL, `returned` is what is actually in this response. Printing only the total put a big
4438
+ // number above a short list and let a partial answer read as a complete one.
4439
+ const head = d.truncated
4440
+ ? `${d.returned} of ${d.rowCount} row(s) for property ${d.property} — THIS IS A PARTIAL ANSWER. Call again with offset:${d.nextOffset} for the next page, or raise limit (max 250000).`
4441
+ : `${d.rowCount || 0} row(s) for property ${d.property}`;
4442
+ return ok(`${head}${d.filtered ? ' (FILTERED — this is a SUBSET of the property, not its total)' : ''}${d.sampled ? ' (SAMPLED — GA4 dropped rows to answer this; say so when reporting)' : ''}.\n${rowLines(d.rows)}`, d);
4414
4443
  }));
4415
4444
  server.registerTool('analytics_realtime', {
4416
4445
  title: 'Who is on the site right now (GA4 realtime)',
@@ -5688,6 +5717,7 @@ export function registerTools(rawServer, opts = {}) {
5688
5717
  bid: z.string().optional().describe('MAX_CONVERSIONS only — the target CPA. Refused on MANUAL_CPT.'),
5689
5718
  currency: z.string().optional().describe('Defaults to the connected ad account’s currency; an account cannot mix currencies.'),
5690
5719
  startTime: z.string().optional(), endTime: z.string().optional().describe('Omit to run indefinitely.'),
5720
+ budgetOrderIds: z.array(z.string()).optional().describe('budget order id(s) from list_apple_ads_budget_orders to assign this campaign to — a budget order is a spend CEILING SHARED across every campaign assigned to it, and it works ALONGSIDE dailyBudget rather than instead of it. Without this a budget order can never be drawn from.'),
5691
5721
  },
5692
5722
  outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), bornPaused: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
5693
5723
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5767,6 +5797,7 @@ export function registerTools(rawServer, opts = {}) {
5767
5797
  rules: z.array(z.object({ field: z.string(), operator: z.string(), value: z.any() })).optional().describe('Location group only (DYNAMIC). REPLACES every rule and puts the group back to PENDING.'),
5768
5798
  description: z.string().optional().describe('Location group only.'),
5769
5799
  startTime: z.string().optional(), endTime: z.string().optional(), currency: z.string().optional(),
5800
+ budgetOrderIds: z.array(z.string()).optional().describe('budget order id(s) from list_apple_ads_budget_orders to assign this campaign to — a budget order is a spend CEILING SHARED across every campaign assigned to it, and it works ALONGSIDE dailyBudget rather than instead of it. Without this a budget order can never be drawn from.'),
5770
5801
  },
5771
5802
  outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
5772
5803
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio + marketing MCP (521 tools): build and manage ad campaigns on Meta, Google Ads, Reddit, X, TikTok, Snapchat, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads — generate finished video, image and UGC avatar ads, publish them to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest, spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus organic TikTok/Instagram/YouTube/Reddit, and read what they achieved in Google Analytics 4. CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
5
+ "description": "AI ad studio + marketing MCP (522 tools): build and manage ad campaigns on Meta, Google Ads, Reddit, X, TikTok, Snapchat, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads — generate finished video, image and UGC avatar ads, publish them to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest, spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus organic TikTok/Instagram/YouTube/Reddit, and read what they achieved in Google Analytics 4. CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"