@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,310 @@
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.createVideoAssemblyTool = void 0;
7
+ const child_process_1 = require("child_process");
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const client_js_1 = require("../client.js");
11
+ const hosted_js_1 = require("../hosted.js");
12
+ const engine_js_1 = require("../engine.js");
13
+ const MEDIA_EXT = new Set([".mp4", ".mov", ".webm", ".m4v", ".jpg", ".jpeg", ".png", ".gif", ".webp"]);
14
+ /** Split B-roll entries into local files (expanding folders) and public URLs.
15
+ * Runs on the MCP host, so it can read the user's disk to expand a folder. */
16
+ function partitionBroll(entries) {
17
+ const localFiles = [];
18
+ const urls = [];
19
+ for (const e of entries) {
20
+ if (/^https?:\/\//i.test(e)) {
21
+ urls.push(e);
22
+ continue;
23
+ }
24
+ try {
25
+ const st = fs_1.default.statSync(e);
26
+ if (st.isDirectory()) {
27
+ for (const name of fs_1.default.readdirSync(e)) {
28
+ if (MEDIA_EXT.has(path_1.default.extname(name).toLowerCase()))
29
+ localFiles.push(path_1.default.join(e, name));
30
+ }
31
+ }
32
+ else if (st.isFile()) {
33
+ localFiles.push(e);
34
+ }
35
+ }
36
+ catch { /* unreadable path — skip */ }
37
+ }
38
+ return { localFiles, urls };
39
+ }
40
+ const VIDEO_EXT = /\.(mp4|mov|webm|m4v)(\?|$)/i;
41
+ function brollKind(url) {
42
+ return VIDEO_EXT.test(url) ? "video" : "image";
43
+ }
44
+ const BROLL_STYLE_KEYS = ["sketchbook", "cinematic_minimal", "dark_studio", "vibrant_illustrated"];
45
+ const CAPTION_STYLE_KEYS = ["auto", "default", "hormozi", "glow_neon", "minimalist", "whiteboard"];
46
+ /** Baked template recipes (mirrors src/data/videoTemplates.ts). Selecting one from
47
+ * the gallery seeds these defaults so the user doesn't describe the style. */
48
+ const VIDEO_TEMPLATES = {
49
+ screen_demo: {
50
+ output_format: "9x16",
51
+ caption_style: "hormozi",
52
+ direction: "Screen-demo style: fast caption-driven cuts on the beat, punch-in zoom to exactly where the on-screen " +
53
+ "action is each cut, meme captions, brand end card.",
54
+ },
55
+ };
56
+ /** Run the engine orchestrator, streaming its logs to stderr, and capture RESULT:<path>. */
57
+ function runEngine(args, extraEnv = {}) {
58
+ return new Promise((resolve, reject) => {
59
+ const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "assemble.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env, ...extraEnv } });
60
+ let stdout = "";
61
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
62
+ child.stderr.on("data", (d) => process.stderr.write(d)); // progress → MCP server log
63
+ child.on("error", reject);
64
+ child.on("close", (code) => {
65
+ const m = stdout.match(/RESULT:(.+)/);
66
+ if (code === 0 && m)
67
+ resolve(m[1].trim());
68
+ else
69
+ reject(new Error(`Assembly engine exited ${code}. ${stdout.slice(-400)}`));
70
+ });
71
+ });
72
+ }
73
+ exports.createVideoAssemblyTool = {
74
+ name: "create_video_assembly",
75
+ description: "Turn an A-roll talking-head/performance clip into a polished vertical (9:16) short by editing in B-roll cutaways. " +
76
+ "Runs a LOCAL pipeline: transcribes the A-roll, finds each B-roll's focal point (face/subject-aware so nothing is cropped off), " +
77
+ "matches B-roll to what's being said, and renders with Ken Burns motion + the speaker's voice as the soundtrack (B-roll clip audio is mixed in as a low faded bed). " +
78
+ "Accepts LOCAL file paths OR public URLs for both A-roll and B-roll, and a B-roll FOLDER is expanded automatically. " +
79
+ "Pass style_ref (a reference video) to AUTO-INFER the editing style from it — captions, hook headline, keyword pops, pace, and aspect ratio — so you don't have to describe them. " +
80
+ "Returns the path to the finished MP4, which you can then publish with publish_now / schedule_post.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ aroll: {
85
+ type: "string",
86
+ description: "A-roll source: a local file path (e.g. C:/clips/talk.mp4) OR a public video URL.",
87
+ },
88
+ broll: {
89
+ type: "array",
90
+ items: { type: "string" },
91
+ description: "Optional B-roll sources: local file paths, a local FOLDER (expanded to its images/videos), and/or public URLs. " +
92
+ "Mix freely. Omit for an A-roll-only edit (just motion, no cutaways).",
93
+ },
94
+ prompt: {
95
+ type: "string",
96
+ description: "Optional creative direction for the edit (mood, what to emphasise, pacing).",
97
+ },
98
+ output_format: {
99
+ type: "string",
100
+ enum: ["9x16", "1x1", "16x9"],
101
+ description: "Aspect ratio. Default 9x16 (vertical for Reels/TikTok/Shorts).",
102
+ },
103
+ out: {
104
+ type: "string",
105
+ description: "Optional output MP4 path. Defaults to the engine's out/ folder.",
106
+ },
107
+ google_picker_session: {
108
+ type: "string",
109
+ description: "Optional sessionId from start_photo_picker. If set, the photos/clips you picked in Google Photos are ingested as B-roll (in addition to any `broll`).",
110
+ },
111
+ style_ref: {
112
+ type: "string",
113
+ description: "Optional reference video (local path or URL). The tool watches it and infers the edit style — captions on/off + style, hook headline, keyword pops, cut pace, and aspect ratio — and applies it automatically. Overrides output_format when set.",
114
+ },
115
+ broll_mode: {
116
+ type: "string",
117
+ enum: ["user_provided", "ai_generated"],
118
+ description: "B-roll source mode. 'user_provided' (default) = use local files or Google Photos. 'ai_generated' = AI reads the transcript and generates stylized cinematic B-roll images — no local footage needed. Requires the 'style' param.",
119
+ },
120
+ destination: {
121
+ type: "string",
122
+ enum: ["local", "hosted"],
123
+ description: "Where the project lives. 'local' (default) = render on this machine, return an MP4 path (no upload; reads local disk). " +
124
+ "'hosted' = create a Swap & Refine project on the user's ViralSpin account: local A-roll/B-roll are uploaded, the project " +
125
+ "opens in the web editor, and follow-up swap_clip/suggest_clips (with its job_id) drive that same project. Use 'hosted' " +
126
+ "when the user wants to refine in the browser or keep the project in their account.",
127
+ },
128
+ style: {
129
+ type: "string",
130
+ enum: BROLL_STYLE_KEYS,
131
+ description: "Style anchor for AI-generated B-roll (required when broll_mode='ai_generated'). " +
132
+ "sketchbook = hand-drawn ink on graph paper; " +
133
+ "cinematic_minimal = flat-lay dark surface, warm tungsten lamp; " +
134
+ "dark_studio = matte black workspace, designer aesthetic; " +
135
+ "vibrant_illustrated = bold pop-art flat illustration.",
136
+ },
137
+ explainer_index_id: {
138
+ type: "string",
139
+ description: "Optional Twelve Labs index ID returned by index_explainer_sources. " +
140
+ "When set, long-form videos (>2 min) in the broll folder are searched semantically " +
141
+ "instead of being clipped from their start. Requires TWELVE_LABS_API_KEY in the MCP env.",
142
+ },
143
+ template: {
144
+ type: "string",
145
+ enum: ["screen_demo"],
146
+ description: "Optional baked template (from the preview-and-select gallery). 'screen_demo' = the viral build-in-public " +
147
+ "product-demo style: 9x16, fast beat-synced cuts, punch-in zoom to the on-screen action, meme captions, brand " +
148
+ "end card. Seeds output_format + caption_style + creative direction so you don't describe the style; combine " +
149
+ "with find_moments mode:'screen' snippets as the footage.",
150
+ },
151
+ caption_style: {
152
+ type: "string",
153
+ enum: CAPTION_STYLE_KEYS,
154
+ description: "Caption style. Default 'auto' = AI picks the best style from the transcript. " +
155
+ "default = bold white spring pop-in. " +
156
+ "hormozi = word-by-word yellow highlight (high-energy talking-head). " +
157
+ "glow_neon = neon cyan pulsing text (dark aesthetic, music, gaming). " +
158
+ "minimalist = lowercase thin font fade-in (calm, educational). " +
159
+ "whiteboard = left-to-right text reveal (explainers, tutorials). " +
160
+ "After rendering, change it instantly with swap_caption_style.",
161
+ },
162
+ },
163
+ required: ["aroll"],
164
+ },
165
+ async execute(args) {
166
+ // Baked template selected in the gallery → seed its recipe defaults (user's
167
+ // explicit args always win). The style intent rides `prompt`; the beat-grid +
168
+ // preset audio live engine-side (VIRALSPIN_VIDEO_TEMPLATE), consumed by the
169
+ // screen-demo assembly path.
170
+ if (args.template && VIDEO_TEMPLATES[args.template]) {
171
+ const t = VIDEO_TEMPLATES[args.template];
172
+ args.output_format = args.output_format ?? t.output_format;
173
+ args.caption_style = args.caption_style ?? t.caption_style;
174
+ args.prompt = args.prompt ? `${t.direction}\n${args.prompt}` : t.direction;
175
+ }
176
+ // AI-generated B-roll mode: delegate to the cloud narrative pipeline.
177
+ if (args.broll_mode === "ai_generated") {
178
+ if (!args.style) {
179
+ return "Error: 'style' is required when broll_mode='ai_generated'. Choose one of: sketchbook, cinematic_minimal, dark_studio, vibrant_illustrated.";
180
+ }
181
+ const job = await (0, client_js_1.post)("/v1/narrative/jobs", {
182
+ aroll_paths: [args.aroll],
183
+ prompt: args.prompt,
184
+ output_format: args.output_format ?? "9x16",
185
+ broll_mode: "ai_generated",
186
+ style_anchor: args.style,
187
+ });
188
+ const deadline = Date.now() + 15 * 60 * 1000;
189
+ let result = { status: "queued" };
190
+ while (Date.now() < deadline) {
191
+ await new Promise((r) => setTimeout(r, 6000));
192
+ result = await (0, client_js_1.get)(`/v1/narrative/jobs/${job.job_id}`);
193
+ if (result.status === "complete" || result.status === "failed")
194
+ break;
195
+ }
196
+ if (result.status === "failed")
197
+ return `Assembly failed: ${result.error_message ?? "unknown error"}`;
198
+ if (result.status !== "complete")
199
+ return `Assembly timed out. Job ID: ${job.job_id} — check the dashboard.`;
200
+ return (`AI B-roll assembly complete (style: ${args.style}).\n` +
201
+ `Preview: ${result.preview_url ?? "—"}\n` +
202
+ `Download: ${result.download_url ?? "—"}\n` +
203
+ `Publish it with publish_now or schedule_post.`);
204
+ }
205
+ // Hosted project: create a Swap & Refine project (narrative job) on the
206
+ // user's account instead of rendering locally. Local files are uploaded to
207
+ // public URLs, then the web editor + the hosted swap_clip/suggest_clips verbs
208
+ // take over. This is the path that "merges" Claude with the web platform.
209
+ if (args.destination === "hosted") {
210
+ // Upload the A-roll (local path → public URL). A URL passes straight through.
211
+ const arollUrl = /^https?:\/\//i.test(args.aroll)
212
+ ? args.aroll
213
+ : (await (0, client_js_1.uploadFiles)([args.aroll])).urls[0];
214
+ // Resolve local B-roll (expand folders, upload files) + keep URL B-roll.
215
+ const { localFiles, urls } = partitionBroll(args.broll ?? []);
216
+ const uploadedUrls = localFiles.length ? (await (0, client_js_1.uploadFiles)(localFiles)).urls : [];
217
+ const brollItems = [...uploadedUrls, ...urls].map((url) => ({ url, kind: brollKind(url) }));
218
+ // Create the project. NOTE (verified against the live backend): a hosted
219
+ // user_provided job picks its INITIAL B-roll from the user's synced library
220
+ // / Google Photos picker — NOT from ad-hoc local files. (Contextual
221
+ // selection over an arbitrary local folder is the LOCAL engine's job.) So
222
+ // local/URL B-roll here is attached as a refine pool the editor + the
223
+ // swap verbs draw from, once the timeline is built.
224
+ const job = await (0, client_js_1.post)("/v1/narrative/jobs", {
225
+ aroll_paths: [arollUrl],
226
+ prompt: args.prompt,
227
+ output_format: args.output_format ?? "9x16",
228
+ broll_mode: "user_provided",
229
+ ...(args.template ? { template: args.template } : {}),
230
+ ...(args.google_picker_session ? { picker_session_id: args.google_picker_session } : {}),
231
+ });
232
+ // Attach local/URL B-roll once the build produces a timeline (the uploads
233
+ // endpoint 409s before then). Best-effort + bounded so we don't hang.
234
+ let attachNote = "";
235
+ if (brollItems.length) {
236
+ const r = await (0, hosted_js_1.attachUploadsWhenReady)(job.job_id, brollItems);
237
+ attachNote = r.attached
238
+ ? `Attached ${brollItems.length} local/URL B-roll source(s) to the project's refine pool.\n`
239
+ : `Queued ${brollItems.length} local/URL B-roll source(s), but they weren't attached yet (${r.detail}); add them in the editor's Upload tab.\n`;
240
+ }
241
+ const creditNote = job.credits_charged != null ? `Charged ${job.credits_charged} credits (${job.credits_remaining} left).\n` : "";
242
+ return (`Created a hosted Swap & Refine project (job ${job.job_id}).\n` +
243
+ creditNote +
244
+ `Open / refine it in the editor: ${(0, hosted_js_1.editorUrl)(job.job_id)}\n` +
245
+ attachNote +
246
+ (args.google_picker_session ? "" : "Heads up: initial B-roll is picked from your synced library / Google Photos — pass a Google Photos picker session for richer hosted B-roll.\n") +
247
+ `From here: suggest_clips(job_id: "${job.job_id}") to see the cutaways, then ` +
248
+ `swap_clip(job_id: "${job.job_id}", …) to change one — edits show up live in the editor.\n` +
249
+ `Tip: set VIRALSPIN_HOSTED_JOB_ID=${job.job_id} to make the swap verbs target this project by default.`);
250
+ }
251
+ // Local assembly shells out to the bundled engine. The public npm package
252
+ // ships WITHOUT it (the closed magic), so route the user to the hosted cloud
253
+ // pipeline instead of failing with an opaque spawn error.
254
+ if (!(0, engine_js_1.engineAvailable)()) {
255
+ return ("Local video assembly needs the ViralSpin engine, which isn't bundled in this install.\n" +
256
+ "Use the cloud pipeline instead: call create_video_assembly again with destination: \"hosted\" " +
257
+ "— it renders in the cloud and opens in the web Swap & Refine editor.\n" +
258
+ "(Advanced: set VIRALSPIN_ASSEMBLY_DIR to a local engine checkout to enable local rendering.)");
259
+ }
260
+ const broll = [...(args.broll ?? [])];
261
+ // Pull in Google Photos picks (re-hosted to public URLs by the backend), if any.
262
+ if (args.google_picker_session) {
263
+ const r = await (0, client_js_1.get)(`/v1/assembly/picker?sessionId=${encodeURIComponent(args.google_picker_session)}`);
264
+ if (r.status !== "ready") {
265
+ return "Your Google Photos picks aren't ready yet — finish selecting in the picker tab, then run create_video_assembly again with the same google_picker_session.";
266
+ }
267
+ for (const it of r.items ?? [])
268
+ broll.push(it.url);
269
+ }
270
+ const cli = ["--aroll", args.aroll];
271
+ if (broll.length)
272
+ cli.push("--broll", ...broll);
273
+ if (args.prompt)
274
+ cli.push("--prompt", args.prompt);
275
+ if (args.style_ref)
276
+ cli.push("--style-ref", args.style_ref);
277
+ if (args.output_format)
278
+ cli.push("--format", args.output_format);
279
+ if (args.out)
280
+ cli.push("--out", args.out);
281
+ if (args.caption_style && args.caption_style !== "auto")
282
+ cli.push("--caption-style", args.caption_style);
283
+ // Pull the admin-managed "secret sauce" prompts (LLM Prompt Store →
284
+ // LOCAL VIDEO ASSEMBLY section) and hand them to the engine. Best-effort:
285
+ // on any failure the engine falls back to its built-in prompts.
286
+ const extraEnv = {};
287
+ try {
288
+ const r = await (0, client_js_1.get)("/v1/assembly/prompts");
289
+ if (r.prompts && Object.keys(r.prompts).length) {
290
+ extraEnv.VIRALSPIN_ASSEMBLY_PROMPTS = JSON.stringify(r.prompts);
291
+ }
292
+ }
293
+ catch { /* engine uses built-in prompts */ }
294
+ if (args.explainer_index_id) {
295
+ extraEnv.EXPLAINER_INDEX_ID = args.explainer_index_id;
296
+ }
297
+ if (process.env.TWELVE_LABS_API_KEY) {
298
+ extraEnv.TWELVE_LABS_API_KEY = process.env.TWELVE_LABS_API_KEY;
299
+ }
300
+ if (args.template) {
301
+ // Engine reads this to apply the baked recipe (beat grid + preset audio +
302
+ // punch-in) in the screen-demo assembly path.
303
+ extraEnv.VIRALSPIN_VIDEO_TEMPLATE = args.template;
304
+ }
305
+ const outPath = await runEngine(cli, extraEnv);
306
+ return (`Video assembly complete.\n` +
307
+ `Output: ${outPath}\n` +
308
+ `Publish it with publish_now (paths: ["${outPath}"]) or schedule_post.`);
309
+ },
310
+ };
package/package.json CHANGED
@@ -1,18 +1,30 @@
1
1
  {
2
2
  "name": "@viralspin/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "viralspin-mcp": "dist/index.js"
8
8
  },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
