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.
- package/README.md +2 -2
- package/mcp/tools.mjs +90 -10
- 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
|
-
**
|
|
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
|
|
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:
|
|
2245
|
-
inputSchema: {
|
|
2246
|
-
|
|
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
|
-
|
|
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–
|
|
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
|
-
|
|
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
|
-
//
|
|
7834
|
-
// been made from this codebase, because no SNAPCHAT_CLIENT_ID
|
|
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.
|
|
3
|
+
"version": "0.1.114",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio + marketing MCP (
|
|
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"
|