@hubfluencer/mcp 0.15.0 → 0.17.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.
@@ -41,6 +41,15 @@
41
41
  */
42
42
 
43
43
  import { z } from "zod";
44
+ import { generationSummarySchema } from "./generation-summary.js";
45
+
46
+ /**
47
+ * Fallback contract for tools whose server-owned payload is intentionally
48
+ * open-ended. Advertising an object schema is still materially better than
49
+ * omitting outputSchema: clients can request structured output consistently,
50
+ * while the stable/high-value tools below keep their precise schemas.
51
+ */
52
+ export const genericToolOutput = z.object({}).passthrough();
44
53
 
45
54
  /** A presigned URL or a local-null field that may be absent/variable. */
46
55
  const urlish = z.unknown().optional();
@@ -62,9 +71,16 @@ export const getStatusOutput = z
62
71
  ready: z.boolean().optional(),
63
72
  video_url: z.union([z.string(), z.null()]).optional(),
64
73
  error: z.union([z.string(), z.null()]).optional(),
74
+ generation_summary: generationSummarySchema.optional(),
75
+ run_id: z.union([z.string(), z.null()]).optional(),
76
+ workflow: z.union([z.string(), z.null()]).optional(),
77
+ revision: z.union([z.number(), z.null()]).optional(),
78
+ poll_after_ms: z.union([z.number(), z.null()]).optional(),
79
+ available_actions: z.array(z.string()).optional(),
65
80
  stale: z.boolean().optional(),
66
81
  active_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
67
82
  stale_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
83
+ autopilot_contract_satisfied: z.boolean().optional(),
68
84
  // Short only, additive (normalizeStatus forwards each gated on its typeof,
69
85
  // so the precise type can never reject a payload).
70
86
  failed_stage: z
@@ -91,10 +107,6 @@ export const getStatusOutput = z
91
107
  .number()
92
108
  .optional()
93
109
  .describe("Short only: paid generation attempt number that failed."),
94
- generation_attempt: z
95
- .number()
96
- .optional()
97
- .describe("Short only: current paid attempt; echo when cancelling."),
98
110
  segments_completed: z.number().optional(),
99
111
  segments_total: z.number().optional(),
100
112
  active_segment_positions: z.array(z.number()).optional(),
@@ -103,6 +115,24 @@ export const getStatusOutput = z
103
115
  })
104
116
  .passthrough();
105
117
 
118
+ /**
119
+ * get_editor -> full or concise Editor state.
120
+ *
121
+ * The payload is intentionally otherwise permissive because the full Editor
122
+ * graph is additive and much larger than the MCP review projection. The
123
+ * lifecycle authority is the one field that is never optional: advertising it
124
+ * here makes strict MCP clients see the same requirement that the handler
125
+ * enforces at runtime.
126
+ */
127
+ export const getEditorOutput = z
128
+ .object({
129
+ generation_summary: generationSummarySchema,
130
+ slug: z.string().optional(),
131
+ segments: z.array(z.unknown()).optional(),
132
+ latest_render: z.unknown().optional(),
133
+ })
134
+ .passthrough();
135
+
106
136
  /** wait_for_completion → get_status plus the download/timeout bookkeeping. */
107
137
  export const waitForCompletionOutput = z
108
138
  .object({
@@ -113,9 +143,16 @@ export const waitForCompletionOutput = z
113
143
  ready: z.boolean().optional(),
114
144
  video_url: z.union([z.string(), z.null()]).optional(),
115
145
  error: z.union([z.string(), z.null()]).optional(),
146
+ generation_summary: generationSummarySchema.optional(),
147
+ run_id: z.union([z.string(), z.null()]).optional(),
148
+ workflow: z.union([z.string(), z.null()]).optional(),
149
+ revision: z.union([z.number(), z.null()]).optional(),
150
+ poll_after_ms: z.union([z.number(), z.null()]).optional(),
151
+ available_actions: z.array(z.string()).optional(),
116
152
  stale: z.boolean().optional(),
117
153
  active_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
118
154
  stale_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
155
+ autopilot_contract_satisfied: z.boolean().optional(),
119
156
  // Short only, additive — mirrors getStatusOutput (see there).
120
157
  failed_stage: z
121
158
  .string()
@@ -141,10 +178,6 @@ export const waitForCompletionOutput = z
141
178
  .number()
142
179
  .optional()
143
180
  .describe("Short only: paid generation attempt number that failed."),
144
- generation_attempt: z
145
- .number()
146
- .optional()
147
- .describe("Short only: current paid attempt; echo when cancelling."),
148
181
  segments_completed: z.number().optional(),
149
182
  segments_total: z.number().optional(),
150
183
  active_segment_positions: z.array(z.number()).optional(),
@@ -152,6 +185,7 @@ export const waitForCompletionOutput = z
152
185
  last_free_rerender_failed: z.boolean().optional(),
153
186
  saved_to: z.union([z.string(), z.null()]).optional(),
154
187
  timed_out: z.boolean().optional(),
188
+ polling_stopped: z.boolean().optional(),
155
189
  })
