@hubfluencer/mcp 0.1.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/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/index.js +30790 -0
- package/dist/login.js +377 -0
- package/package.json +51 -0
- package/src/client.ts +206 -0
- package/src/core.ts +244 -0
- package/src/credentials.ts +48 -0
- package/src/index.ts +2310 -0
- package/src/login.ts +129 -0
- package/src/uploads.ts +369 -0
- package/tsconfig.json +19 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-free helpers shared by the server (index.ts) and its tests.
|
|
3
|
+
*
|
|
4
|
+
* These live outside index.ts on purpose: index.ts registers 23+ tools and
|
|
5
|
+
* connects an stdio transport at module load, so importing it from a unit test
|
|
6
|
+
* would boot a live MCP server. Everything here is dependency-light (crypto +
|
|
7
|
+
* path only) and safe to import in isolation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
12
|
+
|
|
13
|
+
export type Kind = "short" | "editor";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True if `hostname` is an IP literal in a private, loopback, link-local, or
|
|
17
|
+
* cloud-metadata range — i.e. somewhere we must never send a credential or
|
|
18
|
+
* exfiltrate bytes to, even over https. A non-IP hostname (a real DNS name) is
|
|
19
|
+
* treated as public/allowed: we can't resolve it here without a DNS lookup, and
|
|
20
|
+
* the threat model is a tampered base_url / API response pointing at an internal
|
|
21
|
+
* *literal* (e.g. 169.254.169.254, 10.x, ::1). Handles IPv4 dotted-quad, IPv6
|
|
22
|
+
* (incl. bracketed and zone-id forms), and IPv4-mapped IPv6.
|
|
23
|
+
*/
|
|
24
|
+
export function isPrivateOrMetadataHost(hostname: string): boolean {
|
|
25
|
+
// Strip IPv6 brackets and any zone id (e.g. "[fe80::1%eth0]" → "fe80::1").
|
|
26
|
+
let host = hostname.trim().toLowerCase();
|
|
27
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
28
|
+
const zone = host.indexOf("%");
|
|
29
|
+
if (zone !== -1) host = host.slice(0, zone);
|
|
30
|
+
|
|
31
|
+
// IPv4 dotted-quad.
|
|
32
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
33
|
+
if (v4) {
|
|
34
|
+
const o = v4.slice(1).map(Number);
|
|
35
|
+
if (o.some((n) => n > 255)) return false; // not a valid IPv4 literal
|
|
36
|
+
return isPrivateV4(o[0], o[1]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// IPv6 (anything with a colon). Normalize away an IPv4-mapped suffix first.
|
|
40
|
+
if (host.includes(":")) {
|
|
41
|
+
// ::ffff:a.b.c.d / ::a.b.c.d — judge by the embedded IPv4.
|
|
42
|
+
const mapped = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(host);
|
|
43
|
+
if (mapped) {
|
|
44
|
+
const o = mapped[1].split(".").map(Number);
|
|
45
|
+
if (!o.some((n) => n > 255) && isPrivateV4(o[0], o[1])) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (host === "::1" || host === "::") return true; // loopback / unspecified
|
|
50
|
+
if (host === "::ffff:0:0") return true;
|
|
51
|
+
// fc00::/7 (unique-local) and fe80::/10 (link-local).
|
|
52
|
+
if (/^f[cd]/.test(host)) return true;
|
|
53
|
+
if (/^fe[89ab]/.test(host)) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Classifies an IPv4 address by its first two octets — all the blocked ranges
|
|
61
|
+
// (loopback, RFC-1918 private, link-local/metadata, unspecified) are determined
|
|
62
|
+
// by a/b alone, so c/d aren't needed.
|
|
63
|
+
function isPrivateV4(a: number, b: number): boolean {
|
|
64
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
65
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
66
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
67
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
68
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + metadata
|
|
69
|
+
if (a === 0) return true; // 0.0.0.0/8 (incl. 0.0.0.0)
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Guards a URL we're about to send a credential to, or fetch/PUT bytes against:
|
|
75
|
+
* the base API URL (assertSafeBaseUrl) AND every server-returned presigned /
|
|
76
|
+
* download URL (uploads.ts / index.ts). Requires https — refusing an http
|
|
77
|
+
* downgrade that would leak the token in cleartext or move file bytes in the
|
|
78
|
+
* clear — and refuses any private/link-local/metadata IP literal so a tampered
|
|
79
|
+
* base_url or a compromised/MITM'd API response can't redirect the credential
|
|
80
|
+
* or the bytes to an internal host (SSRF). Loopback http is allowed ONLY when
|
|
81
|
+
* `allowLoopback` is set (local development). NEVER include a token in the
|
|
82
|
+
* thrown message.
|
|
83
|
+
*/
|
|
84
|
+
export function assertSafeFetchUrl(
|
|
85
|
+
url: string,
|
|
86
|
+
opts: { allowLoopback?: boolean } = {},
|
|
87
|
+
): URL {
|
|
88
|
+
let u: URL;
|
|
89
|
+
try {
|
|
90
|
+
u = new URL(url);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`Invalid Hubfluencer URL: ${url}`);
|
|
93
|
+
}
|
|
94
|
+
const host = u.hostname;
|
|
95
|
+
const isLoopback =
|
|
96
|
+
host === "localhost" ||
|
|
97
|
+
host === "127.0.0.1" ||
|
|
98
|
+
host === "::1" ||
|
|
99
|
+
host === "[::1]";
|
|
100
|
+
|
|
101
|
+
if (u.protocol === "https:") {
|
|
102
|
+
// https is necessary but not sufficient — a private/metadata IP literal
|
|
103
|
+
// over https is still an internal-redirect we refuse.
|
|
104
|
+
if (isPrivateOrMetadataHost(host)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Refusing to use a Hubfluencer URL pointing at a private/internal host (${host}). ` +
|
|
107
|
+
"Use the public https endpoint.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return u;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Non-https: only loopback, only when explicitly allowed (local dev).
|
|
114
|
+
if (opts.allowLoopback && isLoopback) return u;
|
|
115
|
+
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Refusing to use an insecure Hubfluencer URL (${u.protocol}//${host}). ` +
|
|
118
|
+
"Use an https URL (or localhost for local development).",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface NormalizedStatus {
|
|
123
|
+
kind: Kind;
|
|
124
|
+
slug: string;
|
|
125
|
+
stage: string;
|
|
126
|
+
terminal: boolean;
|
|
127
|
+
ready: boolean;
|
|
128
|
+
video_url: string | null;
|
|
129
|
+
error: string | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function asRecord(v: unknown): Record<string, unknown> {
|
|
133
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Collapses the short/editor state payloads into one shape the agent can poll. */
|
|
137
|
+
export function normalizeStatus(
|
|
138
|
+
kind: Kind,
|
|
139
|
+
slug: string,
|
|
140
|
+
data: unknown,
|
|
141
|
+
): NormalizedStatus {
|
|
142
|
+
const d = asRecord(data);
|
|
143
|
+
const latest = asRecord(d.latest_render);
|
|
144
|
+
const videoUrl = (latest.video_url as string) ?? null;
|
|
145
|
+
|
|
146
|
+
if (kind === "short") {
|
|
147
|
+
const stage = (d.stage as string) ?? "unknown";
|
|
148
|
+
return {
|
|
149
|
+
kind,
|
|
150
|
+
slug,
|
|
151
|
+
stage,
|
|
152
|
+
terminal: stage === "video_ready" || stage === "failed",
|
|
153
|
+
ready: stage === "video_ready",
|
|
154
|
+
video_url: videoUrl,
|
|
155
|
+
error: (d.error_message as string) ?? null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// editor
|
|
160
|
+
const autopilot = (d.autopilot_status as string) ?? "unknown";
|
|
161
|
+
const renderStatus = (latest.status as string) ?? null;
|
|
162
|
+
const ready = renderStatus === "completed" && Boolean(videoUrl);
|
|
163
|
+
const terminal =
|
|
164
|
+
ready ||
|
|
165
|
+
autopilot === "failed" ||
|
|
166
|
+
autopilot === "cancelled" ||
|
|
167
|
+
renderStatus === "failed";
|
|
168
|
+
return {
|
|
169
|
+
kind,
|
|
170
|
+
slug,
|
|
171
|
+
stage: autopilot,
|
|
172
|
+
terminal,
|
|
173
|
+
ready,
|
|
174
|
+
video_url: videoUrl,
|
|
175
|
+
error: (d.autopilot_error_message as string) ?? null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Stable idempotency key for a logical operation, so a retried call is safe. */
|
|
180
|
+
export function idemKey(...parts: string[]): string {
|
|
181
|
+
return createHash("sha256")
|
|
182
|
+
.update(parts.join("|"))
|
|
183
|
+
.digest("hex")
|
|
184
|
+
.slice(0, 32);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Confines a model-supplied save_path to an allowlisted output base so a
|
|
189
|
+
* prompt-injected path can't write outside it (e.g. ~/.ssh/authorized_keys).
|
|
190
|
+
* Base = HUBFLUENCER_OUTPUT_DIR if set, else the current working directory.
|
|
191
|
+
* Rejects traversal and any non-.mp4 target.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveSavePath(savePath: string): string {
|
|
194
|
+
const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
|
|
195
|
+
const target = isAbsolute(savePath)
|
|
196
|
+
? resolve(savePath)
|
|
197
|
+
: resolve(base, savePath);
|
|
198
|
+
if (!target.toLowerCase().endsWith(".mp4")) {
|
|
199
|
+
throw new Error("save_path must end in .mp4");
|
|
200
|
+
}
|
|
201
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`save_path must be inside ${base} (set HUBFLUENCER_OUTPUT_DIR to change). Refusing to write to ${target}.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return target;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Picks short vs editor for make_video's kind:"auto".
|
|
211
|
+
*
|
|
212
|
+
* Bias: an ad/commercial/promo/story brief → the multi-scene EDITOR ad (what
|
|
213
|
+
* someone asking for "a full ad" pictures), UNLESS the prompt explicitly asks
|
|
214
|
+
* for something brief/simple → the cheaper single-clip SHORT. A prompt with no
|
|
215
|
+
* signal at all falls back to the cheaper short. The product is bilingual
|
|
216
|
+
* (EN/FR); accented chars don't sit on \b cleanly, so the FR patterns avoid
|
|
217
|
+
* word boundaries.
|
|
218
|
+
*
|
|
219
|
+
* Whatever this returns, make_video surfaces it (kind_inferred) plus the priced
|
|
220
|
+
* cost before charging, so a wrong guess is visible and overridable — never a
|
|
221
|
+
* silent expensive surprise.
|
|
222
|
+
*/
|
|
223
|
+
export function inferKind(prompt: string): Kind {
|
|
224
|
+
const p = prompt.toLowerCase();
|
|
225
|
+
|
|
226
|
+
// Explicit "keep it short/simple" intent wins — route to the cheap short. A
|
|
227
|
+
// bare duration ("a 30 second ad") is deliberately NOT a brevity signal: many
|
|
228
|
+
// ad briefs name a length, and it shouldn't override clear ad/story intent.
|
|
229
|
+
const wantsShort =
|
|
230
|
+
/\b(short|quick|simple|snappy|single[- ]?clip|one[- ]?clip|teaser)\b/.test(
|
|
231
|
+
p,
|
|
232
|
+
) || /(rapide|simple|court|clip unique|tease?r)/.test(p);
|
|
233
|
+
if (wantsShort) return "short";
|
|
234
|
+
|
|
235
|
+
// Ad / marketing / multi-scene / story intent → the full multi-scene editor.
|
|
236
|
+
const wantsEditor =
|
|
237
|
+
/\b(ads?|advert|advertisement|commercial|promo|campaign|launch|brand|story|stories|multi[- ]?scene|scenes?|narrat|explainer|chapters?|episodes?|testimonial|showcase|walkthrough|demo|spot)\b/.test(
|
|
238
|
+
p,
|
|
239
|
+
) ||
|
|
240
|
+
/(pub(licit[ée])?|annonce|campagne|histoire|multi[- ]?sc[eè]ne|sc[eè]nes?|raconte|chapitres?|[eé]pisodes?|explicat|lancement|t[eé]moignage|d[eé]mo|vitrine)/.test(
|
|
241
|
+
p,
|
|
242
|
+
);
|
|
243
|
+
return wantsEditor ? "editor" : "short";
|
|
244
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local credential store for the device-link flow. The CLI writes the scoped
|
|
3
|
+
* access token here (mode 0600); the MCP server reads it as a fallback when
|
|
4
|
+
* HUBFLUENCER_API_TOKEN isn't set in the environment.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const CREDENTIALS_PATH =
|
|
12
|
+
process.env.HUBFLUENCER_CREDENTIALS ||
|
|
13
|
+
join(homedir(), ".hubfluencer", "credentials.json");
|
|
14
|
+
|
|
15
|
+
export interface StoredCredentials {
|
|
16
|
+
token?: string;
|
|
17
|
+
base_url?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readStoredCredentials(): StoredCredentials {
|
|
21
|
+
let raw: string;
|
|
22
|
+
try {
|
|
23
|
+
raw = readFileSync(CREDENTIALS_PATH, "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
// Best-effort warning if the token file is group/world-accessible — it holds a
|
|
28
|
+
// long-lived account credential. POSIX only; Windows reports mode oddly so we
|
|
29
|
+
// skip the check there. Never blocks the read.
|
|
30
|
+
if (process.platform !== "win32") {
|
|
31
|
+
try {
|
|
32
|
+
const mode = statSync(CREDENTIALS_PATH).mode;
|
|
33
|
+
if (mode & 0o077) {
|
|
34
|
+
console.error(
|
|
35
|
+
`Warning: ${CREDENTIALS_PATH} is accessible to other users ` +
|
|
36
|
+
`(mode ${(mode & 0o777).toString(8)}). Run: chmod 600 ${CREDENTIALS_PATH}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// stat is advisory — never fail the read over it
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(raw) as StoredCredentials;
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|