@viralspin/mcp 0.1.0 → 0.3.1

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/cli.js +40 -0
  4. package/dist/client.js +56 -9
  5. package/dist/engine.js +29 -0
  6. package/dist/hosted.js +159 -0
  7. package/dist/index.js +144 -2
  8. package/dist/local-render/kit.js +217 -0
  9. package/dist/local-render/login.js +138 -0
  10. package/dist/local-render/render.js +252 -0
  11. package/dist/local-render/tokens.js +201 -0
  12. package/dist/sanitize.js +22 -0
  13. package/dist/tools/assembly-adjust.js +194 -0
  14. package/dist/tools/carousel-video.js +197 -0
  15. package/dist/tools/color-grade.js +64 -0
  16. package/dist/tools/connections.js +23 -4
  17. package/dist/tools/edit-clip.js +136 -0
  18. package/dist/tools/engage.js +97 -0
  19. package/dist/tools/explainer-index.js +307 -0
  20. package/dist/tools/find-moments.js +140 -0
  21. package/dist/tools/folder-preview.js +71 -0
  22. package/dist/tools/ideas.js +158 -0
  23. package/dist/tools/image-ads.js +156 -0
  24. package/dist/tools/kinetic-text.js +122 -0
  25. package/dist/tools/local-index.js +84 -0
  26. package/dist/tools/local-mode.js +84 -0
  27. package/dist/tools/photo-picker.js +31 -0
  28. package/dist/tools/profile.js +56 -0
  29. package/dist/tools/publish.js +119 -12
  30. package/dist/tools/relight-clip.js +98 -0
  31. package/dist/tools/render-locally.js +158 -0
  32. package/dist/tools/review.js +103 -0
  33. package/dist/tools/timeline-edit.js +367 -0
  34. package/dist/tools/video-assembly.js +310 -0
  35. package/package.json +16 -3
  36. package/src/client.ts +0 -80
  37. package/src/index.ts +0 -60
  38. package/src/tools/captions.ts +0 -32
  39. package/src/tools/connections.ts +0 -27
  40. package/src/tools/posts.ts +0 -58
  41. package/src/tools/publish.ts +0 -158
  42. package/tsconfig.json +0 -15
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ViralSpin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # ViralSpin MCP — the video editor your AI agent operates
2
+
3
+ [![MCP Registry](https://img.shields.io/badge/MCP-registry-blue)](https://registry.modelcontextprotocol.io/v0/servers?search=viralspin)
4
+ [![npm](https://img.shields.io/badge/npm-%40viralspin%2Fmcp-red)](https://www.npmjs.com/package/@viralspin/mcp)
5
+ [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
6
+
7
+ Turn a talking-head clip + a folder of B-roll into a polished vertical short — and **let Claude do the edit**. Point it at your footage in plain English; it transcribes, picks B-roll cutaways matched to what's said, adds captions + a hook, and renders a 9:16 short. Then publishes to TikTok, Instagram, or YouTube. No timeline-wrangling required.
8
+
9
+ > **The agent operates a real timeline.** Swap a clip, trim it, reorder scenes, cut a span — all by asking. Edits are instant; you render once when the cut is right.
10
+
11
+ ![demo](docs/demo.gif) <!-- TODO: the split-screen "Claude edits, clips stream onto the timeline" clip -->
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ # Claude Code
17
+ claude mcp add viralspin -- npx -y @viralspin/mcp
18
+
19
+ # or Claude Desktop — add to claude_desktop_config.json:
20
+ {
21
+ "mcpServers": {
22
+ "viralspin": { "command": "npx", "args": ["-y", "@viralspin/mcp"] }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Then just ask:
28
+
29
+ ```
30
+ Turn my A-roll at C:/clips/talk.mp4 into a 9:16 short, using B-roll
31
+ from C:/my-broll and my Google Photos.
32
+
33
+ Publish that to TikTok and Instagram with the caption: posting through the chaos.
34
+ ```
35
+
36
+ ## Render a reel on your own computer (`render_locally`)
37
+
38
+ ViralSpin plans the reel and hands over the media; **your Mac or PC does the draw**, nothing is published, 0 credits.
39
+
40
+ 1. Install Node 20+ (macOS: `brew install node` - Windows: `winget install OpenJS.NodeJS.LTS`) and keep 8 GB of disk free.
41
+ 2. Add the server to Claude Desktop (`claude_desktop_config.json`; on Windows use `"command": "npx.cmd"` if `npx` is not found):
42
+ `"viralspin": { "command": "npx", "args": ["-y", "@viralspin/mcp"] }` - no tokens to paste.
43
+ 3. Connect once: in a terminal run `npx -y @viralspin/mcp login` (your browser opens, you click **Authorize**). Skipping this is fine too: the first time you ask Claude to render, the browser opens by itself. Credentials live in `~/.viralspin/oauth.json` (Windows: `%USERPROFILE%\.viralspin`), readable only by you, and renew themselves silently for about 30 days of use. `npx -y @viralspin/mcp whoami` / `logout` show / remove them; nothing ever prints a token.
44
+ 4. In the ViralSpin app: open a reel's **style picker**, choose **On my computer**, tap **Send to my computer**.
45
+ 5. In Claude Desktop say: *"Render my ViralSpin reel"* - with no arguments it picks your newest waiting reel and says which. The first run downloads a ~0.5 GB kit into `~/.viralspin/kit` and Chrome Headless Shell once. The finished reel is uploaded back and shows up in the app; a copy stays in `~/.viralspin/jobs/<id>/`.
46
+
47
+ Tools: `render_locally` (no arguments, or `job_id`, `render_id`, `request`, `concurrency`, `dry_run`, `complete`, `upload`, `background`) and `render_locally_status` (no arguments lists your waiting / rendering reels).
48
+ If the Chrome download is blocked by antivirus or a proxy the error says so and names the manual step.
49
+
50
+ ## What the agent can do
51
+
52
+ | Tool | What it does |
53
+ |---|---|
54
+ | `create_video_assembly` | A-roll + B-roll (local files, a folder, URLs, or Google Photos) → a rendered vertical short. Local or hosted. |
55
+ | `suggest_clips` / `swap_clip` | See the cutaways; swap one for better-fitting B-roll (with a `reason`). |
56
+ | `trim_clip` / `remove_span` / `reorder_scenes` | Granular timeline edits — instant, no re-render. |
57
+ | `render_project` | Bake captions/overlays into the final MP4 (the one render). |
58
+ | `publish_now` / `schedule_post` / `send_to_drafts` | Post to TikTok / Instagram / YouTube. |
59
+ | `generate_caption`, `create_kinetic_text`, carousels, image ads | The rest of the content pipeline. |
60
+
61
+ Edits are **instant and free** (they patch the timeline); `render_project` is the single cloud render. Iterate the cut as much as you like, then finalize once.
62
+
63
+ ## Open source
64
+
65
+ The MCP server, the editor, and the agent verbs are **MIT** — use them free. Cloud generation (AI B-roll, rendering, publishing) runs on ViralSpin's infrastructure and is metered as credits; sign in at [viralspin.ai](https://www.viralspin.ai). You never paste an API key for the AI — the heavy lifting is server-side.
66
+
67
+ - Listed in the official **MCP registry**: `ai.viralspin/viralspin`
68
+ - Hosted connector (no install, for URL / Google Photos workflows): `https://mcp.viralspin.ai/mcp`
69
+
70
+ ## Local vs hosted
71
+
72
+ - **Hosted (the default):** the cloud pipeline does the heavy lifting — assembly, AI B-roll, rendering — and every edit opens in the web Swap & Refine editor. B-roll from Google Photos / URLs / your synced library. Needs a [viralspin.ai](https://www.viralspin.ai) account (generation is metered as credits); no API keys to paste. The `mcp.viralspin.ai/mcp` hosted connector is the same, zero-install.
73
+ - **Local (advanced):** point `VIRALSPIN_ASSEMBLY_DIR` at a local engine checkout to read your disk — index a folder of clips, select B-roll contextually, and render locally. The npm package ships without the engine, so the local-render tools route you to the hosted path until that env var is set.
74
+
75
+ Either way, the agent reads local files *to upload them* (A-roll, a B-roll folder); only on-disk rendering needs the local engine.
76
+
77
+ ## License
78
+
79
+ MIT — see [LICENSE](./LICENSE).
package/dist/cli.js ADDED
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runCli = runCli;
4
+ const client_js_1 = require("./client.js");
5
+ const tokens_js_1 = require("./local-render/tokens.js");
6
+ const login_js_1 = require("./local-render/login.js");
7
+ /**
8
+ * `viralspin-mcp login | logout | whoami` - run from a terminal (not by Claude). Returns true when it handled the command,
9
+ * so index.ts knows not to start the MCP server. Never prints a token.
10
+ */
11
+ async function runCli(argv, out = (s) => process.stdout.write(s + "\n")) {
12
+ const cmd = argv[0];
13
+ if (cmd !== "login" && cmd !== "logout" && cmd !== "whoami" && cmd !== "--help" && cmd !== "help")
14
+ return false;
15
+ if (cmd === "login") {
16
+ try {
17
+ const r = await (0, login_js_1.login)({ apiBase: client_js_1.PY_API_URL, log: out });
18
+ out(`Signed in. Credentials are stored in ${r.file} (readable only by you). Nothing else to do: ask Claude to "Render my ViralSpin reel".`);
19
+ }
20
+ catch (e) {
21
+ out(`Sign-in did not finish: ${e.message}`);
22
+ process.exitCode = 1;
23
+ }
24
+ }
25
+ else if (cmd === "logout") {
26
+ out((0, login_js_1.logout)() ? `Signed out (removed ${(0, tokens_js_1.defaultTokenFile)()}).` : "You were not signed in on this computer.");
27
+ }
28
+ else if (cmd === "whoami") {
29
+ const w = (0, login_js_1.whoami)();
30
+ out(!w.signedIn ? "Not signed in. Run: npx -y @viralspin/mcp login"
31
+ : `Signed in as ViralSpin user ${w.user ?? "(unknown)"}; ${w.hasRefresh ? "renews itself" : "no refresh token (expires)"}.`);
32
+ }
33
+ else {
34
+ out("viralspin-mcp run as an MCP server (Claude Desktop / Claude Code start this)\n" +
35
+ "viralspin-mcp login connect this computer to your ViralSpin account (opens your browser once)\n" +
36
+ "viralspin-mcp whoami show whether this computer is connected (never prints a token)\n" +
37
+ "viralspin-mcp logout disconnect this computer");
38
+ }
39
+ return true;
40
+ }
package/dist/client.js CHANGED
@@ -3,24 +3,36 @@ 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.PY_API_URL = void 0;
6
7
  exports.get = get;
7
8
  exports.post = post;
9
+ exports.patch = patch;
10
+ exports.pyGet = pyGet;
11
+ exports.pyPost = pyPost;
8
12
  exports.del = del;
9
13
  exports.uploadFiles = uploadFiles;
10
14
  exports.deleteFiles = deleteFiles;
11
- const form_data_1 = __importDefault(require("form-data"));
12
15
  const fs_1 = __importDefault(require("fs"));
13
16
  const path_1 = __importDefault(require("path"));
14
17
  const API_URL = (process.env.VIRALSPIN_API_URL || "https://viralspinbackend.onrender.com").replace(/\/$/, "");
18
+ // Python backend — Higgsfield, review-sessions, OAuth, narrative-render
19
+ exports.PY_API_URL = (process.env.VIRALSPIN_PY_API_URL || "https://viralspin-api.onrender.com").replace(/\/$/, "");
15
20
  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");
21
+ // OAuth token takes precedence over API key (set after "Sign in with ViralSpin" flow)
22
+ const OAUTH_TOKEN = process.env.VIRALSPIN_OAUTH_TOKEN || "";
23
+ if (!API_KEY && !OAUTH_TOKEN) {
24
+ process.stderr.write("[viralspin-mcp] Warning: Neither VIRALSPIN_API_KEY nor VIRALSPIN_OAUTH_TOKEN is set\n");
18
25
  }
19
26
  function headers(extra = {}) {
27
+ // OAuth Bearer token (from "Sign in with ViralSpin" flow) takes priority
28
+ if (OAUTH_TOKEN) {
29
+ return { "Authorization": `Bearer ${OAUTH_TOKEN}`, ...extra };
30
+ }
31
+ // Legacy API key — retained for backward compatibility with personal installs
20
32
  return { "X-API-Key": API_KEY, ...extra };
21
33
  }
22
- async function apiFetch(endpoint, init = {}) {
23
- const url = `${API_URL}${endpoint}`;
34
+ async function apiFetch(endpoint, init = {}, base = API_URL) {
35
+ const url = `${base}${endpoint}`;
24
36
  const resp = await fetch(url, {
25
37
  ...init,
26
38
  headers: { ...(init.headers ?? {}), ...headers() },
@@ -46,6 +58,24 @@ function post(endpoint, body) {
46
58
  body: JSON.stringify(body),
47
59
  });
48
60
  }
61
+ function patch(endpoint, body) {
62
+ return apiFetch(endpoint, {
63
+ method: "PATCH",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify(body),
66
+ });
67
+ }
68
+ /** Python backend helpers — for routes that live in viralspin-api (Higgsfield, OAuth, etc.) */
69
+ function pyGet(endpoint) {
70
+ return apiFetch(endpoint, { method: "GET" }, exports.PY_API_URL);
71
+ }
72
+ function pyPost(endpoint, body) {
73
+ return apiFetch(endpoint, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify(body),
77
+ }, exports.PY_API_URL);
78
+ }
49
79
  function del(endpoint, body) {
50
80
  return apiFetch(endpoint, {
51
81
  method: "DELETE",
@@ -55,15 +85,32 @@ function del(endpoint, body) {
55
85
  }
56
86
  /** Upload one or more local files. Returns { url, storage_path } or { urls, storage_paths } */
57
87
  async function uploadFiles(filePaths) {
58
- const form = new form_data_1.default();
88
+ // Use the native Web FormData + Blob (Node 18+). The npm 'form-data' package
89
+ // streams in a way native fetch can't serialize correctly, which previously
90
+ // caused Busboy on the backend to fail with "Failed to parse upload".
91
+ const form = new FormData();
59
92
  const fieldName = filePaths.length === 1 ? "file" : "files";
60
93
  for (const fp of filePaths) {
61
- form.append(fieldName, fs_1.default.createReadStream(fp), path_1.default.basename(fp));
94
+ const buffer = await fs_1.default.promises.readFile(fp);
95
+ const ext = path_1.default.extname(fp).toLowerCase();
96
+ const mime = ext === ".png" ? "image/png" :
97
+ ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
98
+ ext === ".gif" ? "image/gif" :
99
+ ext === ".webp" ? "image/webp" :
100
+ ext === ".mp4" ? "video/mp4" :
101
+ ext === ".mov" ? "video/quicktime" :
102
+ ext === ".webm" ? "video/webm" :
103
+ "application/octet-stream";
104
+ const blob = new Blob([buffer], { type: mime });
105
+ form.append(fieldName, blob, path_1.default.basename(fp));
62
106
  }
107
+ const authHeader = OAUTH_TOKEN
108
+ ? { "Authorization": `Bearer ${OAUTH_TOKEN}` }
109
+ : { "X-API-Key": API_KEY };
63
110
  const resp = await fetch(`${API_URL}/v1/media/upload`, {
64
111
  method: "POST",
65
- headers: { "X-API-Key": API_KEY, ...form.getHeaders() },
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ // Don't set Content-Type manually — fetch will set it with the correct multipart boundary.
113
+ headers: authHeader,
67
114
  body: form,
68
115
  });
69
116
  if (!resp.ok) {
package/dist/engine.js ADDED
@@ -0,0 +1,29 @@
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.ENGINE_DIR = void 0;
7
+ exports.engineAvailable = engineAvailable;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ /**
11
+ * The local Remotion assembly engine ships INSIDE this package (mcp/engine).
12
+ * At runtime __dirname = mcp/dist, so ../engine = mcp/engine. Override with
13
+ * VIRALSPIN_ASSEMBLY_DIR to point at a different engine checkout.
14
+ *
15
+ * NOTE: the published npm package ships WITHOUT the engine (it's the closed
16
+ * render/selection "magic" — see the OSS launch plan). Local-engine tools must
17
+ * therefore check engineAvailable() and degrade to a friendly message rather
18
+ * than failing with an opaque spawn error.
19
+ */
20
+ exports.ENGINE_DIR = process.env.VIRALSPIN_ASSEMBLY_DIR || path_1.default.resolve(__dirname, "../engine");
21
+ /** True when the local engine (with the given entry script) is present. */
22
+ function engineAvailable(entry = "assemble.mjs") {
23
+ try {
24
+ return fs_1.default.existsSync(path_1.default.join(exports.ENGINE_DIR, entry));
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
package/dist/hosted.js ADDED
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.editorUrl = editorUrl;
4
+ exports.hostedJobId = hostedJobId;
5
+ exports.getJob = getJob;
6
+ exports.pollJob = pollJob;
7
+ exports.attachUploadsWhenReady = attachUploadsWhenReady;
8
+ exports.swapSegment = swapSegment;
9
+ exports.setClipDuration = setClipDuration;
10
+ exports.removeSpan = removeSpan;
11
+ exports.detectScenes = detectScenes;
12
+ exports.saveSceneOrder = saveSceneOrder;
13
+ exports.rerender = rerender;
14
+ exports.searchLibrary = searchLibrary;
15
+ exports.listSegments = listSegments;
16
+ /**
17
+ * Hosted Swap & Refine integration.
18
+ *
19
+ * The web Swap & Refine editor is the *narrative jobs* system: a "project" is a
20
+ * narrative job (`job_id`), its edit state lives in `job.timeline`, and B-roll
21
+ * swaps + re-renders go through endpoints that are ALREADY deployed and used by
22
+ * the browser editor (`/v1/narrative/jobs/...`). These helpers let the MCP edit
23
+ * verbs drive that same hosted project instead of the local on-disk engine, so a
24
+ * swap done from Claude shows up in the web editor (and the render lands in the
25
+ * user's Swap & Refine list). Local-engine behavior is untouched — the hosted
26
+ * path is opt-in (a `job_id` arg or the VIRALSPIN_HOSTED_JOB_ID env).
27
+ */
28
+ const client_js_1 = require("./client.js");
29
+ const APP_URL = (process.env.VIRALSPIN_APP_URL || "https://app.viralspin.ai").replace(/\/$/, "");
30
+ /** Deep link to the project in the web Swap & Refine editor. */
31
+ function editorUrl(jobId) {
32
+ return `${APP_URL}/swap-refine/${jobId}`;
33
+ }
34
+ /**
35
+ * The hosted project the edit verbs act on: an explicit `job_id` wins, else the
36
+ * VIRALSPIN_HOSTED_JOB_ID env (set after a hosted create_video_assembly), else
37
+ * null → the caller falls back to the local engine. This is the single switch
38
+ * that keeps existing local behavior the default.
39
+ */
40
+ function hostedJobId(explicit) {
41
+ return explicit || process.env.VIRALSPIN_HOSTED_JOB_ID || null;
42
+ }
43
+ function getJob(jobId) {
44
+ return (0, client_js_1.get)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}`);
45
+ }
46
+ /** Poll until the job reaches a terminal state (complete/failed) or times out. */
47
+ async function pollJob(jobId, timeoutMs = 15 * 60 * 1000) {
48
+ const deadline = Date.now() + timeoutMs;
49
+ let job = { id: jobId, status: "queued" };
50
+ while (Date.now() < deadline) {
51
+ await new Promise((r) => setTimeout(r, 6000));
52
+ job = await getJob(jobId);
53
+ if (job.status === "complete" || job.status === "failed")
54
+ break;
55
+ }
56
+ return job;
57
+ }
58
+ /**
59
+ * Attach B-roll uploads to a hosted project's refine pool, waiting for the
60
+ * pipeline to build a timeline first. The uploads endpoint 409s with
61
+ * "job has no timeline yet" until the build stage finishes (verified against the
62
+ * live backend 2026-06-22), so a fresh create can't attach immediately. Bounded
63
+ * poll; returns whether the attach landed.
64
+ */
65
+ async function attachUploadsWhenReady(jobId, items, timeoutMs = 90000) {
66
+ if (!items.length)
67
+ return { attached: false, detail: "no items" };
68
+ const deadline = Date.now() + timeoutMs;
69
+ let lastDetail = "timed out before the project's timeline was ready";
70
+ while (Date.now() < deadline) {
71
+ const job = await getJob(jobId);
72
+ if (job.status === "failed")
73
+ return { attached: false, detail: "job failed before attach" };
74
+ const hasTimeline = !!(job.timeline && (job.timeline.aroll_url || (job.timeline.timeline?.length ?? 0)));
75
+ if (hasTimeline) {
76
+ try {
77
+ await (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/uploads`, { items });
78
+ return { attached: true };
79
+ }
80
+ catch (e) {
81
+ lastDetail = e instanceof Error ? e.message : String(e);
82
+ if (!/no timeline yet/i.test(lastDetail))
83
+ return { attached: false, detail: lastDetail };
84
+ }
85
+ }
86
+ await new Promise((r) => setTimeout(r, 5000));
87
+ }
88
+ return { attached: false, detail: lastDetail };
89
+ }
90
+ /** Replace one cutaway's B-roll in the stored timeline (same PATCH the editor
91
+ * uses). `reason` is optional provenance — why the agent placed this clip; the
92
+ * backend persists it when supported, and it's echoed back regardless. */
93
+ function swapSegment(jobId, segmentId, assetUrl, assetKind, reason) {
94
+ return (0, client_js_1.patch)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/swap`, {
95
+ segment_id: segmentId,
96
+ asset_url: assetUrl,
97
+ asset_kind: assetKind,
98
+ ...(reason ? { reason } : {}),
99
+ });
100
+ }
101
+ /** Shorten/restore a cutaway's on-screen duration (audio-safe — the backend
102
+ * pairs the trim with A-roll filler so total length is preserved). */
103
+ function setClipDuration(jobId, segmentId, durationSec) {
104
+ return (0, client_js_1.patch)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/segment-duration`, {
105
+ segment_id: segmentId,
106
+ duration_sec: durationSec,
107
+ });
108
+ }
109
+ /** Cut a boundary-aligned span [fromSec,toSec] out of the output (e.g. a
110
+ * duplicate opening line); backend trims the A-roll bed + re-renders. */
111
+ function removeSpan(jobId, fromSec, toSec, render = true) {
112
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/remove-span`, {
113
+ from_sec: fromSec,
114
+ to_sec: toSec,
115
+ render,
116
+ });
117
+ }
118
+ /** Detect (cached) the A-roll's backbone scenes — the reorderable building blocks. */
119
+ function detectScenes(jobId) {
120
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/scenes/detect`, {});
121
+ }
122
+ /** Persist a new running order / soft-removals for the backbone scenes. */
123
+ function saveSceneOrder(jobId, scenes) {
124
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/scenes/order`, { scenes });
125
+ }
126
+ /** Re-render the stored timeline after one or more swaps. Returns immediately. */
127
+ function rerender(jobId) {
128
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/rerender`, {});
129
+ }
130
+ /**
131
+ * Text-search the user's analyzed B-roll library (matches each asset's Gemini
132
+ * tags — notes/subjects/emotion). The match is the "why this clip fits" signal
133
+ * we surface to the user when proposing a swap.
134
+ */
135
+ function searchLibrary(q, limit = 12) {
136
+ return (0, client_js_1.get)(`/v1/assembly/search?q=${encodeURIComponent(q)}&limit=${limit}`);
137
+ }
138
+ /**
139
+ * Best-effort list of the timeline's B-roll cutaways (id + current asset + the
140
+ * beat text it covers) so the user/agent can see what to swap. Tolerant of the
141
+ * exact timeline JSON shape: tries the common array fields and degrades to [].
142
+ */
143
+ function listSegments(timeline) {
144
+ if (!timeline || typeof timeline !== "object")
145
+ return [];
146
+ // The live shape stores the cut at `timeline.timeline` (an array that mixes the
147
+ // A-roll bed + B-roll cutaways); older/other shapes use segments/brolls/overlays.
148
+ const arr = timeline.timeline || timeline.segments || timeline.brolls || timeline.overlays || timeline.broll_overlays || [];
149
+ if (!Array.isArray(arr))
150
+ return [];
151
+ return arr
152
+ .filter((s) => s && (s.id || s.segment_id) && String(s.type ?? "") !== "aroll")
153
+ .map((s) => ({
154
+ id: String(s.id ?? s.segment_id),
155
+ beat: s.beat ?? s.label ?? s.caption ?? s.transcript ?? s.phrase ?? undefined,
156
+ asset_url: s.asset_url ?? s.url ?? undefined,
157
+ kind: s.type ?? s.kind ?? undefined,
158
+ }));
159
+ }
package/dist/index.js CHANGED
@@ -6,17 +6,151 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
6
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
7
7
  const connections_js_1 = require("./tools/connections.js");
8
8
  const publish_js_1 = require("./tools/publish.js");
9
+ const carousel_video_js_1 = require("./tools/carousel-video.js");
10
+ const video_assembly_js_1 = require("./tools/video-assembly.js");
11
+ const photo_picker_js_1 = require("./tools/photo-picker.js");
12
+ const assembly_adjust_js_1 = require("./tools/assembly-adjust.js");
13
+ const timeline_edit_js_1 = require("./tools/timeline-edit.js");
14
+ const folder_preview_js_1 = require("./tools/folder-preview.js");
15
+ const find_moments_js_1 = require("./tools/find-moments.js");
16
+ const local_mode_js_1 = require("./tools/local-mode.js");
17
+ const color_grade_js_1 = require("./tools/color-grade.js");
18
+ const edit_clip_js_1 = require("./tools/edit-clip.js");
19
+ const relight_clip_js_1 = require("./tools/relight-clip.js");
20
+ const ideas_js_1 = require("./tools/ideas.js");
21
+ const local_index_js_1 = require("./tools/local-index.js");
22
+ const profile_js_1 = require("./tools/profile.js");
9
23
  const posts_js_1 = require("./tools/posts.js");
10
24
  const captions_js_1 = require("./tools/captions.js");
25
+ const engage_js_1 = require("./tools/engage.js");
26
+ const explainer_index_js_1 = require("./tools/explainer-index.js");
27
+ const kinetic_text_js_1 = require("./tools/kinetic-text.js");
28
+ const review_js_1 = require("./tools/review.js");
29
+ const cli_js_1 = require("./cli.js");
30
+ const render_locally_js_1 = require("./tools/render-locally.js");
31
+ const image_ads_js_1 = require("./tools/image-ads.js");
11
32
  const tools = [
12
33
  connections_js_1.listConnectionsTool,
13
34
  publish_js_1.publishNowTool,
14
35
  publish_js_1.schedulePostTool,
36
+ publish_js_1.sendToDraftsTool,
37
+ carousel_video_js_1.publishCarouselAsVideoTool,
38
+ video_assembly_js_1.createVideoAssemblyTool,
39
+ photo_picker_js_1.startPhotoPickerTool,
40
+ assembly_adjust_js_1.suggestClipsTool,
41
+ assembly_adjust_js_1.swapClipTool,
42
+ assembly_adjust_js_1.swapCaptionStyleTool,
43
+ timeline_edit_js_1.trimClipTool,
44
+ timeline_edit_js_1.removeSpanTool,
45
+ timeline_edit_js_1.reorderScenesTool,
46
+ timeline_edit_js_1.renderProjectTool,
47
+ timeline_edit_js_1.trimSilencesTool,
48
+ timeline_edit_js_1.getTranscriptTool,
49
+ timeline_edit_js_1.removeSpansTool,
50
+ timeline_edit_js_1.keepWindowsTool,
51
+ folder_preview_js_1.previewFolderTool,
52
+ find_moments_js_1.findMomentsTool,
53
+ local_mode_js_1.setLocalModeTool,
54
+ color_grade_js_1.colorGradeTool,
55
+ edit_clip_js_1.editRawClipTool,
56
+ relight_clip_js_1.relightClipTool,
57
+ ideas_js_1.queueIdeaTool,
58
+ ideas_js_1.listIdeasTool,
59
+ profile_js_1.setCreatorProfileTool,
60
+ profile_js_1.getCreatorProfileTool,
61
+ local_index_js_1.syncLocalIndexTool,
62
+ local_index_js_1.searchLocalIndexTool,
15
63
  posts_js_1.listPostsTool,
16
64
  posts_js_1.cancelPostTool,
17
65
  captions_js_1.generateCaptionTool,
66
+ engage_js_1.setKeywordLinkTool,
67
+ engage_js_1.listKeywordLinksTool,
68
+ engage_js_1.deleteKeywordLinkTool,
69
+ explainer_index_js_1.indexExplainerSourcesTool,
70
+ kinetic_text_js_1.createKineticTextTool,
71
+ review_js_1.createReviewLinkTool,
72
+ review_js_1.checkReviewStatusTool,
73
+ image_ads_js_1.prepareProductAssetTool,
74
+ image_ads_js_1.assembleImageAdTool,
75
+ image_ads_js_1.publishToMetaTool,
76
+ render_locally_js_1.renderLocallyTool,
77
+ render_locally_js_1.renderLocallyStatusTool,
18
78
  ];
19
- const server = new index_js_1.Server({ name: "viralspin", version: "0.1.0" }, { capabilities: { tools: {} } });
79
+ // Shown to the user as the welcome / getting-started message. The agent fills in
80
+ // <picker link> by calling start_photo_picker (per ONBOARDING below).
81
+ const SAMPLE_COMMANDS = [
82
+ "Try one of these — I'll open a DRAFT in your ViralSpin editor for you to review side-by-side:",
83
+ "",
84
+ 'SIMPLE — a quick short:',
85
+ ' "Turn my talking-head at C:/clips/talk.mp4 into a 9:16 TikTok short — pull B-roll from',
86
+ ' C:/my-broll and my Google Photos, add captions, and show me the draft."',
87
+ "",
88
+ 'COMPREHENSIVE — hook + story + scheduled publish (the full loop):',
89
+ ' "Make a hosted reel. Open on the strongest surprise/reveal clip from C:/footage/hooks with the',
90
+ ' title \'Claude just killed video editors\', then tell the story of how AI is replacing video editors',
91
+ ' using the clips in C:/footage/story. Build a story arc that fits the footage, caption it, and show',
92
+ ' me the draft to review. Once I approve, schedule it to Instagram and TikTok next Tuesday at 8pm."',
93
+ ].join("\n");
94
+ const WELCOME = [
95
+ "Welcome to ViralSpin. I turn your footage into polished vertical shorts — B-roll matched to",
96
+ "what's said, captions, a hook headline — then PUBLISH or SCHEDULE them to TikTok, Instagram,",
97
+ "or YouTube. I open every project as a DRAFT in your web editor so you can watch it come together",
98
+ "and tweak it side-by-side. B-roll can be local files, a local folder (auto-indexed), URLs, or Google Photos.",
99
+ "",
100
+ SAMPLE_COMMANDS,
101
+ ].join("\n");
102
+ // Auto-injected into the agent's context when the server connects. Drives proactive onboarding.
103
+ const INSTRUCTIONS = [
104
+ "ViralSpin assembles footage into polished vertical shorts and PUBLISHES/SCHEDULES them to TikTok/Instagram/YouTube — edit AND ship, all from chat, with a live web editor the user watches side-by-side.",
105
+ "",
106
+ "ONBOARDING: The FIRST time you help the user in a session: (1) give the intro below, (2) CALL start_photo_picker for a Google Photos B-roll picker link, and (3) show the TWO ready-to-run use cases verbatim (a SIMPLE one and a COMPREHENSIVE one):",
107
+ SAMPLE_COMMANDS,
108
+ "",
109
+ "DRAFT-FIRST WORKFLOW: For anything beyond a trivial one-off, create a HOSTED project (create_video_assembly destination:\"hosted\") so it opens in the web Swap & Refine editor — the user reviews it side-by-side while you work. You HAVE FREEDOM to pick the clips that best match the user's intent: use suggest_clips to search their analyzed library by MEANING (e.g. a 'surprise/reveal' hook), and build a story arc + hook title that fits the AVAILABLE B-roll. Return the editor link as a DRAFT and invite review. Refine on request with swap_clip / trim_clip / reorder_scenes / remove_span (these stream live into the editor), then render_project. NEVER auto-publish — call publish_now / schedule_post ONLY after the user approves the draft. For 'schedule … next Tuesday 8pm' use schedule_post with that time.",
110
+ "",
111
+ "HOW B-ROLL WORKS: create_video_assembly accepts local file paths, a local FOLDER (auto-expanded + indexed), public URLs, and Google Photos. Hosted user_provided B-roll comes from the synced library / Google Photos picker (call start_photo_picker → user picks → pass google_picker_session). Pass style_ref (a reference video) to auto-infer captions/hook/pace/aspect.",
112
+ "",
113
+ "COST GATE: before assembling a LARGE local folder, call preview_folder first — it counts the clips so you can warn the user (each is analyzed + charged on create) and CONFIRM before spending credits.",
114
+ "",
115
+ "SMART CUTS (you read the transcript, you decide what to cut — no extra vendor): 'edit out the dead air' → trim_silences. 'Remove the bad takes / restarts / repeats' → get_transcript, identify the bad spans yourself, then remove_spans. 'Find the best moments in this long recording / make a highlight reel' → get_transcript, pick the punchy windows, then keep_windows (keeps only those, cuts the rest). This is the cheap moment-search path for talking/narrated long videos — uses the Whisper transcript + your judgment, no Twelve Labs.",
116
+ "",
117
+ "LONG / SILENT VIDEO → REEL: for a long or silent local video (e.g. a screen recording), run find_moments(extract:true) — it scores frames with the vision model (free on the user's GPU when local mode is on) and cuts the highlights into small clips LOCALLY. Then build the reel with create_video_assembly using those clips: destination:'hosted' uploads ONLY the snippets (not the long source) + opens the web editor (DEFAULT); destination:'local' keeps everything on-device with no upload (fully private). Same analysis, two destinations — let the user pick privacy vs the web editor.",
118
+ "",
119
+ "LOOK / LIGHTING: 'make it cinematic' / 'add a teal-orange (or noir / warm / moody) look' → color_grade — FREE, instant ffmpeg LUT looks, no cost, great on the A-roll or any clip (call with no `look` to list them). 'Relight this / change the lighting / put me in [a scene] / re-scene it' → relight_clip — PAID Fal.ai (best on B-roll + short 3–10s hero shots; can change background + add real directional light). ALWAYS show the cost and get explicit confirmation before relight_clip's paid video step (it's two-call: preview the frame first, then confirm:true). Default to the free color_grade unless the user wants a true relight / new background.",
120
+ "",
121
+ "IDEA PIPELINE (brainstorm → execute, persistent): when the user wants content ideas or pastes a saved/liked post URL they want to replicate — (1) analyze it + identify their core topics, (2) write a teleprompter-ready SCRIPT, (3) find what they ALREADY have: search_local_index for their local footage (run sync_local_index on their folders first so it's catalogued) + suggest_clips for cloud library, (4) build a per-shot ASSET PLAN — reuse an existing clip, else AI-generate B-roll (theme), pull a web image (logo/person), add a kinetic-text overlay, or tell them to FILM the missing shot, (5) queue_idea it (hook + script + asset_plan + source_url). It persists cross-browser in /spin/history; the user films the script on the teleprompter at /create and the plan says what B-roll to use vs shoot. Recommend the BEST B-roll option per shot. Use list_ideas to resume / pick the next idea to execute.",
122
+ "",
123
+ "RENDER ON THIS COMPUTER: when the user asks to render a reel 'locally' / on their own machine / to save cloud time, call render_locally. With NO arguments it renders the newest reel the person sent from the ViralSpin app ('Send to my computer') and says which it picked; 'Render my ViralSpin reel' means exactly that. The first call opens the browser once for sign-in (no tokens to paste). Or pass a render_id of a reel ViralSpin already planned, or a reel request. Try dry_run:true first on a new computer. ViralSpin only plans; this computer draws, nothing is published, 0 credits.",
124
+ "",
125
+ "WEEKLY IDEA ENGINE: at setup, learn the user's context — call get_creator_profile; if empty, read their connected accounts via list_connections and/or ASK for their niche + handles, then set_creator_profile. To seed a week's ideas: research what's WORKING in their niche — top-performing short-form on TikTok / Instagram / YouTube Shorts (YouTube Shorts has the cleanest 'top by views' signal; for IG/TikTok use WebSearch + the Discover feature), and factor in the user's OWN account insights — then generate 5 ideas and queue_idea each with the SAME `week` tag (e.g. '2026-W27') and a `format` ('video' = film off the teleprompter at /create; 'carousel' = image carousel for a niche news item). They appear as a week-tab set in /spin/history. Offer to make this a WEEKLY ROUTINE (a scheduled run that re-seeds 5 ideas each week) and set weekly_routine via set_creator_profile.",
126
+ ].join("\n");
127
+ const server = new index_js_1.Server({ name: "viralspin", version: "0.1.0" }, { capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS });
128
+ server.setRequestHandler(types_js_1.ListPromptsRequestSchema, async () => ({
129
+ prompts: [
130
+ {
131
+ name: "getting_started",
132
+ description: "Welcome + how to use ViralSpin: intro, a Google Photos B-roll picker link, and two sample commands.",
133
+ },
134
+ ],
135
+ }));
136
+ server.setRequestHandler(types_js_1.GetPromptRequestSchema, async (req) => {
137
+ if (req.params.name !== "getting_started") {
138
+ throw new Error(`Unknown prompt: ${req.params.name}`);
139
+ }
140
+ return {
141
+ messages: [
142
+ {
143
+ role: "user",
144
+ content: {
145
+ type: "text",
146
+ text: WELCOME +
147
+ "\n\nNow call start_photo_picker and include the returned Google Photos picker link in your reply, " +
148
+ "so I can pick B-roll. Keep the two sample commands above in your reply.",
149
+ },
150
+ },
151
+ ],
152
+ };
153
+ });
20
154
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
21
155
  tools: tools.map((t) => ({
22
156
  name: t.name,
@@ -30,7 +164,13 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
30
164
  return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
31
165
  }
32
166
  try {
33
- const result = await tool.execute(req.params.arguments);
167
+ // Long tools (render_locally) report progress lines: as MCP progress notifications when the client asked for them.
168
+ const token = req.params._meta?.progressToken;
169
+ let n = 0;
170
+ const progress = token === undefined ? undefined : (line) => {
171
+ server.notification({ method: "notifications/progress", params: { progressToken: token, progress: ++n, message: line } }).catch(() => undefined);
172
+ };
173
+ const result = await tool.execute(req.params.arguments, progress);
34
174
  return { content: [{ type: "text", text: String(result) }] };
35
175
  }
36
176
  catch (err) {
@@ -39,6 +179,8 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
39
179
  }
40
180
  });
41
181
  async function main() {
182
+ if (await (0, cli_js_1.runCli)(process.argv.slice(2)))
183
+ return;
42
184
  const transport = new stdio_js_1.StdioServerTransport();
43
185
  await server.connect(transport);
44
186
  process.stderr.write("[viralspin-mcp] Server started\n");