156
190
  .passthrough();
157
191
 
@@ -212,26 +246,63 @@ export const getSliderOutput = z
212
246
 
213
247
  // ── Projects listing ────────────────────────────────────────────────────────────
214
248
 
215
- /** A normalized status row inside list_projects (permissive server-derived). */
249
+ /** A permissive normalized status row reused by nested create-tool results. */
216
250
  const projectRow = z
217
251
  .object({
218
252
  kind: z.string().optional(),
219
253
  slug: z.string().optional(),
220
254
  stage: z.string().optional(),
255
+ kind_detail: z.union([z.string(), z.null()]).optional(),
221
256
  terminal: z.boolean().optional(),
222
257
  ready: z.boolean().optional(),
223
258
  video_url: z.union([z.string(), z.null()]).optional(),
224
259
  error: z.union([z.string(), z.null()]).optional(),
260
+ generation_summary: generationSummarySchema.optional(),
261
+ run_id: z.union([z.string(), z.null()]).optional(),
262
+ workflow: z.union([z.string(), z.null()]).optional(),
263
+ revision: z.union([z.number(), z.null()]).optional(),
264
+ poll_after_ms: z.union([z.number(), z.null()]).optional(),
265
+ available_actions: z.array(z.string()).optional(),
266
+ segments_completed: z.number().optional(),
267
+ segments_total: z.number().optional(),
268
+ active_segment_positions: z.array(z.number()).optional(),
225
269
  latest_render_free: z.boolean().optional(),
226
270
  last_free_rerender_failed: z.boolean().optional(),
227
271
  })
228
272
  .passthrough();
229
273
 
230
- /** list_projects the two normalized lanes plus an optional truncation note. */
274
+ /** Summary-bearing Short row emitted by normalizeStatus in list_projects. */
275
+ const listedShortProject = projectRow.extend({
276
+ kind: z.literal("short"),
277
+ slug: z.string(),
278
+ stage: z.string(),
279
+ terminal: z.boolean(),
280
+ ready: z.boolean(),
281
+ video_url: z.union([z.string(), z.null()]),
282
+ error: z.union([z.string(), z.null()]),
283
+ generation_summary: generationSummarySchema,
284
+ run_id: z.union([z.string(), z.null()]),
285
+ workflow: z.union([z.string(), z.null()]),
286
+ revision: z.union([z.number(), z.null()]),
287
+ poll_after_ms: z.union([z.number(), z.null()]),
288
+ available_actions: z.array(z.string()),
289
+ });
290
+
291
+ /** Summary-free mixed-kind discovery row emitted by normalizeFactoryIndexProject. */
292
+ const listedGenericProject = z
293
+ .object({
294
+ slug: z.string(),
295
+ kind_detail: z.union([z.string(), z.null()]),
296
+ stage: z.string(),
297
+ video_url: z.union([z.string(), z.null()]),
298
+ })
299
+ .passthrough();
300
+
301
+ /** list_projects → two always-present lanes plus an optional truncation note. */
231
302
  export const listProjectsOutput = z
232
303
  .object({
233
- shorts: z.array(projectRow).optional(),
234
- projects: z.array(projectRow).optional(),
304
+ shorts: z.array(listedShortProject),
305
+ projects: z.array(listedGenericProject),
235
306
  note: z.string().optional(),
236
307
  })
237
308
  .passthrough();
@@ -292,6 +363,8 @@ export const makeVideoOutput = z
292
363
  charged: z.boolean().optional(),
293
364
  saved_to: z.union([z.string(), z.null()]).optional(),
294
365
  timed_out: z.boolean().optional(),
366
+ replayed: z.boolean().optional(),
367
+ polling_stopped: z.boolean().optional(),
295
368
  note: z.string().optional(),
296
369
  })
297
370
  .passthrough();
