@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 +25 -1
- package/dist/index.js +194 -23
- package/package.json +3 -4
- package/src/client.ts +3 -3
- package/src/index.ts +286 -24
- package/src/login.ts +19 -17
- package/src/uploads.ts +25 -0
- package/dist/login.js +0 -377
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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", {
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
30072
|
-
|
|
30073
|
-
|
|
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
|
|
30079
|
-
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
}
|