9
14
  "scripts": {
10
15
  "build": "tsc",
11
16
  "dev": "ts-node src/index.ts",
12
17
  "start": "node dist/index.js",
13
18
  "prepublishOnly": "npm run build"
14
19
  },
15
- "keywords": ["mcp", "viralspin", "tiktok", "instagram", "youtube", "social-media"],
20
+ "keywords": [
21
+ "mcp",
22
+ "viralspin",
23
+ "tiktok",
24
+ "instagram",
25
+ "youtube",
26
+ "social-media"
27
+ ],
16
28
  "license": "MIT",
17
29
  "dependencies": {
18
30
  "@modelcontextprotocol/sdk": "^1.0.0",
package/src/client.ts DELETED
@@ -1,80 +0,0 @@
1
- import FormData from "form-data";
2
- import fs from "fs";
3
- import path from "path";
4
-
5
- const API_URL = (process.env.VIRALSPIN_API_URL || "https://viralspinbackend.onrender.com").replace(/\/$/, "");
6
- const API_KEY = process.env.VIRALSPIN_API_KEY || "";
7
-
8
- if (!API_KEY) {
9
- process.stderr.write("[viralspin-mcp] Warning: VIRALSPIN_API_KEY not set\n");
10
- }
11
-
12
- function headers(extra: Record<string, string> = {}): Record<string, string> {
13
- return { "X-API-Key": API_KEY, ...extra };
14
- }
15
-
16
- async function apiFetch<T>(endpoint: string, init: RequestInit = {}): Promise<T> {
17
- const url = `${API_URL}${endpoint}`;
18
- const resp = await fetch(url, {
19
- ...init,
20
- headers: { ...(init.headers as Record<string, string> ?? {}), ...headers() },
21
- });
22
- if (!resp.ok) {
23
- const text = await resp.text().catch(() => "");
24
- let detail = text;
25
- try { detail = JSON.parse(text).detail ?? text; } catch {}
26
- throw new Error(`API error ${resp.status}: ${detail}`);
27
- }
28
- return resp.json() as Promise<T>;
29
- }
30
-
31
- export function get<T>(endpoint: string): Promise<T> {
32
- return apiFetch<T>(endpoint, { method: "GET" });
33
- }
34
-
35
- export function post<T>(endpoint: string, body: unknown): Promise<T> {
36
- return apiFetch<T>(endpoint, {
37
- method: "POST",
38
- headers: { "Content-Type": "application/json" },
39
- body: JSON.stringify(body),
40
- });
41
- }
42
-
43
- export function del<T>(endpoint: string, body?: unknown): Promise<T> {
44
- return apiFetch<T>(endpoint, {
45
- method: "DELETE",
46
- headers: body ? { "Content-Type": "application/json" } : {},
47
- body: body ? JSON.stringify(body) : undefined,
48
- });
49
- }
50
-
51
- /** Upload one or more local files. Returns { url, storage_path } or { urls, storage_paths } */
52
- export async function uploadFiles(filePaths: string[]): Promise<{ urls: string[]; storage_paths: string[] }> {
53
- const form = new FormData();
54
- const fieldName = filePaths.length === 1 ? "file" : "files";
55
- for (const fp of filePaths) {
56
- form.append(fieldName, fs.createReadStream(fp), path.basename(fp));
57
- }
58
-
59
- const resp = await fetch(`${API_URL}/v1/media/upload`, {
60
- method: "POST",
61
- headers: { "X-API-Key": API_KEY, ...form.getHeaders() },
62
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
- body: form as any,
64
- });
65
- if (!resp.ok) {
66
- const text = await resp.text().catch(() => "");
67
- let detail = text;
68
- try { detail = JSON.parse(text).detail ?? text; } catch {}
69
- throw new Error(`Upload failed ${resp.status}: ${detail}`);
70
- }
71
- const data = await resp.json() as { url?: string; storage_path?: string; urls?: string[]; storage_paths?: string[] };
72
- if (data.urls) return { urls: data.urls, storage_paths: data.storage_paths! };
73
- return { urls: [data.url!], storage_paths: [data.storage_path!] };
74
- }
75
-
76
- /** Delete uploaded files from storage (cleanup after publish) */
77
- export async function deleteFiles(storagePaths: string[]): Promise<void> {
78
- if (!storagePaths.length) return;
79
- await del(`/v1/media/${storagePaths[0]}`, storagePaths.length > 1 ? { storage_paths: storagePaths } : undefined);
80
- }
package/src/index.ts DELETED
@@ -1,60 +0,0 @@
1
- #!/usr/bin/env node
2
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import {
5
- CallToolRequestSchema,
6
- ListToolsRequestSchema,
7
- } from "@modelcontextprotocol/sdk/types.js";
8
-
9
- import { listConnectionsTool } from "./tools/connections.js";
10
- import { publishNowTool, schedulePostTool } from "./tools/publish.js";
11
- import { listPostsTool, cancelPostTool } from "./tools/posts.js";
12
- import { generateCaptionTool } from "./tools/captions.js";
13
-
14
- const tools = [
15
- listConnectionsTool,
16
- publishNowTool,
17
- schedulePostTool,
18
- listPostsTool,
19
- cancelPostTool,
20
- generateCaptionTool,
21
- ];
22
-
23
- const server = new Server(
24
- { name: "viralspin", version: "0.1.0" },
25
- { capabilities: { tools: {} } }
26
- );
27
-
28
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
29
- tools: tools.map((t) => ({
30
- name: t.name,
31
- description: t.description,
32
- inputSchema: t.inputSchema,
33
- })),
34
- }));
35
-
36
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
37
- const tool = tools.find((t) => t.name === req.params.name);
38
- if (!tool) {
39
- return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
40
- }
41
-
42
- try {
43
- const result = await tool.execute(req.params.arguments as never);
44
- return { content: [{ type: "text", text: String(result) }] };
45
- } catch (err) {
46
- const msg = err instanceof Error ? err.message : String(err);
47
- return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
48
- }
49
- });
50
-
51
- async function main() {
52
- const transport = new StdioServerTransport();
53
- await server.connect(transport);
54
- process.stderr.write("[viralspin-mcp] Server started\n");
55
- }
56
-
57
- main().catch((err) => {
58
- process.stderr.write(`[viralspin-mcp] Fatal: ${err}\n`);
59
- process.exit(1);
60
- });
@@ -1,32 +0,0 @@
1
- import { post } from "../client.js";
2
-
3
- interface CaptionResponse {
4
- caption: string;
5
- hashtags: string[];
6
- }
7
-
8
- export const generateCaptionTool = {
9
- name: "generate_caption",
10
- description: "Generate an AI caption and hashtags for a post on a specific platform.",
11
- inputSchema: {
12
- type: "object",
13
- properties: {
14
- title: { type: "string", description: "Video or content title/description." },
15
- platform: {
16
- type: "string",
17
- enum: ["tiktok", "instagram", "youtube"],
18
- description: "Target platform.",
19
- },
20
- },
21
- required: ["title", "platform"],
22
- },
23
-
24
- async execute(args: { title: string; platform: string }) {
25
- const data = await post<CaptionResponse>("/v1/publish/generate-caption", {
26
- video_title: args.title,
27
- platform: args.platform,
28
- });
29
- const tags = data.hashtags.map((h) => `#${h.replace(/^#/, "")}`).join(" ");
30
- return `Caption:\n${data.caption}\n\nHashtags:\n${tags}`;
31
- },
32
- };
@@ -1,27 +0,0 @@
1
- import { get } from "../client.js";
2
-
3
- interface Connection {
4
- id: string;
5
- platform: string;
6
- display_name: string;
7
- username: string;
8
- follower_count: number;
9
- is_active: boolean;
10
- connected_at: string;
11
- }
12
-
13
- export const listConnectionsTool = {
14
- name: "list_connections",
15
- description: "List your connected social media accounts (TikTok, Instagram, YouTube).",
16
- inputSchema: { type: "object", properties: {}, required: [] },
17
-
18
- async execute(_args: Record<string, unknown>) {
19
- const connections = await get<Connection[]>("/v1/publish/connections");
20
- if (!connections.length) {
21
- return "No platforms connected yet. Visit https://www.viralspin.ai/spin/settings to connect your accounts.";
22
- }
23
- return connections
24
- .map((c) => `${c.platform}: ${c.display_name} (${c.username}) — ${c.follower_count.toLocaleString()} followers`)
25
- .join("\n");
26
- },
27
- };
@@ -1,58 +0,0 @@
1
- import { get, del } from "../client.js";
2
-
3
- interface ScheduledPost {
4
- id: string;
5
- platforms: string[];
6
- caption: string;
7
- status: string;
8
- scheduled_time: string;
9
- published_at: string | null;
10
- failed_reason: string | null;
11
- media_type: string;
12
- }
13
-
14
- export const listPostsTool = {
15
- name: "list_posts",
16
- description: "List your scheduled, published, or failed posts.",
17
- inputSchema: {
18
- type: "object",
19
- properties: {
20
- status: {
21
- type: "string",
22
- enum: ["pending", "published", "cancelled", "failed"],
23
- description: "Filter by status. Omit to list all posts.",
24
- },
25
- },
26
- required: [],
27
- },
28
-
29
- async execute(args: { status?: string }) {
30
- const qs = args.status ? `?status=${args.status}` : "";
31
- const posts = await get<ScheduledPost[]>(`/v1/publish/posts${qs}`);
32
- if (!posts.length) return "No posts found.";
33
- return posts
34
- .map((p) => {
35
- const time = p.published_at ?? p.scheduled_time;
36
- const label = p.published_at ? "published" : "scheduled";
37
- return `[${p.id.slice(0, 8)}] ${p.status.toUpperCase()} — ${p.platforms.join("+")} ${p.media_type} | ${label}: ${time}\n "${p.caption?.slice(0, 60)}${(p.caption?.length ?? 0) > 60 ? "…" : ""}"${p.failed_reason ? `\n Error: ${p.failed_reason}` : ""}`;
38
- })
39
- .join("\n\n");
40
- },
41
- };
42
-
43
- export const cancelPostTool = {
44
- name: "cancel_post",
45
- description: "Cancel a pending scheduled post by ID.",
46
- inputSchema: {
47
- type: "object",
48
- properties: {
49
- post_id: { type: "string", description: "The post ID to cancel." },
50
- },
51
- required: ["post_id"],
52
- },
53
-
54
- async execute(args: { post_id: string }) {
55
- await del(`/v1/publish/posts/${args.post_id}`);
56
- return `Post ${args.post_id} cancelled.`;
57
- },
58
- };