hermoso 0.1.21 → 0.1.22

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 (2) hide show
  1. package/mcp/tools.mjs +66 -1
  2. package/package.json +1 -1
package/mcp/tools.mjs CHANGED
@@ -34,7 +34,7 @@ const CAPABILITY_MAP = [
34
34
  'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / use_brand · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · make_template_ad (native HTML ad formats) · remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
35
35
  'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
36
36
  'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders).',
37
- 'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). META: list_meta_pages · post_to_meta (Facebook / Instagram / Threads) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · manage_meta_post (edit or delete a published post). YOUTUBE: post_to_youtube (publish a finished video to the brand’s channel — defaults to private; set public/unlisted to go live) · youtube_channel (read title + subscriber/view/video counts for reporting). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library). GOOGLE DRIVE (full CRUD over the files Hermoso created there): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (export data to a spreadsheet the app creates — drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (export copy/brief/report as a doc — drive.file, no verification): create_doc · append_to_doc. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
37
+ 'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). META: list_meta_pages · post_to_meta (Facebook / Instagram / Threads) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · manage_meta_post (edit or delete a published post). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC — the step that finishes the default publish flow; confirm before going public) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · list_youtube_comments + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own recent posts with views/likes/comments/shares). LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn profile). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library). GOOGLE DRIVE (full CRUD over the files Hermoso created there): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (export data to a spreadsheet the app creates — drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (export copy/brief/report as a doc — drive.file, no verification): create_doc · append_to_doc. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
38
38
  ].join('\n');
39
39
 
40
40
  // Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
@@ -866,6 +866,49 @@ export function registerTools(server) {
866
866
  const d = await apiGet('/api/youtube/channel', {});
867
867
  return ok(`${d.title} — ${d.subscribers} subscribers, ${d.videos} videos, ${d.views} total views.`, d);
868
868
  }));
