hermoso 0.1.112 → 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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/mcp/tools.mjs +90 -10
  3. package/package.json +2 -2
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
- **519 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 519 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',
@@ -3750,6 +3758,63 @@ export function registerTools(rawServer, opts = {}) {
3750
3758
  const d = await apiGet('/api/merchant/products', { merchantCenterId: a.merchantCenterId, limit: a.limit });
3751
3759
  return ok(`Merchant Center ${d.merchantCenterId}: ${d.count} product(s).\n${JSON.stringify(d.products || []).slice(0, 4000)}`, d);
3752
3760
  }));
3761
+ // ── SHOPIFY: the merchant's own storefront (2026-08-19) ──────────────────────────────────────────────────────
3762
+ // These close the LAST web-only capability in the product. `publish_to_product` existed only inside the Shopify
3763
+ // admin iframe, because its route authenticated with a ~60-second App Bridge JWT no headless caller can mint —
3764
+ // so a merchant driving Hermoso from Claude or Cursor could generate an ad FOR their product and then had to go
3765
+ // click it into the listing by hand. Web-only is the one direction the parity law calls a defect.
3766
+ //
3767
+ // There is deliberately no `shop` parameter on either tool. The store is derived server-side from the verified
3768
+ // account (a Shopify merchant's Hermoso account IS `shopify:<shop>`); accepting one from the caller would be a
3769
+ // forgeable instruction to publish into somebody else's storefront.
3770
+ server.registerTool('list_shopify_products', {
3771
+ title: 'List the Shopify catalog',
3772
+ description: "The merchant's real Shopify products — id, title, description, price, images and storefront URL. This is where the productId for publish_to_shopify_product comes from, and it doubles as ground truth about what the brand actually sells (real titles and real photos, not a guess from the website). Newest-updated first. Only works for accounts created by installing Hermoso from the Shopify App Store. Read-only, free.",
3773
+ inputSchema: {
3774
+ limit: z.number().optional().describe('how many products (1–100, default 24)'),
3775
+ cursor: z.string().optional().describe('pageInfo.endCursor from a previous call, to page further'),
3776
+ },
3777
+ outputSchema: { shop: z.string().optional(), products: z.array(z.any()).optional(), pageInfo: z.any().optional() },
3778
+ annotations: { readOnlyHint: true, openWorldHint: true },
3779
+ }, wrap(async (a) => {
3780
+ const d = await apiGet('/api/shopify/products', { limit: a.limit, cursor: a.cursor });
3781
+ const list = d.products || [];
3782
+ const lines = list.map((p) => `• ${p.title} — ${p.id}${p.price ? ` (${p.price.currency} ${p.price.min})` : ''}`).join('\n');
3783
+ return ok(`${d.shop}: ${list.length} product(s)${d.pageInfo?.hasNextPage ? ' (more available — pass cursor)' : ''}.\n${lines}`, d);
3784
+ }));
3785
+
3786
+ server.registerTool('publish_to_shopify_product', {
3787
+ title: 'Publish an image onto a Shopify product',
3788
+ description: "Attach a finished image to one of the merchant's Shopify product listings, as product media. Pass productId (from list_shopify_products — the gid://shopify/Product/… form) and a PUBLIC https imageUrl, which is what every Hermoso render returns. Shopify fetches the image server-side and processes it asynchronously, so the media can come back status PROCESSING and appear on the listing a moment later — that is success, not a failure. Only works for accounts created by installing Hermoso from the Shopify App Store. Free — the render was already paid for.",
3789
+ inputSchema: {
3790
+ productId: z.string().describe('gid://shopify/Product/… from list_shopify_products'),
3791
+ imageUrl: z.string().describe('a public https image URL — any Hermoso render URL works'),
3792
+ alt: z.string().optional().describe('alt text for accessibility and SEO; defaults to a generic credit'),
3793
+ },
3794
+ outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), productId: z.string().optional(), productUrl: z.string().optional(), media: z.any().optional() },
3795
+ }, wrap(async (a) => {
3796
+ const d = await apiPost('/api/shopify/publish-to-product', { productId: a.productId, imageUrl: a.imageUrl, alt: a.alt });
3797
+ const st = d.media?.status || 'UNKNOWN';
3798
+ return ok(`Published to ${d.productId} on ${d.shop} — media ${d.media?.id || '?'} is ${st}`
3799
+ + (st === 'PROCESSING' ? ' (Shopify is still processing it; it will appear on the listing shortly).' : '.')
3800
+ + (d.productUrl ? `\nListing: ${d.productUrl}` : ''), d);
3801
+ }));
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
+ }));
3753
3818
  server.registerTool('list_merchant_issues', {
3754
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.",
3755
3820
  inputSchema: { merchantCenterId: z.string().describe('from list_merchant_accounts') },
@@ -4356,19 +4421,25 @@ export function registerTools(rawServer, opts = {}) {
4356
4421
  dimensions: z.array(z.string()).optional().describe('GA4 dimension names to break the metrics down by — omit for a single total row'),
4357
4422
  startDate: z.string().optional().describe('YYYY-MM-DD or a GA4 relative date like "28daysAgo" (default 28daysAgo)'),
4358
4423
  endDate: z.string().optional().describe('YYYY-MM-DD or "today" (default today)'),
4359
- 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.'),
4360
4426
  orderByMetric: z.string().optional().describe('sort by this metric — must be one of the metrics requested'),
4361
4427
  orderDesc: z.boolean().optional().describe('default true (largest first) when orderByMetric is set'),
4362
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.'),
4363
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.'),
4364
4430
  },
4365
- 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() },
4366
4432
  annotations: { readOnlyHint: true, openWorldHint: true },
4367
4433
  }, wrap(async (a) => {
4368
4434
  const d = await apiPost('/api/analytics/report', a);
4369
4435
  // SAY WHETHER IT WAS FILTERED. A narrow answer and a small property look identical in a row list, so a reader
4370
4436
  // who did not send the filter cannot otherwise tell which one they are looking at.
4371
- 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);
4372
4443
  }));
