@viralspin/mcp 0.1.0 → 0.2.2

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.
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCreatorProfileTool = exports.setCreatorProfileTool = void 0;
4
+ /**
5
+ * set_creator_profile / get_creator_profile — the user's niche + social handles +
6
+ * topics, so the weekly idea routine knows their context. Capture it at setup
7
+ * (ask the user, or read their connected accounts via list_connections), and the
8
+ * weekly routine reads it to research what's working in their niche.
9
+ */
10
+ const client_js_1 = require("../client.js");
11
+ exports.setCreatorProfileTool = {
12
+ name: "set_creator_profile",
13
+ description: "Save the user's creator profile — their NICHE, social HANDLES (instagram/tiktok/youtube), and core TOPICS — " +
14
+ "so the weekly idea routine can research WHAT'S CURRENTLY GOING VIRAL in their niche (trending formats, hooks, " +
15
+ "and 5-7s caption reels — queue_idea format 'reel_7s') and model that week's ideas on proven winners. Capture " +
16
+ "this at setup: ask the user, or infer handles from list_connections. Call get_creator_profile first.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ niche: { type: "string", description: "The user's niche / what their content is about (e.g. 'AI tech for founders')." },
21
+ handles: {
22
+ type: "object",
23
+ description: "Their social handles: { instagram, tiktok, youtube }.",
24
+ properties: { instagram: { type: "string" }, tiktok: { type: "string" }, youtube: { type: "string" } },
25
+ },
26
+ topics: { type: "array", items: { type: "string" }, description: "Core content pillars / recurring topics." },
27
+ weekly_routine: { type: "boolean", description: "Whether the weekly 5-ideas routine is on for them." },
28
+ },
29
+ },
30
+ async execute(args) {
31
+ await (0, client_js_1.post)("/v1/spin/profile", args);
32
+ return `Saved the creator profile${args.niche ? ` (niche: ${args.niche})` : ""}. The weekly idea routine will research this niche + their accounts.`;
33
+ },
34
+ };
35
+ exports.getCreatorProfileTool = {
36
+ name: "get_creator_profile",
37
+ description: "Read the user's saved creator profile (niche, handles, topics, whether the weekly routine is on). Use at the " +
38
+ "start of a brainstorming or weekly-idea session so you target their actual niche/accounts. If it's empty, ask " +
39
+ "the user for their niche + handles (or read list_connections) and set_creator_profile.",
40
+ inputSchema: { type: "object", properties: {} },
41
+ async execute() {
42
+ const r = await (0, client_js_1.get)("/v1/spin/profile");
43
+ if (!r.profile || (!r.profile.niche && !r.profile.handles)) {
44
+ return "No creator profile set yet. Ask the user for their niche + social handles (or read list_connections), then set_creator_profile.";
45
+ }
46
+ const p = r.profile;
47
+ const handles = p.handles ? Object.entries(p.handles).filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`).join(", ") : "";
48
+ return [
49
+ `Creator profile:`,
50
+ `- niche: ${p.niche || "(not set)"}`,
51
+ handles ? `- handles: ${handles}` : `- handles: (none)`,
52
+ p.topics?.length ? `- topics: ${p.topics.join(", ")}` : "",
53
+ `- weekly routine: ${p.weekly_routine ? "on" : "off"}`,
54
+ ].filter(Boolean).join("\n");
55
+ },
56
+ };
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.schedulePostTool = exports.publishNowTool = void 0;
6
+ exports.schedulePostTool = exports.sendToDraftsTool = exports.publishNowTool = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const client_js_1 = require("../client.js");
@@ -48,8 +48,15 @@ const publishSchema = {
48
48
  },
49
49
  platforms: {
50
50
  type: "array",
51
- items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
52
- description: "Platforms to publish to. Must be connected.",
51
+ items: { type: "string", enum: ["tiktok", "instagram", "youtube", "linkedin"] },
52
+ description: "Platforms to publish to (uses the first connected account per platform). Omit when using account_ids. LinkedIn posts the caption as the article body + the images as a native multi-image carousel; LinkedIn has no draft mode.",
53
+ },
54
+ account_ids: {
55
+ type: "array",
56
+ items: { type: "string" },
57
+ description: "Specific connection IDs to publish to. Use list_connections to get IDs. " +
58
+ "Required when you have multiple accounts on the same platform and want to target a specific one. " +
59
+ "Takes precedence over 'platforms' when both are provided.",
53
60
  },
54
61
  caption: { type: "string", description: "Post caption." },
55
62
  hashtags: {
@@ -58,13 +65,18 @@ const publishSchema = {
58
65
  description: "Hashtags without # prefix.",
59
66
  },
60
67
  },
61
- required: ["paths", "platforms", "caption"],
68
+ required: ["paths", "caption"],
62
69
  };
63
70
  exports.publishNowTool = {
64
71
  name: "publish_now",
65
- description: "Upload local file(s) and publish immediately to TikTok, Instagram, and/or YouTube. Pass a folder path to publish all images in it as a carousel.",
72
+ description: "Upload local file(s) and publish immediately to TikTok, Instagram, and/or YouTube. " +
73
+ "Pass a folder path to publish all images in it as a carousel. " +
74
+ "Use account_ids (from list_connections) to target a specific account when you have multiple accounts on the same platform.",
66
75
  inputSchema: publishSchema,
67
76
  async execute(args) {
77
+ if (!args.platforms?.length && !args.account_ids?.length) {
78
+ throw new Error("Provide 'platforms' (e.g. [\"instagram\"]) or 'account_ids' from list_connections.");
79
+ }
68
80
  const filePaths = expandPaths(args.paths);
69
81
  if (!filePaths.length)
70
82
  throw new Error("No media files found in the provided paths.");
@@ -79,7 +91,8 @@ exports.publishNowTool = {
79
91
  result = await (0, client_js_1.post)("/v1/publish/schedule", {
80
92
  media_urls: urls,
81
93
  media_type: mediaType,
82
- platforms: args.platforms,
94
+ platforms: args.platforms ?? [],
95
+ ...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
83
96
  caption: args.caption,
84
97
  hashtags: args.hashtags ?? [],
85
98
  publish_now: true,
@@ -99,6 +112,81 @@ exports.publishNowTool = {
99
112
  return summary.trim();
100
113
  },
101
114
  };
115
+ exports.sendToDraftsTool = {
116
+ name: "send_to_drafts",
117
+ description: "Upload local file(s) and send to the platform's drafts (TikTok inbox / YouTube as private) so the user can finish in the mobile app — useful when they want to add licensed music or polish before publishing. " +
118
+ "Instagram does NOT support drafts via API (IG drafts are local to the mobile app only) — if instagram is in the platforms list, that platform will return an error explaining the limitation. " +
119
+ "Pass a folder path to send all images as a carousel draft.",
120
+ inputSchema: {
121
+ type: "object",
122
+ properties: {
123
+ paths: {
124
+ type: "array",
125
+ items: { type: "string" },
126
+ description: "Local file path(s) or a folder path. Single video → video draft. Multiple images or image folder → carousel draft.",
127
+ },
128
+ platforms: {
129
+ type: "array",
130
+ items: { type: "string", enum: ["tiktok", "youtube"] },
131
+ description: "Platforms to send drafts to. Must be connected. Instagram is not supported (API limitation).",
132
+ },
133
+ caption: {
134
+ type: "string",
135
+ description: "Caption text. On TikTok video drafts, the caption is NOT used (user enters it in-app); on TikTok photo drafts and YouTube drafts it is preserved.",
136
+ },
137
+ hashtags: {
138
+ type: "array",
139
+ items: { type: "string" },
140
+ description: "Hashtags without # prefix.",
141
+ },
142
+ },
143
+ required: ["paths", "platforms", "caption"],
144
+ },
145
+ async execute(args) {
146
+ if (!args.platforms?.length && !args.account_ids?.length) {
147
+ throw new Error("Provide 'platforms' (e.g. [\"tiktok\"]) or 'account_ids' from list_connections.");
148
+ }
149
+ const filePaths = expandPaths(args.paths);
150
+ if (!filePaths.length)
151
+ throw new Error("No media files found in the provided paths.");
152
+ const mediaType = detectMediaType(filePaths);
153
+ if (mediaType === "carousel" && filePaths.length > 10) {
154
+ throw new Error(`Carousel max is 10 images. Found ${filePaths.length} files.`);
155
+ }
156
+ // Surface the IG-not-supported gotcha before consuming credits / uploading.
157
+ if (args.platforms?.includes("instagram")) {
158
+ throw new Error("Instagram does not support drafts via API. IG drafts are local to the mobile app only — " +
159
+ "there is no API endpoint to send to drafts. Remove 'instagram' from platforms, or use schedule_post / publish_now instead.");
160
+ }
161
+ const { urls, storage_paths } = await (0, client_js_1.uploadFiles)(filePaths);
162
+ let result;
163
+ try {
164
+ result = await (0, client_js_1.post)("/v1/publish/schedule", {
165
+ media_urls: urls,
166
+ media_type: mediaType,
167
+ platforms: args.platforms ?? [],
168
+ ...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
169
+ caption: args.caption,
170
+ hashtags: args.hashtags ?? [],
171
+ publish_now: true,
172
+ mode: "draft",
173
+ });
174
+ }
175
+ finally {
176
+ await (0, client_js_1.deleteFiles)(storage_paths).catch(() => { });
177
+ }
178
+ const { status, platform_post_ids, failed_reason } = result.post;
179
+ const sentTo = Object.keys(platform_post_ids);
180
+ let summary = `Status: ${status}\n`;
181
+ if (sentTo.length) {
182
+ summary += `Sent to drafts on: ${sentTo.join(", ")}\n`;
183
+ summary += `Open the platform's mobile app to finish — add music, adjust caption, then publish.`;
184
+ }
185
+ if (failed_reason)
186
+ summary += `\nErrors: ${failed_reason}`;
187
+ return summary.trim();
188
+ },
189
+ };
102
190
  exports.schedulePostTool = {
103
191
  name: "schedule_post",
104
192
  description: "Upload local file(s) and schedule a post for a future time. Pass a folder path to schedule all images as a carousel.",
@@ -115,9 +203,12 @@ exports.schedulePostTool = {
115
203
  description: "IANA timezone name (e.g. 'America/New_York'). Defaults to UTC.",
116
204
  },
117
205
  },
