hermoso 0.1.12 → 0.1.13

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/mcp/client.mjs CHANGED
@@ -50,6 +50,18 @@ export async function apiPut(p, body = {}) {
50
50
  return unwrap(res);
51
51
  }
52
52
 
53
+ // A hosted-connector call (via mcp/http.mjs) has an mcpCtx store; local stdio/CLI does not. Used to REFUSE local-path
54
+ // file reads on the hosted connector (it runs on the SERVER host, not the user's machine — an LFI/exfil vector).
55
+ export const isRemote = () => !!mcpCtx.getStore();
56
+ // Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
57
+ // the server reads the raw body. Lets an agent post ARBITRARY user files (not just Hermoso renders).
58
+ export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
59
+ const h = headers({ 'Content-Type': contentType });
60
+ if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
61
+ const res = await fetch(`${API_BASE}${p}`, { method: 'POST', headers: h, body: buf });
62
+ return unwrap(res);
63
+ }
64
+
53
65
  // /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
54
66
  export async function apiSSE(p, body = {}) {
55
67
  const res = await fetch(`${API_BASE}${p}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
package/mcp/tools.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
5
5
  // needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
6
6
  import { z } from 'zod';
7
- import { apiGet, apiPost, apiPut, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, API_BASE, PROFILE, mcpCtx } from './client.mjs';
7
+ import { apiGet, apiPost, apiPut, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, isRemote, API_BASE, PROFILE, mcpCtx } from './client.mjs';
8
+ import { readFile } from 'node:fs/promises';
8
9
 
9
10
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || 10 * 60 * 1000);
10
11
  const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
@@ -499,13 +500,42 @@ export function registerTools(server) {
499
500
  const pages = pg.pages || [], adAccounts = aa.adAccounts || [];
500
501
  return ok(`Pages: ${pages.map(p => p.name + (p.instagram ? ` (IG @${p.instagram.username})` : '')).join(', ') || 'none'}\nAd accounts: ${adAccounts.map(a => `${a.name} (act_${a.accountId}, ${a.currency}${a.active ? '' : ', inactive'})`).join(', ') || 'none'}`, { pages, adAccounts });
501
502
  }));
503
+ // Ingest an ARBITRARY user file (desktop media, etc. — nothing to do with a Hermoso render) into Hermoso and get back a
504
+ // durable public URL to feed post_to_meta / upload_meta_asset / create_meta_ad. This is what makes the publishing tools
505
+ // work on the user's OWN files, not just generated ones.
506
+ const EXT_MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4' };
507
+ server.registerTool('upload_file', {
508
+ title: 'Upload a local file → durable public URL',
509
+ description: 'Persist an ARBITRARY user file (image or video, up to 150MB) into Hermoso and get back a durable public URL you can pass to post_to_meta / upload_meta_asset / create_meta_ad — including files that have NOTHING to do with a Hermoso render (e.g. media on the user\'s desktop). Provide exactly ONE source: `path` (a local file — works ONLY when Hermoso runs locally over stdio/CLI; the hosted connector can\'t see the user\'s machine), or `dataUri` (a base64 data: URI — keep under ~15MB on the hosted connector). If the file is ALREADY at a public https URL you do NOT need this — pass that URL straight to post_to_meta/upload_meta_asset and the server re-hosts it safely. Returns {url, kind, bytes}.',
510
+ inputSchema: {
511
+ path: z.string().optional().describe('local filesystem path (stdio/CLI only — refused on the hosted connector)'),
512
+ dataUri: z.string().optional().describe('base64 data: URI of the file bytes (data:<mime>;base64,<…>)'),
513
+ name: z.string().optional().describe('original file name — helps pick the right extension'),
514
+ },
515
+ outputSchema: { url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional() },
516
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
517
+ }, wrap(async (a) => {
518
+ let buf, contentType = 'application/octet-stream', fileName = a.name || '';
519
+ if (a.dataUri) {
520
+ const m = /^data:([^;]+);base64,(.*)$/s.exec(String(a.dataUri).trim());
521
+ if (!m) throw new Error('dataUri must be a base64 data: URI: data:<mime>;base64,<…>');
522
+ buf = Buffer.from(m[2], 'base64'); contentType = m[1];
523
+ } else if (a.path) {
524
+ if (isRemote()) throw new Error('`path` only works when Hermoso runs on your own machine (stdio/CLI). On the hosted connector I can\'t read your files — pass `dataUri`, or give the publishing tool a public https URL.');
525
+ buf = await readFile(a.path);
526
+ fileName = fileName || String(a.path).split(/[\\/]/).pop();
527
+ contentType = EXT_MIME[(fileName.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
528
+ } else throw new Error('Provide exactly one source: `path` (local file) or `dataUri`.');
529
+ const d = await apiUpload('/api/upload', buf, { contentType, fileName });
530
+ return ok(`Uploaded ${d.kind || 'file'} (${d.bytes || buf.length} bytes) → ${d.url}. Pass this url to post_to_meta / upload_meta_asset / create_meta_ad.`, { url: d.url, kind: d.kind, bytes: d.bytes });
531
+ }));
502
532
  server.registerTool('post_to_meta', {
503
533
  title: 'Post to Facebook, Instagram or Threads',
504
- description: 'Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO (public https URLs). target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Perfect for shipping a finished Hermoso ad straight to the brand’s socials. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings ▸ Connectors ▸ Meta) with posting permission; Threads needs its own connection (Settings ▸ Connectors ▸ Threads).',
534
+ description: 'Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings ▸ Connectors ▸ Meta) with posting permission; Threads needs its own connection.',
505
535
  inputSchema: {
506
536
  message: z.string().optional().describe('post text / caption'),
507
- imageUrl: z.string().optional().describe('public https:// image URL'),
508
- videoUrl: z.string().optional().describe('public https:// video URL (FB video post / IG Reel)'),
537
+ imageUrl: z.string().optional().describe('public https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file)'),
538
+ videoUrl: z.string().optional().describe('public https URL, data: URI, or /generated path — FB video post / IG Reel'),
509
539
  link: z.string().optional().describe('a URL to attach (FB text post only)'),
510
540
  target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account'),
511
541
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
@@ -518,18 +548,20 @@ export function registerTools(server) {
518
548
  }));
519
549
  server.registerTool('upload_meta_asset', {
520
550
  title: 'Upload an asset to a Meta ad account',
521
- description: 'Upload a finished creative (image or video, public https URL) into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use it in their OWN campaigns. Great when the user just wants Hermoso to hand off the creative into Meta, not run the campaign. Image returns an image hash; video returns a video id (reference these when building an ad). Pass adAccountId from list_meta_pages.',
551
+ 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.',
522
552
  inputSchema: {
523
553
  adAccountId: z.string().describe('ad account id (digits or act_… — from list_meta_pages)'),
524
- url: z.string().describe('public https:// image or video URL'),
554
+ url: z.string().optional().describe('a single public https URL / data: URI / /generated path'),
555
+ urls: z.array(z.string()).optional().describe('up to 20 media URLs/paths for a one-call BULK upload'),
525
556
  kind: z.enum(['image', 'video']).optional().describe('inferred from the URL if omitted'),
526
557
  name: z.string().optional().describe('a label for the asset'),
527
558
  },
528
- outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), hash: z.string().optional(), videoId: z.string().optional() },
559
+ outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), hash: z.string().optional(), videoId: z.string().optional(), assets: z.array(z.object({ kind: z.string().optional(), hash: z.string().optional(), videoId: z.string().optional() })).optional() },
529
560
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
530
561
  }, wrap(async (a) => {
531
562
  const d = await apiPost('/api/meta/upload-asset', a);
532
- return ok(`Uploaded ${d.kind} to the ad account library${d.hash ? ` (image hash ${d.hash})` : d.videoId ? ` (video id ${d.videoId})` : ''}. ${d.note || ''}`, d);
563
+ const summary = d.assets ? `Uploaded ${d.assets.length} asset${d.assets.length > 1 ? 's' : ''} to the ad account library.` : `Uploaded ${d.kind} to the ad account library${d.hash ? ` (image hash ${d.hash})` : d.videoId ? ` (video id ${d.videoId})` : ''}.`;
564
+ return ok(`${summary} ${d.note || ''}`.trim(), d);
533
565
  }));
534
566
  server.registerTool('create_meta_campaign', {
535
567
  title: 'Create a Meta ad campaign (paused)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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 — 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",