@hubfluencer/mcp 0.1.0 → 0.3.0

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
@@ -12,6 +12,11 @@ auth is an opaque bearer token passed through from an env var.
12
12
  No install step — run it on demand with `npx` (commands below). For local development,
13
13
  build from source instead: see [Develop](#develop).
14
14
 
15
+ > **Run these in a normal terminal** (Terminal, iTerm, your IDE's terminal) — **not inside a
16
+ > running Claude Code / agent session.** A nested `claude` forwards its arguments to the model
17
+ > as a prompt instead of executing the `mcp add` subcommand, so the server is never registered
18
+ > (you'll later see `/mcp` → *Failed to reconnect: -32000*). See [Troubleshooting](#troubleshooting).
19
+
15
20
  ### 1. Connect (recommended — no copy-paste)
16
21
 
17
22
  Run the device-link login. It prints a URL + short code; you approve it in the signed-in
@@ -19,7 +24,7 @@ Hubfluencer app, and a scoped access token is saved locally to `~/.hubfluencer/c
19
24
  (mode 0600) — the MCP server reads it from there automatically.
20
25
 
21
26
  ```bash
22
- npx -y -p @hubfluencer/mcp hubfluencer-login "Claude Code"
27
+ npx -y @hubfluencer/mcp login "Claude Code"
23
28
  # 1. Open: https://hubfluencer.com/connect?code=ABCD-EFGH
24
29
  # 2. Confirm code: ABCDEFGH
25
30
  # 3. Click Approve. → ✓ Connected.
@@ -31,6 +36,12 @@ Then register the server (no token env needed — it uses the stored credential)
31
36
  claude mcp add hubfluencer -- npx -y @hubfluencer/mcp
32
37
  ```
33
38
 
39
+ Verify it connected:
40
+
41
+ ```bash
42
+ claude mcp list # → hubfluencer: ... ✓ Connected
43
+ ```
44
+
34
45
  ### Alternative: paste an access token
35
46
 
36
47
  Create a token in the app (**Settings → Access tokens**, scoped `video:generate`+`video:read` —
@@ -96,6 +107,15 @@ If it returns `terminal:false`, the render is still going — call `wait_for_com
96
107
  > TikTok/Instagram requires a human-linked social account and is out of scope —
97
108
  > return the MP4 + a suggested caption instead.
98
109
 
110
+ ## Troubleshooting
111
+
112
+ | Symptom | Cause & fix |
113
+ |---|---|
114
+ | `/mcp` → **Failed to reconnect: -32000** | The server process never started — almost always because `claude mcp add` was run **inside a Claude session** (a nested `claude` runs the line as a prompt, not the subcommand), so nothing was registered. Re-run the `claude mcp add` above in a **normal terminal**, then `claude mcp list` to confirm. |
115
+ | `npm error could not determine executable to run` | You're on a cached **0.1.x** (which shipped two bins). 0.2.0+ ships a single bin and resolves cleanly. Force a fresh fetch: `npx --ignore-existing -y @hubfluencer/mcp`. |
116
+ | `npm error 404 … @hubfluencer/mcp` immediately after a release | npm registry/CDN propagation lag (a couple of minutes after publish). Wait and retry — it self-heals. |
117
+ | Tools fail with **"Not connected to Hubfluencer"** | No token resolved. Run `npx -y @hubfluencer/mcp login`, or pass `--env HUBFLUENCER_API_TOKEN=…`. |
118
+
99
119
  ## Develop
100
120
 
101
121
  ```bash
@@ -105,6 +125,10 @@ bun run dev # runs the stdio server (talk to it via an MCP client)
105
125
  bun run build # emits dist/
106
126
  ```
107
127
 
128
+ The package exposes a single bin, `hubfluencer-mcp`: with no args it starts the stdio MCP
129
+ server; `hubfluencer-mcp login [name]` runs the device-link login. This is why
130
+ `npx -y @hubfluencer/mcp` and `npx -y @hubfluencer/mcp login` both work without `-p`.
131
+
108
132
  ## License
109
133
 
110
134
  [MIT](./LICENSE) © Monocursive
package/dist/index.js CHANGED
@@ -11964,7 +11964,7 @@ var require_dist = __commonJS((exports, module) => {
11964
11964
  });
11965
11965
 
11966
11966
  // src/index.ts
11967
- import { writeFile } from "node:fs/promises";
11967
+ import { writeFile as writeFile2 } from "node:fs/promises";
11968
11968
 
11969
11969
  // ../../node_modules/@modelcontextprotocol/sdk/node_modules/zod/v3/helpers/util.js
11970
11970
  var util;
@@ -29569,11 +29569,88 @@ function clientFromEnv() {
29569
29569
  const token = process.env.HUBFLUENCER_API_TOKEN || stored.token;
29570
29570
  const baseUrl = process.env.HUBFLUENCER_BASE_URL || stored.base_url || "https://hubfluencer.com";
29571
29571
  if (!token) {
29572
- throw new Error("Not connected to Hubfluencer. Run `hubfluencer-login` to connect, or set " + "HUBFLUENCER_API_TOKEN (create one in the app: Settings → Access tokens).");
29572
+ throw new Error("Not connected to Hubfluencer. Run `npx -y @hubfluencer/mcp login` to connect, or set " + "HUBFLUENCER_API_TOKEN (create one in the app: Settings → Access tokens).");
29573
29573
  }
29574
29574
  return new HubfluencerClient(baseUrl, token);
29575
29575
  }
29576
29576
 
29577
+ // src/login.ts
29578
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
29579
+ import { dirname } from "node:path";
29580
+ var MAX_POLLS = 120;
29581
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29582
+ async function runLogin(clientName = "Claude Code") {
29583
+ const BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com").replace(/\/+$/, "").replace(/\/api$/, "");
29584
+ assertSafeBaseUrl(BASE);
29585
+ const startRes = await fetch(`${BASE}/api/agent-link/start`, {
29586
+ method: "POST",
29587
+ headers: { "content-type": "application/json", accept: "application/json" },
29588
+ body: JSON.stringify({
29589
+ client_name: clientName,
29590
+ scopes: ["video:generate", "video:read"]
29591
+ })
29592
+ });
29593
+ if (!startRes.ok) {
29594
+ console.error(`Could not start login (HTTP ${startRes.status}). Check HUBFLUENCER_BASE_URL.`);
29595
+ process.exit(1);
29596
+ }
29597
+ const start = await startRes.json();
29598
+ const url = start.verification_uri_complete || start.verification_uri;
29599
+ console.error(`
29600
+ Connect this agent to Hubfluencer:
29601
+ `);
29602
+ console.error(` 1. Open: ${url}`);
29603
+ console.error(` 2. Confirm code: ${start.user_code}`);
29604
+ console.error(` 3. Click Approve (you'll need to be signed in).
29605
+ `);
29606
+ console.error("Waiting for approval…");
29607
+ let intervalMs = Math.max(2, start.interval ?? 5) * 1000;
29608
+ const MAX_INTERVAL_MS = 30000;
29609
+ for (let i = 0;i < MAX_POLLS; i++) {
29610
+ await sleep(intervalMs);
29611
+ const pollRes = await fetch(`${BASE}/api/agent-link/poll`, {
29612
+ method: "POST",
29613
+ headers: {
29614
+ "content-type": "application/json",
29615
+ accept: "application/json"
29616
+ },
29617
+ body: JSON.stringify({ device_code: start.device_code })
29618
+ });
29619
+ const body = await pollRes.json().catch(() => ({}));
29620
+ if (pollRes.ok && body.status === "approved" && body.token) {
29621
+ await storeToken(body.token, BASE);
29622
+ console.error(`
29623
+ ✓ Connected. Access token saved to ${CREDENTIALS_PATH}.`);
29624
+ console.error(` Revoke anytime in the app: Settings → Access tokens.
29625
+ `);
29626
+ return;
29627
+ }
29628
+ if (body.status === "slow_down") {
29629
+ intervalMs = Math.min(intervalMs + 5000, MAX_INTERVAL_MS);
29630
+ continue;
29631
+ }
29632
+ if (body.status === "pending")
29633
+ continue;
29634
+ console.error(`
29635
+ ✗ ${body.error || body.status || `HTTP ${pollRes.status}`}. Run login again.`);
29636
+ process.exit(1);
29637
+ }
29638
+ console.error(`
29639
+ ✗ Timed out waiting for approval. Run login again.`);
29640
+ process.exit(1);
29641
+ }
29642
+ async function storeToken(token, base) {
29643
+ const dir = dirname(CREDENTIALS_PATH);
29644
+ await mkdir(dir, { recursive: true, mode: 448 });
29645
+ await writeFile(CREDENTIALS_PATH, JSON.stringify({ token, base_url: base }, null, 2), {
29646
+ mode: 384
29647
+ });
29648
+ try {
29649
+ await chmod(CREDENTIALS_PATH, 384);
29650
+ await chmod(dir, 448);
29651
+ } catch {}
29652
+ }
29653
+
29577
29654
  // src/uploads.ts
29578
29655
  import { open, readFile, stat } from "node:fs/promises";
29579
29656
  import { basename, extname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
@@ -29745,6 +29822,14 @@ async function uploadImageFile(client, presignPath, filePath, maxBytes = MAX_IMA
29745
29822
  await putToPresignedUrl(presigned_url, buf, file.mime);
29746
29823
  return { s3_key };
29747
29824
  }
29825
+ async function uploadShortPoster(client, slug, filePath) {
29826
+ const file = await resolveReadPath(filePath, IMAGE_EXT_MIME, MAX_IMAGE_BYTES);
29827
+ const presign = await client.post(`/shorts/${slug}/poster/presign`, { content_type: file.mime });
29828
+ const { upload_url, s3_key } = presign;
29829
+ const buf = await readFile(file.path);
29830
+ await putToPresignedUrl(upload_url, buf, file.mime);
29831
+ return { s3_key };
29832
+ }
29748
29833
 
29749
29834
  // src/index.ts
29750
29835
  async function fetchStatus(client, kind, slug) {
@@ -29786,7 +29871,7 @@ async function downloadTo(videoUrl, savePath) {
29786
29871
  await reader.cancel().catch(() => {});
29787
29872
  }
29788
29873
  const buf = Buffer.concat(chunks, total);
29789
- await writeFile(target, buf);
29874
+ await writeFile2(target, buf);
29790
29875
  return { saved_to: target, bytes: buf.length };
29791
29876
  }
29792
29877
  function ok(payload, links = []) {
@@ -29883,7 +29968,7 @@ function tool(fn) {
29883
29968
  }
29884
29969
  };
29885
29970
  }
29886
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29971
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
29887
29972
  var kindSchema = exports_external.enum(["short", "editor"]).describe("Project kind");
29888
29973
  var RO = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
29889
29974
  var WRITE = {
@@ -29891,7 +29976,7 @@ var WRITE = {
29891
29976
  destructiveHint: false,
29892
29977
  openWorldHint: true
29893
29978
  };
29894
- var server = new McpServer({ name: "hubfluencer", version: "0.1.0" });
29979
+ var server = new McpServer({ name: "hubfluencer", version: "0.3.0" });
29895
29980
  var registerTool = server.registerTool.bind(server);
29896
29981
  async function pollToTerminal(client, kind, slug, extra, budgetMs, intervalMs) {
29897
29982
  const deadline = Date.now() + budgetMs;
@@ -29899,7 +29984,7 @@ async function pollToTerminal(client, kind, slug, extra, budgetMs, intervalMs) {
29899
29984
  let n = 0;
29900
29985
  while (!status.terminal && Date.now() + intervalMs <= deadline) {
29901
29986
  await reportProgress(extra, ++n, `stage: ${status.stage}`);
29902
- await sleep(intervalMs);
29987
+ await sleep2(intervalMs);
29903
29988
  status = await fetchStatus(client, kind, slug);
29904
29989
  }
29905
29990
  return status;
@@ -29921,7 +30006,7 @@ async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, i
29921
30006
  let { status, error: error2 } = await read();
29922
30007
  while (status !== "completed" && status !== "failed" && Date.now() + intervalMs <= deadline) {
29923
30008
  await reportProgress(extra, ++n, `segment ${sid}: ${status}`);
29924
- await sleep(intervalMs);
30009
+ await sleep2(intervalMs);
29925
30010
  ({ status, error: error2 } = await read());
29926
30011
  }
29927
30012
  return {
@@ -29932,13 +30017,17 @@ async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, i
29932
30017
  }
29933
30018
  registerTool("make_video", {
29934
30019
  title: "Make a video from a prompt (one shot)",
29935
- description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish.",
30020
+ description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish. " + "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " + "music_vibe/theme so a one-shot short isn't bare — these apply to SHORTS (music_vibe/headline/subheadline " + "are ignored for editor; theme applies to both). For richer branding — a product image, brand logo, or " + "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " + "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
29936
30021
  inputSchema: {
29937
30022
  prompt: exports_external.string().describe("What the ad/video should be about (min 10 chars)"),
29938
30023
  kind: exports_external.string().optional().describe("'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)"),
29939
30024
  language: exports_external.string().optional().describe('Language code, e.g. "en" (default)'),
29940
30025
  aspect: exports_external.string().optional().describe("Aspect ratio for editor: 9:16 (default), 16:9, or 1:1"),
29941
30026
  voice_id: exports_external.string().optional().describe("Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover"),
30027
+ headline: exports_external.string().optional().describe("SHORTS only: the on-screen TITLE overlay (≤160). Set this so the short isn't bare."),
30028
+ subheadline: exports_external.string().optional().describe("SHORTS only: the secondary title / supporting line (≤200)"),
30029
+ music_vibe: exports_external.string().optional().describe("SHORTS only: music mood — Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz"),
30030
+ theme: exports_external.string().optional().describe("Visual theme (both kinds): none (literal — no imposed style), realistic (default), cinematic, " + "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " + "minimalist, cyberpunk"),
29942
30031
  save_path: exports_external.string().optional().describe("Optional .mp4 path to download to (confined to HUBFLUENCER_OUTPUT_DIR or cwd)"),
29943
30032
  max_wait_seconds: exports_external.number().optional().describe("Block budget seconds (default 240, capped 10–280)"),
29944
30033
  dry_run: exports_external.boolean().optional().describe("Preview only: create a free draft, price it, and STOP before spending credits. " + "Returns {estimated_credits, available_credits, slug}. Resume with generate_short / start_autopilot."),
@@ -29957,15 +30046,23 @@ registerTool("make_video", {
29957
30046
  resolveSavePath(args.save_path);
29958
30047
  let slug;
29959
30048
  if (kind === "short") {
29960
- const created = await client.post("/shorts", { product_prompt: args.prompt, language: args.language }, idemKey("make-short", args.prompt, args.language ?? ""));
30049
+ const created = await client.post("/shorts", {
30050
+ product_prompt: args.prompt,
30051
+ language: args.language,
30052
+ headline: args.headline,
30053
+ subheadline: args.subheadline,
30054
+ music_vibe: args.music_vibe,
30055
+ theme: args.theme
30056
+ }, idemKey("make-short", args.prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", args.music_vibe ?? "", args.theme ?? ""));
29961
30057
  slug = created.data.slug;
29962
30058
  } else {
29963
30059
  const created = await client.post("/editor", {
29964
30060
  language: args.language ?? "en",
29965
30061
  product_prompt: args.prompt,
29966
30062
  export_aspect_ratio: args.aspect,
29967
- voice_id: args.voice_id
29968
- }, idemKey("make-editor", args.prompt, args.language ?? "en", args.aspect ?? "", args.voice_id ?? ""));
30063
+ voice_id: args.voice_id,
30064
+ theme: args.theme
30065
+ }, idemKey("make-editor", args.prompt, args.language ?? "en", args.aspect ?? "", args.voice_id ?? "", args.theme ?? ""));
29969
30066
  slug = created.data.slug;
29970
30067
  }
29971
30068
  const costPath = kind === "short" ? `/shorts/${slug}/cost` : `/editor/${slug}/autopilot/cost`;
@@ -30024,7 +30121,17 @@ registerTool("get_credits", {
30024
30121
  description: "Returns the authenticated account's credit balance. A short costs 15 credits.",
30025
30122
  inputSchema: {},
30026
30123
  annotations: { title: "Get credits", ...RO }
30027
- }, tool(async (_args, client) => ok(await client.get("/studio/credits"))));
30124
+ }, tool(async (_args, client) => {
30125
+ const res = await client.get("/studio/credits");
30126
+ const d = asRecord(asRecord(res).data);
30127
+ return ok({
30128
+ credits: d.credits,
30129
+ spendable_credits: d.spendable_credits,
30130
+ reserved_credits: d.reserved_credits,
30131
+ remaining_reserved_credits: d.remaining_reserved_credits,
30132
+ message: d.message
30133
+ });
30134
+ }));
30028
30135
  registerTool("list_voices", {
30029
30136
  title: "List voices",
30030
30137
  description: "Lists available narration voices (id + name). Pass a voice id as voice_id to create_editor_ad / " + "make_video to pick the narration voice for an editor ad. Shorts have no voiceover.",
@@ -30064,19 +30171,48 @@ registerTool("list_projects", {
30064
30171
  }));
30065
30172
  registerTool("create_short", {
30066
30173
  title: "Create a short (draft)",
30067
- description: "Creates a short draft from a product prompt (min 10 chars). Returns the slug. Costs 0 credits. " + "Follow with generate_short to render. Prefer make_video for the one-shot path.",
30174
+ description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " + "costs 0 credits. Follow with generate_short to render. " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe and theme that fit the brand. To brand it further, attach a product image " + "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " + "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
30068
30175
  inputSchema: {
30069
30176
  product_prompt: exports_external.string().min(10).describe("What the short should be about"),
30070
30177
  language: exports_external.string().optional().describe('Language code, e.g. "en" (default)'),
30071
- theme: exports_external.string().optional(),
30072
- headline: exports_external.string().optional(),
30073
- subheadline: exports_external.string().optional(),
30074
- music_vibe: exports_external.string().optional()
30178
+ headline: exports_external.string().max(160).optional().describe("The on-screen TITLE composited over the short (poster text overlay). ≤160 chars. Set this."),
30179
+ subheadline: exports_external.string().max(200).optional().describe("The SECONDARY title / supporting line under the headline. ≤200 chars."),
30180
+ theme: exports_external.string().optional().describe("Visual theme (default realistic). One of: none (no imposed style — follows the prompt literally), " + "realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, " + "gaming, retro_80s, minimalist, cyberpunk."),
30181
+ music_vibe: exports_external.string().optional().describe("Background-music mood. Recognized: Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz."),
30182
+ music_instruments: exports_external.array(exports_external.string()).optional().describe('Optional instrument hints, e.g. ["piano", "strings"]'),
30183
+ text_position: exports_external.enum(["top", "center", "bottom"]).optional().describe("Where the title overlay sits (default bottom)"),
30184
+ text_animation: exports_external.enum(["reveal", "typewriter", "fade_in", "pop", "bounce"]).optional().describe("Title overlay animation (default fade_in)"),
30185
+ font_family: exports_external.string().optional().describe("Overlay font (default ShortFontSpaceGrotesk). One of: ShortFontSpaceGrotesk, ShortFontMontserrat, " + "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontProximaNova, ShortFontAnton, " + "ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, " + "ShortFontTikTokSans, ShortFontBangers.")
30075
30186
  },
30076
30187
  annotations: { title: "Create short", ...WRITE, idempotentHint: true }
30077
30188
  }, tool(async (args, client) => {
30078
- const res = await client.post("/shorts", args, idemKey("create-short", String(args.product_prompt ?? "")));
30079
- return ok({ slug: res.data.slug, kind: "short", next: "generate_short" });
30189
+ const body = {
30190
+ product_prompt: args.product_prompt
30191
+ };
30192
+ if (args.language !== undefined)
30193
+ body.language = args.language;
30194
+ if (args.headline !== undefined)
30195
+ body.headline = args.headline;
30196
+ if (args.subheadline !== undefined)
30197
+ body.subheadline = args.subheadline;
30198
+ if (args.theme !== undefined)
30199
+ body.theme = args.theme;
30200
+ if (args.music_vibe !== undefined)
30201
+ body.music_vibe = args.music_vibe;
30202
+ if (args.music_instruments !== undefined)
30203
+ body.music_instruments = args.music_instruments;
30204
+ if (args.text_position !== undefined)
30205
+ body.short_text_position = args.text_position;
30206
+ if (args.text_animation !== undefined)
30207
+ body.short_text_animation = args.text_animation;
30208
+ if (args.font_family !== undefined)
30209
+ body.short_font_family = args.font_family;
30210
+ const res = await client.post("/shorts", body, idemKey("create-short", args.product_prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? ""));
30211
+ return ok({
30212
+ slug: res.data.slug,
30213
+ kind: "short",
30214
+ next: "set_short_product / set_short_poster (optional branding), then generate_short"
30215
+ });
30080
30216
  }));
30081
30217
  registerTool("generate_short", {
30082
30218
  title: "Generate (render) a short",
@@ -30090,11 +30226,11 @@ registerTool("generate_short", {
30090
30226
  }));
30091
30227
  registerTool("create_editor_ad", {
30092
30228
  title: "Create a multi-scene editor ad (autopilot)",
30093
- description: "Creates an editor project from a product prompt and starts autopilot (server-orchestrated " + "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " + "Returns the slug. Costs credits. Then poll with get_status / wait_for_completion (kind=editor). Prefer " + "make_video for the one-shot path.",
30229
+ description: "Creates an editor project from a product prompt and starts autopilot (server-orchestrated " + "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " + "Returns the slug. Costs credits. Then poll with get_status / wait_for_completion (kind=editor). Prefer " + "make_video for the one-shot path. " + "BRANDING: to attach the user's own product image, brand logo, or closing card, create the draft with " + "create_editor_draft first, call set_product / set_logo / set_closing_image (all 0 credits), then " + "start_autopilot — autopilot weaves them in. (Editor ads carry their copy in-scene/narration, not as a " + "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.)",
30094
30230
  inputSchema: {
30095
30231
  product_prompt: exports_external.string().min(10).describe("Brief for the ad (min 10 chars)"),
30096
30232
  language: exports_external.string().optional().describe('Language code, e.g. "en" (default)'),
30097
- theme: exports_external.string().optional().describe('Visual theme (default "realistic")'),
30233
+ theme: exports_external.string().optional().describe('Visual theme (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
30098
30234
  voice_id: exports_external.string().optional().describe("Preferred narration voice id (see list_voices); omit for the default voice"),
30099
30235
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")')
30100
30236
  },
@@ -30149,7 +30285,7 @@ registerTool("create_editor_draft", {
30149
30285
  inputSchema: {
30150
30286
  product_prompt: exports_external.string().min(10).max(5000).optional().describe("Brief for the ad — 10–5000 chars, or omit entirely"),
30151
30287
  language: exports_external.string().min(2).max(10).optional().describe('Language code, e.g. "en" (default)'),
30152
- theme: exports_external.string().optional().describe('Visual theme (default "realistic")'),
30288
+ theme: exports_external.string().optional().describe('Visual theme (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
30153
30289
  voice_id: exports_external.string().regex(/^[A-Za-z0-9_-]+$/).max(64).optional().describe("Preferred narration voice id (see list_voices); ≤64 chars, [A-Za-z0-9_-] only"),
30154
30290
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
30155
30291
  project_intent: exports_external.enum(["social_ad", "creative_story"]).optional().describe("Project intent (optional)")
@@ -30532,7 +30668,7 @@ async function pollUploadToReady(client, slug, uploadId, extra, budgetMs, interv
30532
30668
  let { status, error: error2 } = await read();
30533
30669
  while (status !== "ready" && status !== "failed" && Date.now() + intervalMs <= deadline) {
30534
30670
  await reportProgress(extra, ++n, `upload ${uid}: ${status}`);
30535
- await sleep(intervalMs);
30671
+ await sleep2(intervalMs);
30536
30672
  ({ status, error: error2 } = await read());
30537
30673
  }
30538
30674
  return {
@@ -30721,6 +30857,37 @@ registerTool("set_logo", {
30721
30857
  }
30722
30858
  return ok(result);
30723
30859
  }));
30860
+ registerTool("set_short_product", {
30861
+ title: "Attach a product image to a short (0 credits)",
30862
+ description: "Uploads a local product image and attaches it to a SHORT so the product is woven into the footage. " + "Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20 MB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Optional description " + "(≤500 chars) guides how it's featured. Attach BEFORE generate_short so the product is part of the scenes. " + "(This is the shorts equivalent of set_product for editor projects.)",
30863
+ inputSchema: {
30864
+ slug: exports_external.string().describe("Short slug (from create_short)"),
30865
+ file_path: exports_external.string().describe("Local product image path (.jpg/.jpeg/.png)"),
30866
+ description: exports_external.string().max(500).optional().describe("Product description (≤500 chars)")
30867
+ },
30868
+ annotations: {
30869
+ title: "Set short product",
30870
+ ...WRITE,
30871
+ idempotentHint: false
30872
+ }
30873
+ }, tool(async (args, client) => {
30874
+ const { s3_key } = await uploadImageFile(client, `/shorts/${args.slug}/product/presign`, args.file_path);
30875
+ const res = await client.post(`/shorts/${args.slug}/product/confirm`, { s3_key, product_description: args.description });
30876
+ return ok(asRecord(res).data ?? res);
30877
+ }));
30878
+ registerTool("set_short_poster", {
30879
+ title: "Set a short's end-card poster image (0 credits)",
30880
+ description: "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " + "render from 12s to 14s). Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20 MB), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " + "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
30881
+ inputSchema: {
30882
+ slug: exports_external.string().describe("Short slug (from create_short)"),
30883
+ file_path: exports_external.string().describe("Local poster image (.jpg/.jpeg/.png)")
30884
+ },
30885
+ annotations: { title: "Set short poster", ...WRITE, idempotentHint: false }
30886
+ }, tool(async (args, client) => {
30887
+ const { s3_key } = await uploadShortPoster(client, args.slug, args.file_path);
30888
+ const res = await client.post(`/shorts/${args.slug}/poster/confirm`, { s3_key });
30889
+ return ok(asRecord(res).data ?? res);
30890
+ }));
30724
30891
  registerTool("get_status", {
30725
30892
  title: "Get generation status",
30726
30893
  description: "Returns a normalized status {stage, terminal, ready, video_url, error} for a short or editor " + "project. ready:true means the post-ready MP4 is at video_url.",
@@ -30780,6 +30947,10 @@ registerTool("download_result", {
30780
30947
  }, link);
30781
30948
  }));
30782
30949
  async function main() {
30950
+ if (process.argv[2] === "login") {
30951
+ await runLogin(process.argv[3]);
30952
+ return;
30953
+ }
30783
30954
  const transport = new StdioServerTransport;
30784
30955
  await server.connect(transport);
30785
30956
  console.error("hubfluencer-mcp running on stdio");
package/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
5
5
  "license": "MIT",
6
6
  "author": "Monocursive <contact@monocursive.com>",
7
7
  "private": false,
8
8
  "type": "module",
9
9
  "bin": {
10
- "hubfluencer-mcp": "./dist/index.js",
11
- "hubfluencer-login": "./dist/login.js"
10
+ "hubfluencer-mcp": "./dist/index.js"
12
11
  },
13
12
  "main": "./dist/index.js",
14
13
  "files": [
@@ -20,7 +19,7 @@
20
19
  ],
21
20
  "scripts": {
22
21
  "dev": "bun run src/index.ts",
23
- "build": "rm -rf dist && bun build src/index.ts src/login.ts --target node --outdir dist",
22
+ "build": "rm -rf dist && bun build src/index.ts --target node --outdir dist",
24
23
  "prepublishOnly": "bun run build",
25
24
  "start": "node dist/index.js",
26
25
  "test": "bun test",
package/src/client.ts CHANGED
@@ -185,8 +185,8 @@ export class HubfluencerClient {
185
185
 
186
186
  /**
187
187
  * Builds a client from the environment, falling back to the device-link
188
- * credential file (written by `hubfluencer-login`). Throws a clear, actionable
189
- * error if no token is available.
188
+ * credential file (written by `hubfluencer-mcp login`). Throws a clear,
189
+ * actionable error if no token is available.
190
190
  */
191
191
  export function clientFromEnv(): HubfluencerClient {
192
192
  const stored = readStoredCredentials();
@@ -198,7 +198,7 @@ export function clientFromEnv(): HubfluencerClient {
198
198
 
199
199
  if (!token) {
200
200
  throw new Error(
201
- "Not connected to Hubfluencer. Run `hubfluencer-login` to connect, or set " +
201
+ "Not connected to Hubfluencer. Run `npx -y @hubfluencer/mcp login` to connect, or set " +
202
202
  "HUBFLUENCER_API_TOKEN (create one in the app: Settings → Access tokens).",
203
203
  );
204
204
  }