869
+ // ── YOUTUBE: MEASURE + MANAGE (2026-07-30). We requested yt-analytics.readonly and youtube.force-ssl from day one
870
+ // and shipped nothing that used them, so an agent could publish to YouTube and then neither measure nor manage it.
871
+ // No reconnect needed — every connected user already granted these. See docs/mcp-connector-gap-map.md.
872
+ server.registerTool('youtube_video_insights', {
873
+ title: 'Performance of one of your YouTube videos',
874
+ description: 'Per-VIDEO performance for a video on the connected channel — views, estimated minutes watched, average view duration, average view PERCENTAGE (the retention number that tells you whether the hook held), likes, comments, shares and subscribers gained. Use it for "how did that video do", "which upload performed best", or to judge an ad before spending more behind it. youtube_channel only returns channel-wide totals and cannot answer this. Defaults to the last 28 days; pass startDate/endDate (YYYY-MM-DD) for another window. Read-only, 0 credits. Needs a connected YouTube channel.',
875
+ inputSchema: { videoId: z.string().describe('the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)'), startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago'), endDate: z.string().optional().describe('YYYY-MM-DD, default today') },
876
+ outputSchema: { videoId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), views: z.number().optional(), estimatedMinutesWatched: z.number().optional(), averageViewDuration: z.number().optional(), averageViewPercentage: z.number().optional(), likes: z.number().optional(), comments: z.number().optional(), shares: z.number().optional(), subscribersGained: z.number().optional(), url: z.string().optional() },
877
+ annotations: { readOnlyHint: true, openWorldHint: true },
878
+ }, wrap(async (a) => {
879
+ const d = await apiGet('/api/youtube/video-insights', { videoId: a.videoId, ...(a.startDate ? { startDate: a.startDate } : {}), ...(a.endDate ? { endDate: a.endDate } : {}) });
880
+ return ok(`${d.views ?? 0} views, ${d.averageViewPercentage ?? 0}% average retention, ${d.estimatedMinutesWatched ?? 0} minutes watched (${d.startDate} → ${d.endDate}).`, d);
881
+ }));
882
+ server.registerTool('update_youtube_video', {
883
+ title: 'Update a YouTube video’s title, description, tags or privacy',
884
+ description: 'Edit an existing video on the connected channel: title, description, tags, and/or privacy (unlisted | public | private). THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC — post_to_youtube defaults to UNLISTED, and without this there was no way to publish it afterwards. Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched. Needs a connected YouTube channel.',
885
+ inputSchema: { videoId: z.string().describe('the YouTube video id'), title: z.string().optional().describe('≤100 chars'), description: z.string().optional().describe('≤5000 chars'), tags: z.array(z.string()).optional(), privacy: z.enum(['unlisted', 'public', 'private']).optional().describe('public = live on the channel; confirm with the user first') },
886
+ outputSchema: { videoId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), url: z.string().optional() },
887
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
888
+ }, wrap(async (a) => {
889
+ const d = await apiPost('/api/youtube/update-video', a);
890
+ return ok(`Updated — “${d.title}” is now ${d.privacy}. ${d.url}`, d);
891
+ }));
892
+ server.registerTool('list_youtube_comments', {
893
+ title: 'Read comments on one of your YouTube videos',
894
+ description: 'Read the comments under a video on the connected channel — the questions, objections and exact wording real viewers use. Same raw material for ad copy that list_meta_comments gives you on Meta. Returns author, text, like count, timestamp and reply count, newest first. Read-only, 0 credits. Needs a connected YouTube channel.',
895
+ inputSchema: { videoId: z.string().describe('the YouTube video id'), limit: z.number().optional().describe('max comments, default 25, cap 100') },
896
+ outputSchema: { videoId: z.string().optional(), count: z.number().optional(), comments: z.array(z.object({ id: z.string().optional(), author: z.string().optional(), text: z.string().optional(), likes: z.number().optional(), at: z.string().optional(), replies: z.number().optional() })).optional() },
897
+ annotations: { readOnlyHint: true, openWorldHint: true },
898
+ }, wrap(async (a) => {
899
+ const d = await apiGet('/api/youtube/comments', { videoId: a.videoId, ...(a.limit ? { limit: a.limit } : {}) });
900
+ return ok(`${d.count} comment${d.count === 1 ? '' : 's'} on ${d.videoId}.`, d);
901
+ }));
902
+ server.registerTool('reply_to_youtube_comment', {
903
+ title: 'Reply to a YouTube comment',
904
+ description: 'Post a public reply to a comment on the connected channel, as the channel. This is PUBLIC and immediate — show the user the exact reply text and get an explicit yes before calling. Get commentId from list_youtube_comments. Needs a connected YouTube channel.',
905
+ inputSchema: { commentId: z.string().describe('id of the comment to reply to (from list_youtube_comments)'), text: z.string().describe('the reply, shown publicly under the video') },
906
+ outputSchema: { id: z.string().optional(), text: z.string().optional(), at: z.string().optional() },
907
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
908
+ }, wrap(async (a) => {
909
+ const d = await apiPost('/api/youtube/reply-comment', a);
910
+ return ok('Reply posted.', d);
911
+ }));
869
912
  server.registerTool('tiktok_creator_info', {
870
913
  title: 'Read the connected TikTok creator’s posting options',
871
914
  description: 'Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).',
@@ -898,6 +941,28 @@ export function registerTools(server) {
898
941
  if (d.destination === 'draft') return ok(`Sent to TikTok drafts${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app under drafts.${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
899
942
  return ok(`Posted to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}`, d);
900
943
  }));
944
+ server.registerTool('tiktok_account', {
945
+ title: 'Read the connected TikTok account',
946
+ description: 'Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).',
947
+ inputSchema: {},
948
+ outputSchema: { openId: z.string().nullable().optional(), displayName: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), bio: z.string().nullable().optional(), profileLink: z.string().nullable().optional(), verified: z.boolean().optional(), followers: z.number().nullable().optional(), following: z.number().nullable().optional(), likes: z.number().nullable().optional(), videos: z.number().nullable().optional(), partial: z.boolean().optional() },
949
+ annotations: { readOnlyHint: true, openWorldHint: true },
950
+ }, wrap(async () => {
951
+ const d = await apiGet('/api/tiktok/me', { full: '1' });
952
+ const bits = [d.followers != null ? `${d.followers} followers` : null, d.likes != null ? `${d.likes} total likes` : null, d.videos != null ? `${d.videos} videos` : null].filter(Boolean);
953
+ return ok(`TikTok: ${d.displayName || d.username || '(unnamed)'}${d.username ? ` (@${d.username})` : ''}${d.verified ? ' — verified' : ''}. ${bits.join(' · ') || 'no public stats returned'}.${d.partial ? ' This connection predates the profile/stats permissions — reconnect TikTok to see followers and stats.' : ''}`, d);
954
+ }));
955
+ server.registerTool('list_tiktok_videos', {
956
+ title: 'List the connected account’s TikTok posts',
957
+ description: 'List the connected account’s own recent PUBLIC TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. Use it for “how did our last TikToks do”, “which of our videos performed best”, or to pick a reference before making a new ad. Only ever the connected user’s OWN videos. Read-only. Needs TikTok connected.',
958
+ inputSchema: { limit: z.number().optional().describe('1-20, default 10') },
959
+ outputSchema: { videos: z.array(z.object({ id: z.string().nullable().optional(), title: z.string().optional(), durationSeconds: z.number().nullable().optional(), cover: z.string().nullable().optional(), url: z.string().nullable().optional(), postedAt: z.string().nullable().optional(), views: z.number().nullable().optional(), likes: z.number().nullable().optional(), comments: z.number().nullable().optional(), shares: z.number().nullable().optional() })).optional(), cursor: z.number().nullable().optional(), hasMore: z.boolean().optional() },
960
+ annotations: { readOnlyHint: true, openWorldHint: true },
961
+ }, wrap(async (a) => {
962
+ const d = await apiGet('/api/tiktok/videos', a);
963
+ const rows = (d.videos || []).map((v, i) => `${i + 1}. ${(v.title || '(no caption)').slice(0, 70)} — ${v.views ?? '?'} views, ${v.likes ?? '?'} likes${v.url ? ` — ${v.url}` : ''}`);
964
+ return ok(rows.length ? `${rows.length} recent TikTok post(s)${d.hasMore ? ' (more available)' : ''}:\n${rows.join('\n')}` : 'No public videos on that TikTok account yet.', d);
965
+ }));
901
966
  server.registerTool('upload_meta_asset', {
902
967
  title: 'Upload an asset to a Meta ad account',
903
968
  description: 'Upload creative(s) — a finished Hermoso ad OR arbitrary user files (e.g. a folder of media from the user’s desktop) — into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use them in their OWN campaigns. Pass `url` for one file, or `urls` (up to 20) to BULK-upload in a single call. Each accepts a public https URL, a data: URI, or a Hermoso /generated path; for LOCAL files call upload_file first and pass the url(s) it returns. Image → image hash; video → video id. Pass adAccountId from list_meta_pages.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, 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",