118
- required: ["paths", "platforms", "caption", "scheduled_time"],
206
+ required: ["paths", "caption", "scheduled_time"],
119
207
  },
120
208
  async execute(args) {
209
+ if (!args.platforms?.length && !args.account_ids?.length) {
210
+ throw new Error("Provide 'platforms' (e.g. [\"instagram\"]) or 'account_ids' from list_connections.");
211
+ }
121
212
  const filePaths = expandPaths(args.paths);
122
213
  if (!filePaths.length)
123
214
  throw new Error("No media files found in the provided paths.");
@@ -128,7 +219,8 @@ exports.schedulePostTool = {
128
219
  const result = await (0, client_js_1.post)("/v1/publish/schedule", {
129
220
  media_urls: urls,
130
221
  media_type: mediaType,
131
- platforms: args.platforms,
222
+ platforms: args.platforms ?? [],
223
+ ...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
132
224
  caption: args.caption,
133
225
  hashtags: args.hashtags ?? [],
134
226
  scheduled_time: args.scheduled_time,
@@ -138,7 +230,10 @@ exports.schedulePostTool = {
138
230
  // Note: storage_paths are NOT deleted here — the cron job needs the URLs to stay valid.
139
231
  // They expire after 1 hour. For posts scheduled further out, re-upload closer to publish time.
140
232
  void storage_paths;
233
+ const targets = args.account_ids?.length
234
+ ? `account IDs: ${args.account_ids.join(", ")}`
235
+ : (args.platforms ?? []).join(", ");
141
236
  const { post: p } = result;
142
- return `Scheduled post ${p.id}\nPlatforms: ${args.platforms.join(", ")}\nTime: ${args.scheduled_time}\nStatus: ${p.status}`;
237
+ return `Scheduled post ${p.id}\nTargets: ${targets}\nTime: ${args.scheduled_time}\nStatus: ${p.status}`;
143
238
  },
144
239
  };
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.relightClipTool = void 0;
7
+ /**
8
+ * relight_clip — relight / re-scene a SHORT clip (3–10s) via Fal.ai (bundled
9
+ * relight.py: nano-banana-2 relights one frame, Kling O3 Pro transfers it onto
10
+ * the clip with audio preserved). PAID, so it's two-phase with a cost gate:
11
+ * phase 1 (no confirm): prep + relight ONE frame (~$0.10) → preview + the video
12
+ * cost estimate + the folder.
13
+ * phase 2 (confirm + folder): run the paid Kling video step.
14
+ * Needs FAL_KEY in the environment. Best on B-roll / short hero shots — a full
15
+ * talking-head monologue can drift on the mouth.
16
+ */
17
+ const child_process_1 = require("child_process");
18
+ const path_1 = __importDefault(require("path"));
19
+ const engine_js_1 = require("../engine.js");
20
+ function pythonBin() { return process.platform === "win32" ? "python" : "python3"; }
21
+ function runRelight(args) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = (0, child_process_1.spawn)(pythonBin(), [path_1.default.join(engine_js_1.ENGINE_DIR, "relight.py"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
24
+ let out = "";
25
+ let err = "";
26
+ child.stdout.on("data", (d) => { out += d.toString(); });
27
+ child.stderr.on("data", (d) => { err += d.toString(); process.stderr.write(d); });
28
+ child.on("error", reject);
29
+ child.on("close", (code) => (code === 0 ? resolve(out.trim()) : reject(new Error(err.slice(-300) || out.slice(-300) || `relight exited ${code}`))));
30
+ });
31
+ }
32
+ function lastJson(s) {
33
+ const m = s.match(/\{[\s\S]*\}\s*$/);
34
+ return JSON.parse(m ? m[0] : s);
35
+ }
36
+ exports.relightClipTool = {
37
+ name: "relight_clip",
38
+ description: "Relight or re-scene a SHORT clip (3–10s — best on B-roll / short hero shots) via Fal.ai: change the lighting " +
39
+ "or drop the subject into a new environment, audio preserved. PAID (~$0.10 for the reference frame + ~$0.168/sec " +
40
+ "for the video). COST-GATED two-step: call WITHOUT confirm → it preps + relights ONE frame and returns a preview " +
41
+ "+ the video cost + a `folder`; review it, show the user the cost, then call again WITH confirm:true and that " +
42
+ "`folder` to run the paid video step. Write a geometry-locked, LIGHTING-focused scene_prompt (same framing/pose; " +
43
+ "change only lighting + background). Needs FAL_KEY in the environment. For a free non-AI color look, use color_grade.",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ video_path: { type: "string", description: "Local path to a 3–10s clip." },
48
+ scene_prompt: { type: "string", description: "Relight/re-scene prompt — keep the EXACT framing/pose; change only lighting + background (e.g. 'hard key from high front-left, deep shadow on the other side, warm rim; soft golden-hour rooftop bokeh behind him')." },
49
+ reference_image: { type: "string", description: "Optional reference image of the scene/lighting to match." },
50
+ confirm: { type: "boolean", description: "true = run the PAID video step (only after the user approved the preview + cost)." },
51
+ folder: { type: "string", description: "The `folder` returned by the first (no-confirm) call — required with confirm:true." },
52
+ model: { type: "string", enum: ["o3", "o1"], description: "o3 (default, best lip-sync) or o1 (cheaper/faster draft)." },
53
+ },
54
+ required: ["video_path", "scene_prompt"],
55
+ },
56
+ async execute(args) {
57
+ if (!process.env.FAL_KEY) {
58
+ return "relight_clip needs FAL_KEY in the environment. Set it (Windows: setx FAL_KEY \"<key>\") and restart, then try again.";
59
+ }
60
+ // Phase 2 — the PAID video transfer.
61
+ if (args.confirm) {
62
+ if (!args.folder)
63
+ return "To run the paid video step, pass the `folder` returned by the first call.";
64
+ try {
65
+ const res = lastJson(await runRelight(["video", "--folder", args.folder, ...(args.model ? ["--model", args.model] : [])]));
66
+ return `Relit clip rendered (audio preserved): ${res.video_path}\nSwap it into your project, or publish it.`;
67
+ }
68
+ catch (e) {
69
+ return `Relight video step failed: ${e instanceof Error ? e.message : String(e)}`;
70
+ }
71
+ }
72
+ // Phase 1 — prep + relight ONE frame (cheap), then quote the video cost.
73
+ let prep;
74
+ try {
75
+ prep = lastJson(await runRelight(["prep", "--video", args.video_path, "--title", "relight"]));
76
+ }
77
+ catch (e) {
78
+ const msg = e instanceof Error ? e.message : String(e);
79
+ return /3.?10|second|duration/i.test(msg) ? `That clip is outside the 3–10s limit — trim to a ≤10s segment first. (${msg})` : `Couldn't prep the clip: ${msg}`;
80
+ }
81
+ try {
82
+ await runRelight(["image", "--frame", prep.frame_path, "--folder", prep.folder, "--aspect-ratio", String(prep.aspect_ratio), "--prompt", args.scene_prompt, ...(args.reference_image ? ["--reference", args.reference_image] : [])]);
83
+ }
84
+ catch (e) {
85
+ return `Couldn't relight the reference frame: ${e instanceof Error ? e.message : String(e)}`;
86
+ }
87
+ const sec = Number(prep.duration) || 0;
88
+ const perSec = args.model === "o1" ? 0.168 : 0.168;
89
+ const cost = (0.1 + sec * perSec).toFixed(2);
90
+ return [
91
+ `Relit a reference frame ✓ — preview in: ${prep.folder}`,
92
+ ``,
93
+ `The PAID video step transfers that look onto the whole ${sec}s clip (audio kept) ≈ $${cost}.`,
94
+ `Show this cost to the user. To run it, call:`,
95
+ ` relight_clip(video_path: "${args.video_path}", scene_prompt: "…", confirm: true, folder: "${prep.folder}")`,
96
+ ].join("\n");
97
+ },
98
+ };
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.checkReviewStatusTool = exports.createReviewLinkTool = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const client_js_1 = require("../client.js");
9
+ const APP_BASE = process.env.VIRALSPIN_APP_URL || "https://app.viralspin.ai";
10
+ function makeToken() {
11
+ return crypto_1.default.randomBytes(9).toString("base64url").slice(0, 12);
12
+ }
13
+ exports.createReviewLinkTool = {
14
+ name: "create_review_link",
15
+ description: "After assembling a video, generate a private shareable review link for a client. " +
16
+ "The client opens the link in their browser — no account required — sees the video in a " +
17
+ "premium 'Private Review Room', and clicks Approve or Request Changes. " +
18
+ "Returns the review URL to paste into an email or DM.",
19
+ inputSchema: {
20
+ type: "object",
21
+ required: ["output_path"],
22
+ properties: {
23
+ output_path: {
24
+ type: "string",
25
+ description: "The local file path or public URL of the rendered MP4 — e.g. the path returned by create_video_assembly.",
26
+ },
27
+ metadata: {
28
+ type: "object",
29
+ description: "Optional context shown in the review room: caption, hook_text, style, format, platform_targets.",
30
+ properties: {
31
+ caption: { type: "string" },
32
+ hook_text: { type: "string" },
33
+ style: { type: "string" },
34
+ format: { type: "string", enum: ["9x16", "1x1", "16x9"] },
35
+ platform_targets: { type: "array", items: { type: "string" } },
36
+ },
37
+ },
38
+ pin: {
39
+ type: "string",
40
+ description: "Optional 4-digit PIN the client must enter before viewing the review.",
41
+ },
42
+ expires_hours: {
43
+ type: "number",
44
+ description: "How many hours until the link expires (default: 168 = 7 days).",
45
+ },
46
+ },
47
+ },
48
+ async execute(args) {
49
+ const token = makeToken();
50
+ const expiresHours = args.expires_hours ?? 168;
51
+ await (0, client_js_1.post)("/v1/review-sessions", {
52
+ token,
53
+ output_path: args.output_path,
54
+ metadata: args.metadata ?? {},
55
+ pin: args.pin ?? null,
56
+ expires_hours: expiresHours,
57
+ });
58
+ const reviewUrl = `${APP_BASE}/review/${token}`;
59
+ return [
60
+ `✅ Review link created:`,
61
+ ``,
62
+ ` ${reviewUrl}`,
63
+ ``,
64
+ `Send this link to your client. They'll see the video in a private premiere room`,
65
+ `and can Approve or Request Changes — no account required.`,
66
+ args.pin ? `PIN required: ${args.pin}` : ``,
67
+ `Link expires in ${expiresHours >= 24 ? `${Math.round(expiresHours / 24)} days` : `${expiresHours}h`}.`,
68
+ ``,
69
+ `To check if they've approved: check_review_status with token "${token}"`,
70
+ ]
71
+ .filter((l) => l !== undefined)
72
+ .join("\n");
73
+ },
74
+ };
75
+ exports.checkReviewStatusTool = {
76
+ name: "check_review_status",
77
+ description: "Check whether a client has approved or requested changes on a review link. " +
78
+ "Pass the token from the URL (e.g. 'abc123xyz456' from /review/abc123xyz456).",
79
+ inputSchema: {
80
+ type: "object",
81
+ required: ["token"],
82
+ properties: {
83
+ token: {
84
+ type: "string",
85
+ description: "The 12-character review token from the review URL.",
86
+ },
87
+ },
88
+ },
89
+ async execute({ token }) {
90
+ const data = await (0, client_js_1.get)(`/v1/review-sessions/${token}/status`);
91
+ const statusLine = data.status === "approved"
92
+ ? `✅ Approved${data.approved_at ? ` on ${new Date(data.approved_at).toLocaleString()}` : ""}`
93
+ : data.status === "rejected"
94
+ ? `📝 Changes requested`
95
+ : `⏳ Pending — client hasn't reviewed yet`;
96
+ const lines = [statusLine];
97
+ if (data.feedback)
98
+ lines.push(``, `Client feedback: "${data.feedback}"`);
99
+ if (data.status === "approved")
100
+ lines.push(``, `Ready to publish with publish_now or schedule_post.`);
101
+ return lines.join("\n");
102
+ },
103
+ };