@viralspin/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/dist/client.js +88 -0
- package/dist/index.js +49 -0
- package/dist/tools/captions.js +28 -0
- package/dist/tools/connections.js +18 -0
- package/dist/tools/posts.js +47 -0
- package/dist/tools/publish.js +144 -0
- package/package.json +29 -0
- package/src/client.ts +80 -0
- package/src/index.ts +60 -0
- package/src/tools/captions.ts +32 -0
- package/src/tools/connections.ts +27 -0
- package/src/tools/posts.ts +58 -0
- package/src/tools/publish.ts +158 -0
- package/tsconfig.json +15 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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.get = get;
|
|
7
|
+
exports.post = post;
|
|
8
|
+
exports.del = del;
|
|
9
|
+
exports.uploadFiles = uploadFiles;
|
|
10
|
+
exports.deleteFiles = deleteFiles;
|
|
11
|
+
const form_data_1 = __importDefault(require("form-data"));
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const API_URL = (process.env.VIRALSPIN_API_URL || "https://viralspinbackend.onrender.com").replace(/\/$/, "");
|
|
15
|
+
const API_KEY = process.env.VIRALSPIN_API_KEY || "";
|
|
16
|
+
if (!API_KEY) {
|
|
17
|
+
process.stderr.write("[viralspin-mcp] Warning: VIRALSPIN_API_KEY not set\n");
|
|
18
|
+
}
|
|
19
|
+
function headers(extra = {}) {
|
|
20
|
+
return { "X-API-Key": API_KEY, ...extra };
|
|
21
|
+
}
|
|
22
|
+
async function apiFetch(endpoint, init = {}) {
|
|
23
|
+
const url = `${API_URL}${endpoint}`;
|
|
24
|
+
const resp = await fetch(url, {
|
|
25
|
+
...init,
|
|
26
|
+
headers: { ...(init.headers ?? {}), ...headers() },
|
|
27
|
+
});
|
|
28
|
+
if (!resp.ok) {
|
|
29
|
+
const text = await resp.text().catch(() => "");
|
|
30
|
+
let detail = text;
|
|
31
|
+
try {
|
|
32
|
+
detail = JSON.parse(text).detail ?? text;
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
throw new Error(`API error ${resp.status}: ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
return resp.json();
|
|
38
|
+
}
|
|
39
|
+
function get(endpoint) {
|
|
40
|
+
return apiFetch(endpoint, { method: "GET" });
|
|
41
|
+
}
|
|
42
|
+
function post(endpoint, body) {
|
|
43
|
+
return apiFetch(endpoint, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify(body),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function del(endpoint, body) {
|
|
50
|
+
return apiFetch(endpoint, {
|
|
51
|
+
method: "DELETE",
|
|
52
|
+
headers: body ? { "Content-Type": "application/json" } : {},
|
|
53
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Upload one or more local files. Returns { url, storage_path } or { urls, storage_paths } */
|
|
57
|
+
async function uploadFiles(filePaths) {
|
|
58
|
+
const form = new form_data_1.default();
|
|
59
|
+
const fieldName = filePaths.length === 1 ? "file" : "files";
|
|
60
|
+
for (const fp of filePaths) {
|
|
61
|
+
form.append(fieldName, fs_1.default.createReadStream(fp), path_1.default.basename(fp));
|
|
62
|
+
}
|
|
63
|
+
const resp = await fetch(`${API_URL}/v1/media/upload`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "X-API-Key": API_KEY, ...form.getHeaders() },
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
body: form,
|
|
68
|
+
});
|
|
69
|
+
if (!resp.ok) {
|
|
70
|
+
const text = await resp.text().catch(() => "");
|
|
71
|
+
let detail = text;
|
|
72
|
+
try {
|
|
73
|
+
detail = JSON.parse(text).detail ?? text;
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
throw new Error(`Upload failed ${resp.status}: ${detail}`);
|
|
77
|
+
}
|
|
78
|
+
const data = await resp.json();
|
|
79
|
+
if (data.urls)
|
|
80
|
+
return { urls: data.urls, storage_paths: data.storage_paths };
|
|
81
|
+
return { urls: [data.url], storage_paths: [data.storage_path] };
|
|
82
|
+
}
|
|
83
|
+
/** Delete uploaded files from storage (cleanup after publish) */
|
|
84
|
+
async function deleteFiles(storagePaths) {
|
|
85
|
+
if (!storagePaths.length)
|
|
86
|
+
return;
|
|
87
|
+
await del(`/v1/media/${storagePaths[0]}`, storagePaths.length > 1 ? { storage_paths: storagePaths } : undefined);
|
|
88
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
5
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
6
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
7
|
+
const connections_js_1 = require("./tools/connections.js");
|
|
8
|
+
const publish_js_1 = require("./tools/publish.js");
|
|
9
|
+
const posts_js_1 = require("./tools/posts.js");
|
|
10
|
+
const captions_js_1 = require("./tools/captions.js");
|
|
11
|
+
const tools = [
|
|
12
|
+
connections_js_1.listConnectionsTool,
|
|
13
|
+
publish_js_1.publishNowTool,
|
|
14
|
+
publish_js_1.schedulePostTool,
|
|
15
|
+
posts_js_1.listPostsTool,
|
|
16
|
+
posts_js_1.cancelPostTool,
|
|
17
|
+
captions_js_1.generateCaptionTool,
|
|
18
|
+
];
|
|
19
|
+
const server = new index_js_1.Server({ name: "viralspin", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
20
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
21
|
+
tools: tools.map((t) => ({
|
|
22
|
+
name: t.name,
|
|
23
|
+
description: t.description,
|
|
24
|
+
inputSchema: t.inputSchema,
|
|
25
|
+
})),
|
|
26
|
+
}));
|
|
27
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
|
|
28
|
+
const tool = tools.find((t) => t.name === req.params.name);
|
|
29
|
+
if (!tool) {
|
|
30
|
+
return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const result = await tool.execute(req.params.arguments);
|
|
34
|
+
return { content: [{ type: "text", text: String(result) }] };
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
38
|
+
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
async function main() {
|
|
42
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
43
|
+
await server.connect(transport);
|
|
44
|
+
process.stderr.write("[viralspin-mcp] Server started\n");
|
|
45
|
+
}
|
|
46
|
+
main().catch((err) => {
|
|
47
|
+
process.stderr.write(`[viralspin-mcp] Fatal: ${err}\n`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateCaptionTool = void 0;
|
|
4
|
+
const client_js_1 = require("../client.js");
|
|
5
|
+
exports.generateCaptionTool = {
|
|
6
|
+
name: "generate_caption",
|
|
7
|
+
description: "Generate an AI caption and hashtags for a post on a specific platform.",
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
title: { type: "string", description: "Video or content title/description." },
|
|
12
|
+
platform: {
|
|
13
|
+
type: "string",
|
|
14
|
+
enum: ["tiktok", "instagram", "youtube"],
|
|
15
|
+
description: "Target platform.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
required: ["title", "platform"],
|
|
19
|
+
},
|
|
20
|
+
async execute(args) {
|
|
21
|
+
const data = await (0, client_js_1.post)("/v1/publish/generate-caption", {
|
|
22
|
+
video_title: args.title,
|
|
23
|
+
platform: args.platform,
|
|
24
|
+
});
|
|
25
|
+
const tags = data.hashtags.map((h) => `#${h.replace(/^#/, "")}`).join(" ");
|
|
26
|
+
return `Caption:\n${data.caption}\n\nHashtags:\n${tags}`;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.listConnectionsTool = void 0;
|
|
4
|
+
const client_js_1 = require("../client.js");
|
|
5
|
+
exports.listConnectionsTool = {
|
|
6
|
+
name: "list_connections",
|
|
7
|
+
description: "List your connected social media accounts (TikTok, Instagram, YouTube).",
|
|
8
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
9
|
+
async execute(_args) {
|
|
10
|
+
const connections = await (0, client_js_1.get)("/v1/publish/connections");
|
|
11
|
+
if (!connections.length) {
|
|
12
|
+
return "No platforms connected yet. Visit https://www.viralspin.ai/spin/settings to connect your accounts.";
|
|
13
|
+
}
|
|
14
|
+
return connections
|
|
15
|
+
.map((c) => `${c.platform}: ${c.display_name} (${c.username}) — ${c.follower_count.toLocaleString()} followers`)
|
|
16
|
+
.join("\n");
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cancelPostTool = exports.listPostsTool = void 0;
|
|
4
|
+
const client_js_1 = require("../client.js");
|
|
5
|
+
exports.listPostsTool = {
|
|
6
|
+
name: "list_posts",
|
|
7
|
+
description: "List your scheduled, published, or failed posts.",
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
status: {
|
|
12
|
+
type: "string",
|
|
13
|
+
enum: ["pending", "published", "cancelled", "failed"],
|
|
14
|
+
description: "Filter by status. Omit to list all posts.",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
required: [],
|
|
18
|
+
},
|
|
19
|
+
async execute(args) {
|
|
20
|
+
const qs = args.status ? `?status=${args.status}` : "";
|
|
21
|
+
const posts = await (0, client_js_1.get)(`/v1/publish/posts${qs}`);
|
|
22
|
+
if (!posts.length)
|
|
23
|
+
return "No posts found.";
|
|
24
|
+
return posts
|
|
25
|
+
.map((p) => {
|
|
26
|
+
const time = p.published_at ?? p.scheduled_time;
|
|
27
|
+
const label = p.published_at ? "published" : "scheduled";
|
|
28
|
+
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}` : ""}`;
|
|
29
|
+
})
|
|
30
|
+
.join("\n\n");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
exports.cancelPostTool = {
|
|
34
|
+
name: "cancel_post",
|
|
35
|
+
description: "Cancel a pending scheduled post by ID.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
post_id: { type: "string", description: "The post ID to cancel." },
|
|
40
|
+
},
|
|
41
|
+
required: ["post_id"],
|
|
42
|
+
},
|
|
43
|
+
async execute(args) {
|
|
44
|
+
await (0, client_js_1.del)(`/v1/publish/posts/${args.post_id}`);
|
|
45
|
+
return `Post ${args.post_id} cancelled.`;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
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.schedulePostTool = exports.publishNowTool = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const client_js_1 = require("../client.js");
|
|
10
|
+
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
|
11
|
+
const VIDEO_EXTS = new Set([".mp4", ".mov", ".webm", ".avi"]);
|
|
12
|
+
function detectMediaType(filePaths) {
|
|
13
|
+
if (filePaths.length > 1)
|
|
14
|
+
return "carousel";
|
|
15
|
+
const ext = path_1.default.extname(filePaths[0]).toLowerCase();
|
|
16
|
+
return IMAGE_EXTS.has(ext) ? "carousel" : "video";
|
|
17
|
+
}
|
|
18
|
+
/** Expand a folder path into individual file paths (images/videos only, sorted) */
|
|
19
|
+
function expandPaths(inputs) {
|
|
20
|
+
const result = [];
|
|
21
|
+
for (const p of inputs) {
|
|
22
|
+
const stat = fs_1.default.statSync(p, { throwIfNoEntry: false });
|
|
23
|
+
if (!stat)
|
|
24
|
+
throw new Error(`Path not found: ${p}`);
|
|
25
|
+
if (stat.isDirectory()) {
|
|
26
|
+
const files = fs_1.default.readdirSync(p)
|
|
27
|
+
.filter((f) => {
|
|
28
|
+
const ext = path_1.default.extname(f).toLowerCase();
|
|
29
|
+
return IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
|
|
30
|
+
})
|
|
31
|
+
.sort()
|
|
32
|
+
.map((f) => path_1.default.join(p, f));
|
|
33
|
+
result.push(...files);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
result.push(p);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
const publishSchema = {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
paths: {
|
|
45
|
+
type: "array",
|
|
46
|
+
items: { type: "string" },
|
|
47
|
+
description: "Local file path(s) or a folder path. Single video → video post. Multiple images or image folder → carousel.",
|
|
48
|
+
},
|
|
49
|
+
platforms: {
|
|
50
|
+
type: "array",
|
|
51
|
+
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
52
|
+
description: "Platforms to publish to. Must be connected.",
|
|
53
|
+
},
|
|
54
|
+
caption: { type: "string", description: "Post caption." },
|
|
55
|
+
hashtags: {
|
|
56
|
+
type: "array",
|
|
57
|
+
items: { type: "string" },
|
|
58
|
+
description: "Hashtags without # prefix.",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ["paths", "platforms", "caption"],
|
|
62
|
+
};
|
|
63
|
+
exports.publishNowTool = {
|
|
64
|
+
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.",
|
|
66
|
+
inputSchema: publishSchema,
|
|
67
|
+
async execute(args) {
|
|
68
|
+
const filePaths = expandPaths(args.paths);
|
|
69
|
+
if (!filePaths.length)
|
|
70
|
+
throw new Error("No media files found in the provided paths.");
|
|
71
|
+
const mediaType = detectMediaType(filePaths);
|
|
72
|
+
if (mediaType === "carousel" && filePaths.length > 10) {
|
|
73
|
+
throw new Error(`Instagram supports max 10 images per carousel. Found ${filePaths.length} files.`);
|
|
74
|
+
}
|
|
75
|
+
// Upload files
|
|
76
|
+
const { urls, storage_paths } = await (0, client_js_1.uploadFiles)(filePaths);
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
80
|
+
media_urls: urls,
|
|
81
|
+
media_type: mediaType,
|
|
82
|
+
platforms: args.platforms,
|
|
83
|
+
caption: args.caption,
|
|
84
|
+
hashtags: args.hashtags ?? [],
|
|
85
|
+
publish_now: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
// Always clean up uploaded files
|
|
90
|
+
await (0, client_js_1.deleteFiles)(storage_paths).catch(() => { });
|
|
91
|
+
}
|
|
92
|
+
const { status, platform_post_ids, failed_reason } = result.post;
|
|
93
|
+
const published = Object.keys(platform_post_ids);
|
|
94
|
+
let summary = `Status: ${status}\n`;
|
|
95
|
+
if (published.length)
|
|
96
|
+
summary += `Published to: ${published.join(", ")}\n`;
|
|
97
|
+
if (failed_reason)
|
|
98
|
+
summary += `Errors: ${failed_reason}`;
|
|
99
|
+
return summary.trim();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
exports.schedulePostTool = {
|
|
103
|
+
name: "schedule_post",
|
|
104
|
+
description: "Upload local file(s) and schedule a post for a future time. Pass a folder path to schedule all images as a carousel.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
...publishSchema.properties,
|
|
109
|
+
scheduled_time: {
|
|
110
|
+
type: "string",
|
|
111
|
+
description: "ISO 8601 datetime string for when to publish (e.g. '2026-05-10T09:00:00'). Uses your local timezone if no timezone offset provided.",
|
|
112
|
+
},
|
|
113
|
+
timezone: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description: "IANA timezone name (e.g. 'America/New_York'). Defaults to UTC.",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
required: ["paths", "platforms", "caption", "scheduled_time"],
|
|
119
|
+
},
|
|
120
|
+
async execute(args) {
|
|
121
|
+
const filePaths = expandPaths(args.paths);
|
|
122
|
+
if (!filePaths.length)
|
|
123
|
+
throw new Error("No media files found in the provided paths.");
|
|
124
|
+
const mediaType = detectMediaType(filePaths);
|
|
125
|
+
// Upload files (storage URLs remain valid for 1 hour; cron will publish before then if scheduled soon,
|
|
126
|
+
// otherwise the cron route will fail — advise scheduling within the hour for now)
|
|
127
|
+
const { urls, storage_paths } = await (0, client_js_1.uploadFiles)(filePaths);
|
|
128
|
+
const result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
129
|
+
media_urls: urls,
|
|
130
|
+
media_type: mediaType,
|
|
131
|
+
platforms: args.platforms,
|
|
132
|
+
caption: args.caption,
|
|
133
|
+
hashtags: args.hashtags ?? [],
|
|
134
|
+
scheduled_time: args.scheduled_time,
|
|
135
|
+
timezone: args.timezone,
|
|
136
|
+
publish_now: false,
|
|
137
|
+
});
|
|
138
|
+
// Note: storage_paths are NOT deleted here — the cron job needs the URLs to stay valid.
|
|
139
|
+
// They expire after 1 hour. For posts scheduled further out, re-upload closer to publish time.
|
|
140
|
+
void storage_paths;
|
|
141
|
+
const { post: p } = result;
|
|
142
|
+
return `Scheduled post ${p.id}\nPlatforms: ${args.platforms.join(", ")}\nTime: ${args.scheduled_time}\nStatus: ${p.status}`;
|
|
143
|
+
},
|
|
144
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@viralspin/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"viralspin-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "ts-node src/index.ts",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["mcp", "viralspin", "tiktok", "instagram", "youtube", "social-media"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
|
+
"form-data": "^4.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/form-data": "^2.5.0",
|
|
23
|
+
"@types/node": "^20.0.0",
|
|
24
|
+
"typescript": "^5.0.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { post, uploadFiles, deleteFiles } from "../client.js";
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
|
6
|
+
const VIDEO_EXTS = new Set([".mp4", ".mov", ".webm", ".avi"]);
|
|
7
|
+
|
|
8
|
+
function detectMediaType(filePaths: string[]): "video" | "carousel" {
|
|
9
|
+
if (filePaths.length > 1) return "carousel";
|
|
10
|
+
const ext = path.extname(filePaths[0]).toLowerCase();
|
|
11
|
+
return IMAGE_EXTS.has(ext) ? "carousel" : "video";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Expand a folder path into individual file paths (images/videos only, sorted) */
|
|
15
|
+
function expandPaths(inputs: string[]): string[] {
|
|
16
|
+
const result: string[] = [];
|
|
17
|
+
for (const p of inputs) {
|
|
18
|
+
const stat = fs.statSync(p, { throwIfNoEntry: false });
|
|
19
|
+
if (!stat) throw new Error(`Path not found: ${p}`);
|
|
20
|
+
if (stat.isDirectory()) {
|
|
21
|
+
const files = fs.readdirSync(p)
|
|
22
|
+
.filter((f) => {
|
|
23
|
+
const ext = path.extname(f).toLowerCase();
|
|
24
|
+
return IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
|
|
25
|
+
})
|
|
26
|
+
.sort()
|
|
27
|
+
.map((f) => path.join(p, f));
|
|
28
|
+
result.push(...files);
|
|
29
|
+
} else {
|
|
30
|
+
result.push(p);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PublishResult {
|
|
37
|
+
post: { id: string; status: string; platform_post_ids: Record<string, string>; failed_reason?: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const publishSchema = {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
paths: {
|
|
44
|
+
type: "array",
|
|
45
|
+
items: { type: "string" },
|
|
46
|
+
description: "Local file path(s) or a folder path. Single video → video post. Multiple images or image folder → carousel.",
|
|
47
|
+
},
|
|
48
|
+
platforms: {
|
|
49
|
+
type: "array",
|
|
50
|
+
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
51
|
+
description: "Platforms to publish to. Must be connected.",
|
|
52
|
+
},
|
|
53
|
+
caption: { type: "string", description: "Post caption." },
|
|
54
|
+
hashtags: {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: { type: "string" },
|
|
57
|
+
description: "Hashtags without # prefix.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["paths", "platforms", "caption"],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const publishNowTool = {
|
|
64
|
+
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.",
|
|
66
|
+
inputSchema: publishSchema,
|
|
67
|
+
|
|
68
|
+
async execute(args: { paths: string[]; platforms: string[]; caption: string; hashtags?: string[] }) {
|
|
69
|
+
const filePaths = expandPaths(args.paths);
|
|
70
|
+
if (!filePaths.length) throw new Error("No media files found in the provided paths.");
|
|
71
|
+
|
|
72
|
+
const mediaType = detectMediaType(filePaths);
|
|
73
|
+
if (mediaType === "carousel" && filePaths.length > 10) {
|
|
74
|
+
throw new Error(`Instagram supports max 10 images per carousel. Found ${filePaths.length} files.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Upload files
|
|
78
|
+
const { urls, storage_paths } = await uploadFiles(filePaths);
|
|
79
|
+
|
|
80
|
+
let result: PublishResult;
|
|
81
|
+
try {
|
|
82
|
+
result = await post<PublishResult>("/v1/publish/schedule", {
|
|
83
|
+
media_urls: urls,
|
|
84
|
+
media_type: mediaType,
|
|
85
|
+
platforms: args.platforms,
|
|
86
|
+
caption: args.caption,
|
|
87
|
+
hashtags: args.hashtags ?? [],
|
|
88
|
+
publish_now: true,
|
|
89
|
+
});
|
|
90
|
+
} finally {
|
|
91
|
+
// Always clean up uploaded files
|
|
92
|
+
await deleteFiles(storage_paths).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { status, platform_post_ids, failed_reason } = result.post;
|
|
96
|
+
const published = Object.keys(platform_post_ids);
|
|
97
|
+
let summary = `Status: ${status}\n`;
|
|
98
|
+
if (published.length) summary += `Published to: ${published.join(", ")}\n`;
|
|
99
|
+
if (failed_reason) summary += `Errors: ${failed_reason}`;
|
|
100
|
+
return summary.trim();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const schedulePostTool = {
|
|
105
|
+
name: "schedule_post",
|
|
106
|
+
description: "Upload local file(s) and schedule a post for a future time. Pass a folder path to schedule all images as a carousel.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
...publishSchema.properties,
|
|
111
|
+
scheduled_time: {
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "ISO 8601 datetime string for when to publish (e.g. '2026-05-10T09:00:00'). Uses your local timezone if no timezone offset provided.",
|
|
114
|
+
},
|
|
115
|
+
timezone: {
|
|
116
|
+
type: "string",
|
|
117
|
+
description: "IANA timezone name (e.g. 'America/New_York'). Defaults to UTC.",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ["paths", "platforms", "caption", "scheduled_time"],
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async execute(args: {
|
|
124
|
+
paths: string[];
|
|
125
|
+
platforms: string[];
|
|
126
|
+
caption: string;
|
|
127
|
+
hashtags?: string[];
|
|
128
|
+
scheduled_time: string;
|
|
129
|
+
timezone?: string;
|
|
130
|
+
}) {
|
|
131
|
+
const filePaths = expandPaths(args.paths);
|
|
132
|
+
if (!filePaths.length) throw new Error("No media files found in the provided paths.");
|
|
133
|
+
|
|
134
|
+
const mediaType = detectMediaType(filePaths);
|
|
135
|
+
|
|
136
|
+
// Upload files (storage URLs remain valid for 1 hour; cron will publish before then if scheduled soon,
|
|
137
|
+
// otherwise the cron route will fail — advise scheduling within the hour for now)
|
|
138
|
+
const { urls, storage_paths } = await uploadFiles(filePaths);
|
|
139
|
+
|
|
140
|
+
const result = await post<PublishResult>("/v1/publish/schedule", {
|
|
141
|
+
media_urls: urls,
|
|
142
|
+
media_type: mediaType,
|
|
143
|
+
platforms: args.platforms,
|
|
144
|
+
caption: args.caption,
|
|
145
|
+
hashtags: args.hashtags ?? [],
|
|
146
|
+
scheduled_time: args.scheduled_time,
|
|
147
|
+
timezone: args.timezone,
|
|
148
|
+
publish_now: false,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Note: storage_paths are NOT deleted here — the cron job needs the URLs to stay valid.
|
|
152
|
+
// They expire after 1 hour. For posts scheduled further out, re-upload closer to publish time.
|
|
153
|
+
void storage_paths;
|
|
154
|
+
|
|
155
|
+
const { post: p } = result;
|
|
156
|
+
return `Scheduled post ${p.id}\nPlatforms: ${args.platforms.join(", ")}\nTime: ${args.scheduled_time}\nStatus: ${p.status}`;
|
|
157
|
+
},
|
|
158
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"resolveJsonModule": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist"]
|
|
15
|
+
}
|