4373
4444
  server.registerTool('analytics_realtime', {
4374
4445
  title: 'Who is on the site right now (GA4 realtime)',
@@ -5646,6 +5717,7 @@ export function registerTools(rawServer, opts = {}) {
5646
5717
  bid: z.string().optional().describe('MAX_CONVERSIONS only — the target CPA. Refused on MANUAL_CPT.'),
5647
5718
  currency: z.string().optional().describe('Defaults to the connected ad account’s currency; an account cannot mix currencies.'),
5648
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.'),
5649
5721
  },
5650
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() },
5651
5723
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -5725,6 +5797,7 @@ export function registerTools(rawServer, opts = {}) {
5725
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.'),
5726
5798
  description: z.string().optional().describe('Location group only.'),
5727
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.'),
5728
5801
  },
5729
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() },
5730
5803
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -7830,8 +7903,15 @@ export function registerTools(rawServer, opts = {}) {
7830
7903
  // ways a caller gets it wrong and neither is guessable: money is MICRO-currency (1,000,000 = 1 unit) and a
7831
7904
  // creative headline is capped at 34 characters.
7832
7905
  //
7833
- // NOT LIVE-VERIFIED. Every shape here comes from developers.snap.com read on 2026-08-10; no Snapchat call has
7834
- // been made from this codebase, because no SNAPCHAT_CLIENT_ID exists in the deployment yet.
7906
+ // VERIFIED-LIVE 2026-08-10 this header said the OPPOSITE until 2026-08-19 and was wrong for nine days.
7907
+ // It read "NOT LIVE-VERIFIED … no Snapchat call has been made from this codebase, because no SNAPCHAT_CLIENT_ID
7908
+ // exists in the deployment yet". The credential landed and the whole tree was swept the SAME DAY the shapes were
7909
+ // read: every Snapchat cell in tools/lib/ads-matrix.json is VERIFIED-LIVE against ad account
7910
+ // a6ef8b0b-2f33-4c41-b7e8-d756f9c987a4, and that sweep FOUND AND FIXED three defects (SNAP-1's 100%-failing
7911
+ // budget/status writes, SNAP-3, SNAP-6) — which is precisely the work a "docs only" header tells the next reader
7912
+ // has not happened. A stale not-verified claim is not harmlessly conservative: it makes someone redo a live sweep,
7913
+ // or distrust code that works. The one thing still genuinely unexercised is the chunked upload transport, and
7914
+ // that is stated where it lives (lib/snapchat-ads.mjs).
7835
7915
  server.registerTool('list_snapchat_ads_accounts', {
7836
7916
  title: 'List Snapchat organizations and ad accounts',
7837
7917
  description: 'List the Snapchat AD ACCOUNTS SHARED WITH THIS BRAND — the ones it may actually build on and spend from, which is NOT everything the Snapchat login can reach — id, name, currency, timezone and status. Every other Snapchat Ads tool needs an adAccountId and this is where it comes from. One call returns both tiers, because Snap nests ad accounts inside their organization. An account flagged as a TEST account is marked as such — those cannot serve real ads. Read-only, free.',
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.112",
3
+ "version": "0.1.114",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio + marketing MCP (519 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"