hermoso 0.1.244 → 0.1.247

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
- **838 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **841 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
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
171
171
 
172
172
  Then ask your agent: *“Generate an image ad with Hermoso.”*
173
173
 
174
- ### What the 838 tools cover
174
+ ### What the 841 tools cover
175
175
 
176
176
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
177
177
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -102,7 +102,7 @@ export const TOOL_PROVIDER_RULES = [
102
102
  [/bluesky/, 'bluesky'],
103
103
  [/telegram/, 'telegram'],
104
104
  [/^post_to_tiktok$|^tiktok_|_tiktok_/, 'tiktok'],
105
- [/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
105
+ [/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^block_x_user$|^unblock_x_user$|^list_x_blocks$|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
106
106
  [/pinterest/, 'pinterest'],
107
107
  [/reddit/, 'reddit'],
108
108
  [/snapchat/, 'snapchat'],
package/mcp/tools.mjs CHANGED
@@ -4713,6 +4713,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
4713
4713
  text: z.string().optional().describe('the post text. 280 characters without X Premium, up to 25,000 with it \u2014 write the full thing, it is never truncated. Use this OR thread, not both.'),
4714
4714
  thread: z.array(z.string()).optional().describe('a thread: each string is one post, published in order, each replying to the previous. Max 25. Each part follows the same length rule as `text`, and on an X Premium account ONE long post is usually both better reading and cheaper than a thread.'),
4715
4715
  mediaUrl: z.string().optional().describe('a Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media'),
4716
+ // A MEDIA URL DROPPED WITHOUT AN ERROR IS A TEXT-ONLY POST (2026-09-15, hit dogfooding): the in-app agent's schema says
4717
+ // imageUrl/videoUrl and the server reads all three names, but this schema knew only mediaUrl, so a caller passing
4718
+ // videoUrl published text with no media and no warning. Accept the two aliases here; xPost already prefers video.
4719
+ videoUrl: z.string().optional().describe('alias of mediaUrl for a VIDEO — same as passing it as mediaUrl'),
4720
+ imageUrl: z.string().optional().describe('alias of mediaUrl for an IMAGE — same as passing it as mediaUrl'),
4716
4721
  mediaUrls: z.array(z.string()).optional().describe('UP TO FOUR Hermoso-hosted media attached to ONE post — X\u2019s own schema caps media_ids at 4. X renders them as a GRID: every image visible at once, nothing to swipe to. That is NOT a carousel, and a numbered "1/6 \u00b7 SWIPE" slide deck must still not be sent here — it would publish as a grid and the "swipe" instruction would make no sense. Order decides the layout. On a thread the media rides the FIRST post; give each later part its own post to attach more. Mutually exclusive with mediaUrl, and more than 4 is refused before anything is uploaded.'),
4717
4722
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility description of the attached media, max 1000 characters \u2014 write one whenever you attach a render. Costs a small extra amount: X bills one metadata write PER media. With SEVERAL media, pass an ARRAY aligned to their order \u2014 X attaches alt text per media id, and a single string describes only the FIRST one (X renders up to four media as a GRID, not a carousel, so one sentence would be wrong for the other three).'),
4718
4723
  poll: z.object({
@@ -4720,8 +4725,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
4720
4725
  durationMinutes: z.number().optional().describe('5 to 10080 minutes (7 days); default 1440 = one day'),
4721
4726
  }).optional().describe('run a poll on the post. X does not allow a poll and media on the same post, and a poll cannot ride on a thread.'),
4722
4727
  replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('restrict who can reply — omit for everyone, which is the right default for a brand post'),
4723
- replyToId: z.string().optional().describe('numeric id of an existing X post to reply to'),
4724
- quotePostId: z.string().optional().describe('numeric id of a post to QUOTE \u2014 X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post\u2019s t.co URL whatever your text says.'),
4728
+ replyToId: z.string().optional().describe('numeric id of an existing X post to reply to. X ONLY ACCEPTS AN API REPLY TO A POST THAT MENTIONS THIS ACCOUNT OR THAT THIS ACCOUNT WROTE (X policy since Feb 2026, every tier below Enterprise): a cold reply under a stranger\'s post is refused by X with "You can only reply to or quote posts where you are mentioned or are the author" and nothing is posted. Reply to those from x.com itself; use this for replies in your own threads and to people who tagged you.'),
4729
+ quotePostId: z.string().optional().describe('numeric id of a post to QUOTE \u2014 X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post\u2019s t.co URL whatever your text says. Same X rule as replyToId: a quote of a post that does not mention this account is refused by X.'),
4725
4730
  communityId: z.string().optional().describe('publish into an X COMMUNITY instead of the main timeline \u2014 the number in the community\u2019s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
4726
4731
  paidPartnership: z.boolean().optional().describe('label the post a PAID PARTNERSHIP on X \u2014 the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user\u2019s behalf.'),
4727
4732
  },
@@ -5820,6 +5825,36 @@ function buildTools(rawServer, opts = {}, sink = null) {
5820
5825
  const top = (d.users || []).slice(0, 10).map((u) => `\u2022 ${u.username}${u.name ? ` (${u.name})` : ''}${u.followers != null ? ` \u2014 ${Number(u.followers).toLocaleString()} followers` : ''}`).join('\n');
5821
5826
  return ok(`${d.count} of ${d.account}\u2019s ${d.direction}${d.total != null ? ` (of ${Number(d.total).toLocaleString()})` : ''}:\n${top}${d.nextToken ? '\n\nMore available \u2014 pass paginationToken.' : ''}`, d);
5822
5827
  }));
5828
+ server.registerTool('block_x_user', {
5829
+ title: 'Block an account on X',
5830
+ description: 'BLOCK an account on the connected X account: they can no longer see, reply to, follow or message the brand. Reversible with unblock_x_user, but confirm the exact handle with the user first, X shows the block to the person blocked. NEEDS THE BLOCK PERMISSION: an X connection made before 2026-09-15 does not carry it and must be reconnected once under Settings ▸ Connectors ▸ X (same account, one click); the tool says so if that is the case and changes nothing. Costs credits (X bills per API call).',
5831
+ inputSchema: { username: z.string().describe('the handle to block, with or without the @') },
5832
+ outputSchema: { ok: z.boolean().optional(), account: z.string().optional(), target: z.string().optional(), targetId: z.string().optional(), blocking: z.boolean().optional(), costCredits: z.number().optional() },
5833
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5834
+ }, wrap(async (a) => {
5835
+ const d = await apiPost('/api/x/block', a);
5836
+ return ok(d.blocking ? `Blocked ${d.target} on ${d.account}. Cost ${d.costCredits ?? '?'} credits.` : `X did not confirm the block of ${d.target}.`, d);
5837
+ }));
5838
+ server.registerTool('unblock_x_user', {
5839
+ title: 'Unblock an account on X',
5840
+ description: 'UNBLOCK an account the connected X account has blocked. Same permission note as block_x_user (a pre-2026-09-15 connection reconnects once). Costs credits (X bills per API call).',
5841
+ inputSchema: { username: z.string().describe('the handle to unblock, with or without the @') },
5842
+ outputSchema: { ok: z.boolean().optional(), account: z.string().optional(), target: z.string().optional(), targetId: z.string().optional(), blocking: z.boolean().optional(), costCredits: z.number().optional() },
5843
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
5844
+ }, wrap(async (a) => {
5845
+ const d = await apiPost('/api/x/unblock', a);
5846
+ return ok(d.blocking ? `X still reports ${d.target} as blocked by ${d.account}.` : `Unblocked ${d.target} on ${d.account}. Cost ${d.costCredits ?? '?'} credits.`, d);
5847
+ }));
5848
+ server.registerTool('list_x_blocks', {
5849
+ title: 'Who the connected X account has blocked',
5850
+ description: 'The accounts the connected X account has BLOCKED, with handle, name, bio and follower count: the read-back after block_x_user / unblock_x_user. Same permission note as block_x_user. Costs credits (X bills per API call).',
5851
+ inputSchema: { maxResults: z.number().optional().describe('1–1000, default 100'), paginationToken: z.string().optional() },
5852
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
5853
+ }, wrap(async (a = {}) => {
5854
+ const d = await apiGet('/api/x/blocks', a);
5855
+ const top = (d.users || []).slice(0, 20).map((u) => `• ${u.username}${u.name ? ` (${u.name})` : ''}${u.followers != null ? ` — ${Number(u.followers).toLocaleString()} followers` : ''}`).join('\n');
5856
+ return ok(`${d.account} blocks ${d.count} account(s)${d.nextToken ? ' (more available — pass paginationToken)' : ''}:\n${top || '(none)'}`, d);
5857
+ }));
5823
5858
  server.registerTool('x_user', {
5824
5859
  title: 'Look up any public X account',
5825
5860
  description: 'ACCOUNT-LEVEL numbers for ANY public X account by handle \u2014 followers, following, posts, listed and media counts, plus bio, location, verified status and account age. The research twin of `x_account` (which reads YOUR connected account): use this to size a competitor or vet a creator before working with them. Returns the identical shape as x_account, so the two are never described differently, and a counter X does not return is reported as unknown rather than zero. Needs X connected.',
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.244",
3
+ "version": "0.1.247",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 838 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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.",
5
+ "description": "AI ad studio and marketing MCP server with 841 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"