package/src/polling.ts ADDED
@@ -0,0 +1,64 @@
1
+ export interface PollOptions<T> {
2
+ read: (deadline: number) => Promise<T>;
3
+ isTerminal: (value: T) => boolean;
4
+ /** False means the server has explicitly told this client to stop polling. */
5
+ shouldContinue?: (value: T) => boolean;
6
+ budgetMs: number;
7
+ intervalMs: number | ((value: T) => number);
8
+ onPoll?: (value: T, pollNumber: number) => Promise<void>;
9
+ now?: () => number;
10
+ sleep?: (milliseconds: number) => Promise<void>;
11
+ }
12
+
13
+ /**
14
+ * Poll within a fixed budget, then perform one separately bounded
15
+ * reconciliation read. The final read closes the common race where work
16
+ * finishes during the last partial interval and the caller otherwise returns
17
+ * the stale pre-completion snapshot.
18
+ */
19
+ export async function pollWithFinalReconciliation<T>({
20
+ read,
21
+ isTerminal,
22
+ shouldContinue = () => true,
23
+ budgetMs,
24
+ intervalMs,
25
+ onPoll,
26
+ now = Date.now,
27
+ sleep = (milliseconds) =>
28
+ new Promise<void>((resolve) => setTimeout(resolve, milliseconds)),
29
+ }: PollOptions<T>): Promise<T> {
30
+ const deadline = now() + budgetMs;
31
+ let value = await read(deadline);
32
+ let pollNumber = 0;
33
+ let lastIntervalMs = resolveInterval(intervalMs, value);
34
+
35
+ while (!isTerminal(value) && shouldContinue(value)) {
36
+ const remainingMs = deadline - now();
37
+ if (remainingMs <= 0) break;
38
+ await onPoll?.(value, ++pollNumber);
39
+ lastIntervalMs = resolveInterval(intervalMs, value);
40
+ await sleep(Math.min(lastIntervalMs, remainingMs));
41
+ if (now() >= deadline) break;
42
+ value = await read(deadline);
43
+ }
44
+
45
+ if (!isTerminal(value) && shouldContinue(value)) {
46
+ const finalReadDeadline =
47
+ now() + Math.min(5_000, Math.max(1_000, lastIntervalMs));
48
+ try {
49
+ value = await read(finalReadDeadline);
50
+ } catch {
51
+ // The normal timeout/resume contract uses the last successful state.
52
+ }
53
+ }
54
+
55
+ return value;
56
+ }
57
+
58
+ function resolveInterval<T>(
59
+ interval: PollOptions<T>["intervalMs"],
60
+ value: T,
61
+ ): number {
62
+ const resolved = typeof interval === "function" ? interval(value) : interval;
63
+ return Number.isFinite(resolved) && resolved > 0 ? resolved : 1_000;
64
+ }
@@ -0,0 +1,100 @@
1
+ import { access, cp, mkdir, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { VERSION } from "./version.js";
6
+
7
+ export const SKILL_CLIENTS = [
8
+ "codex",
9
+ "claude-code",
10
+ "opencode",
11
+ "pi",
12
+ ] as const;
13
+ export type SkillClient = (typeof SKILL_CLIENTS)[number];
14
+
15
+ const sourceDirectory = fileURLToPath(
16
+ new URL("../skill/hubfluencer-create", import.meta.url),
17
+ );
18
+
19
+ function parseClient(value: string | undefined): SkillClient {
20
+ if (SKILL_CLIENTS.includes(value as SkillClient)) return value as SkillClient;
21
+ throw new Error(
22
+ `install-skill requires one client: ${SKILL_CLIENTS.join(", ")}.`,
23
+ );
24
+ }
25
+
26
+ export function skillTarget(
27
+ client: SkillClient,
28
+ project: boolean,
29
+ cwd = process.cwd(),
30
+ home = homedir(),
31
+ ): string {
32
+ const root = project ? cwd : home;
33
+ switch (client) {
34
+ case "codex":
35
+ return join(root, ".agents", "skills", "hubfluencer-create");
36
+ case "claude-code":
37
+ return join(root, ".claude", "skills", "hubfluencer-create");
38
+ case "opencode":
39
+ return project
40
+ ? join(root, ".opencode", "skills", "hubfluencer-create")
41
+ : join(root, ".config", "opencode", "skills", "hubfluencer-create");
42
+ case "pi":
43
+ return project
44
+ ? join(root, ".pi", "skills", "hubfluencer-create")
45
+ : join(root, ".pi", "agent", "skills", "hubfluencer-create");
46
+ }
47
+ }
48
+
49
+ export type InstallSkillOptions = {
50
+ client: SkillClient;
51
+ project: boolean;
52
+ force: boolean;
53
+ source?: string;
54
+ cwd?: string;
55
+ home?: string;
56
+ };
57
+
58
+ export async function installSkill(
59
+ options: InstallSkillOptions,
60
+ ): Promise<string> {
61
+ const source = options.source ?? sourceDirectory;
62
+ const target = skillTarget(
63
+ options.client,
64
+ options.project,
65
+ options.cwd,
66
+ options.home,
67
+ );
68
+ await access(source);
69
+ try {
70
+ await access(target);
71
+ if (!options.force) {
72
+ throw new Error(
73
+ `Skill already exists at ${target}. Re-run with --force to replace it.`,
74
+ );
75
+ }
76
+ await rm(target, { recursive: true, force: true });
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
79
+ }
80
+ await mkdir(dirname(target), { recursive: true });
81
+ await cp(source, target, { recursive: true });
82
+ return target;
83
+ }
84
+
85
+ export async function runInstallSkill(args: string[]): Promise<void> {
86
+ const positional = args.filter((arg) => !arg.startsWith("--"));
87
+ const clientFlag = args.indexOf("--client");
88
+ const client = parseClient(
89
+ clientFlag >= 0 ? args[clientFlag + 1] : positional[1],
90
+ );
91
+ const target = await installSkill({
92
+ client,
93
+ project: args.includes("--project"),
94
+ force: args.includes("--force"),
95
+ });
96
+ console.log(`Installed hubfluencer-create for ${client}: ${target}`);
97
+ console.log(
98
+ `Restart the agent so it discovers the skill, then run \`npx -y @hubfluencer/mcp@${VERSION} doctor --profile creator\`.`,
99
+ );
100
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Tool discovery profiles.
3
+ *
4
+ * `full` remains the default so existing MCP configurations keep the exact
5
+ * surface they already use. `creator` is an opt-in, low-context surface for
6
+ * agents that primarily need to create, iterate, recover, and download assets.
7
+ * The server still owns the same implementation; this only changes tools/list.
8
+ */
9
+
10
+ export const TOOL_PROFILES = ["full", "creator"] as const;
11
+ export type ToolProfile = (typeof TOOL_PROFILES)[number];
12
+
13
+ export const CREATOR_TOOL_NAMES = [
14
+ "make_video",
15
+ "get_credits",
16
+ "list_voices",
17
+ "list_projects",
18
+ "create_short",
19
+ "update_short",
20
+ "generate_short",
21
+ "cancel_short_generation",
22
+ "rerender_short",
23
+ "set_short_product",
24
+ "set_short_logo",
25
+ "set_short_poster",
26
+ "create_slider",
27
+ "generate_slider",
28
+ "get_slider",
29
+ "restyle_slider",
30
+ "edit_slider_slide",
31
+ "create_tracking_video",
32
+ "create_editor_ad",
33
+ "start_autopilot",
34
+ "provide_generation_input",
35
+ "get_editor",
36
+ "retry_segment_generation",
37
+ "cancel_segment_generation",
38
+ "retry_render",
39
+ "retry_editor_batch",
40
+ "cancel_editor_batch",
41
+ "cancel_autopilot",
42
+ "get_status",
43
+ "wait_for_completion",
44
+ "download_result",
45
+ ] as const;
46
+
47
+ const creatorTools = new Set<string>(CREATOR_TOOL_NAMES);
48
+
49
+ export function parseProfile(value: string | undefined): ToolProfile {
50
+ const normalized = value?.trim().toLowerCase();
51
+ if (!normalized || normalized === "full") return "full";
52
+ if (normalized === "creator") return "creator";
53
+ throw new Error(
54
+ `Unknown Hubfluencer MCP profile "${value}". Use "creator" or "full".`,
55
+ );
56
+ }
57
+
58
+ export function profileFromArgs(
59
+ args: string[],
60
+ envValue = process.env.HUBFLUENCER_MCP_PROFILE,
61
+ ): ToolProfile {
62
+ const equalsArg = args.find((arg) => arg.startsWith("--profile="));
63
+ if (equalsArg) return parseProfile(equalsArg.slice("--profile=".length));
64
+
65
+ const index = args.indexOf("--profile");
66
+ if (index >= 0) {
67
+ const value = args[index + 1];
68
+ if (!value || value.startsWith("-")) {
69
+ throw new Error("--profile requires creator or full.");
70
+ }
71
+ return parseProfile(value);
72
+ }
73
+
74
+ return parseProfile(envValue);
75
+ }
76
+
77
+ export function withoutProfileArgs(args: string[]): string[] {
78
+ const result: string[] = [];
79
+ for (let index = 0; index < args.length; index += 1) {
80
+ const arg = args[index];
81
+ if (arg.startsWith("--profile=")) continue;
82
+ if (arg === "--profile") {
83
+ index += 1;
84
+ continue;
85
+ }
86
+ result.push(arg);
87
+ }
88
+ return result;
89
+ }
90
+
91
+ export function toolEnabled(profile: ToolProfile, name: string): boolean {
92
+ return profile === "full" || creatorTools.has(name);
93
+ }