@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.
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,65 @@
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
+ ## What the agent can do
37
+
38
+ | Tool | What it does |
39
+ |---|---|
40
+ | `create_video_assembly` | A-roll + B-roll (local files, a folder, URLs, or Google Photos) → a rendered vertical short. Local or hosted. |
41
+ | `suggest_clips` / `swap_clip` | See the cutaways; swap one for better-fitting B-roll (with a `reason`). |
42
+ | `trim_clip` / `remove_span` / `reorder_scenes` | Granular timeline edits — instant, no re-render. |
43
+ | `render_project` | Bake captions/overlays into the final MP4 (the one render). |
44
+ | `publish_now` / `schedule_post` / `send_to_drafts` | Post to TikTok / Instagram / YouTube. |
45
+ | `generate_caption`, `create_kinetic_text`, carousels, image ads | The rest of the content pipeline. |
46
+
47
+ 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.
48
+
49
+ ## Open source
50
+
51
+ 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.
52
+
53
+ - Listed in the official **MCP registry**: `ai.viralspin/viralspin`
54
+ - Hosted connector (no install, for URL / Google Photos workflows): `https://mcp.viralspin.ai/mcp`
55
+
56
+ ## Local vs hosted
57
+
58
+ - **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.
59
+ - **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.
60
+
61
+ Either way, the agent reads local files *to upload them* (A-roll, a B-roll folder); only on-disk rendering needs the local engine.
62
+
63
+ ## License
64
+
65
+ MIT — see [LICENSE](./LICENSE).
package/dist/client.js CHANGED
@@ -5,22 +5,33 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.get = get;
7
7
  exports.post = post;
8
+ exports.patch = patch;
9
+ exports.pyGet = pyGet;
10
+ exports.pyPost = pyPost;
8
11
  exports.del = del;
9
12
  exports.uploadFiles = uploadFiles;
10
13
  exports.deleteFiles = deleteFiles;
11
- const form_data_1 = __importDefault(require("form-data"));
12
14
  const fs_1 = __importDefault(require("fs"));
13
15
  const path_1 = __importDefault(require("path"));
14
16
  const API_URL = (process.env.VIRALSPIN_API_URL || "https://viralspinbackend.onrender.com").replace(/\/$/, "");
17
+ // Python backend — Higgsfield, review-sessions, OAuth, narrative-render
18
+ const PY_API_URL = (process.env.VIRALSPIN_PY_API_URL || "https://viralspin-api.onrender.com").replace(/\/$/, "");
15
19
  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");
20
+ // OAuth token takes precedence over API key (set after "Sign in with ViralSpin" flow)
21
+ const OAUTH_TOKEN = process.env.VIRALSPIN_OAUTH_TOKEN || "";
22
+ if (!API_KEY && !OAUTH_TOKEN) {
23
+ process.stderr.write("[viralspin-mcp] Warning: Neither VIRALSPIN_API_KEY nor VIRALSPIN_OAUTH_TOKEN is set\n");
18
24
  }
19
25
  function headers(extra = {}) {
26
+ // OAuth Bearer token (from "Sign in with ViralSpin" flow) takes priority
27
+ if (OAUTH_TOKEN) {
28
+ return { "Authorization": `Bearer ${OAUTH_TOKEN}`, ...extra };
29
+ }
30
+ // Legacy API key — retained for backward compatibility with personal installs
20
31
  return { "X-API-Key": API_KEY, ...extra };
21
32
  }
22
- async function apiFetch(endpoint, init = {}) {
23
- const url = `${API_URL}${endpoint}`;
33
+ async function apiFetch(endpoint, init = {}, base = API_URL) {
34
+ const url = `${base}${endpoint}`;
24
35
  const resp = await fetch(url, {
25
36
  ...init,
26
37
  headers: { ...(init.headers ?? {}), ...headers() },
@@ -46,6 +57,24 @@ function post(endpoint, body) {
46
57
  body: JSON.stringify(body),
47
58
  });
48
59
  }
60
+ function patch(endpoint, body) {
61
+ return apiFetch(endpoint, {
62
+ method: "PATCH",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(body),
65
+ });
66
+ }
67
+ /** Python backend helpers — for routes that live in viralspin-api (Higgsfield, OAuth, etc.) */
68
+ function pyGet(endpoint) {
69
+ return apiFetch(endpoint, { method: "GET" }, PY_API_URL);
70
+ }
71
+ function pyPost(endpoint, body) {
72
+ return apiFetch(endpoint, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify(body),
76
+ }, PY_API_URL);
77
+ }
49
78
  function del(endpoint, body) {
50
79
  return apiFetch(endpoint, {
51
80
  method: "DELETE",
@@ -55,15 +84,32 @@ function del(endpoint, body) {
55
84
  }
56
85
  /** Upload one or more local files. Returns { url, storage_path } or { urls, storage_paths } */
57
86
  async function uploadFiles(filePaths) {
58
- const form = new form_data_1.default();
87
+ // Use the native Web FormData + Blob (Node 18+). The npm 'form-data' package
88
+ // streams in a way native fetch can't serialize correctly, which previously
89
+ // caused Busboy on the backend to fail with "Failed to parse upload".
90
+ const form = new FormData();
59
91
  const fieldName = filePaths.length === 1 ? "file" : "files";
60
92
  for (const fp of filePaths) {
61
- form.append(fieldName, fs_1.default.createReadStream(fp), path_1.default.basename(fp));
93
+ const buffer = await fs_1.default.promises.readFile(fp);
94
+ const ext = path_1.default.extname(fp).toLowerCase();
95
+ const mime = ext === ".png" ? "image/png" :
96
+ ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
97
+ ext === ".gif" ? "image/gif" :
98
+ ext === ".webp" ? "image/webp" :
99
+ ext === ".mp4" ? "video/mp4" :
100
+ ext === ".mov" ? "video/quicktime" :
101
+ ext === ".webm" ? "video/webm" :
102
+ "application/octet-stream";
103
+ const blob = new Blob([buffer], { type: mime });
104
+ form.append(fieldName, blob, path_1.default.basename(fp));
62
105
  }
106
+ const authHeader = OAUTH_TOKEN
107
+ ? { "Authorization": `Bearer ${OAUTH_TOKEN}` }
108
+ : { "X-API-Key": API_KEY };
63
109
  const resp = await fetch(`${API_URL}/v1/media/upload`, {
64
110
  method: "POST",
65
- headers: { "X-API-Key": API_KEY, ...form.getHeaders() },
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ // Don't set Content-Type manually — fetch will set it with the correct multipart boundary.
112
+ headers: authHeader,
67
113
  body: form,
68
114
  });
69
115
  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,145 @@ 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 image_ads_js_1 = require("./tools/image-ads.js");
11
30
  const tools = [
12
31
  connections_js_1.listConnectionsTool,
13
32
  publish_js_1.publishNowTool,
14
33
  publish_js_1.schedulePostTool,
34
+ publish_js_1.sendToDraftsTool,
35
+ carousel_video_js_1.publishCarouselAsVideoTool,
36
+ video_assembly_js_1.createVideoAssemblyTool,
37
+ photo_picker_js_1.startPhotoPickerTool,
38
+ assembly_adjust_js_1.suggestClipsTool,
39
+ assembly_adjust_js_1.swapClipTool,
40
+ assembly_adjust_js_1.swapCaptionStyleTool,
41
+ timeline_edit_js_1.trimClipTool,
42
+ timeline_edit_js_1.removeSpanTool,
43
+ timeline_edit_js_1.reorderScenesTool,
44
+ timeline_edit_js_1.renderProjectTool,
45
+ timeline_edit_js_1.trimSilencesTool,
46
+ timeline_edit_js_1.getTranscriptTool,
47
+ timeline_edit_js_1.removeSpansTool,
48
+ timeline_edit_js_1.keepWindowsTool,
49
+ folder_preview_js_1.previewFolderTool,
50
+ find_moments_js_1.findMomentsTool,
51
+ local_mode_js_1.setLocalModeTool,
52
+ color_grade_js_1.colorGradeTool,
53
+ edit_clip_js_1.editRawClipTool,
54
+ relight_clip_js_1.relightClipTool,
55
+ ideas_js_1.queueIdeaTool,
56
+ ideas_js_1.listIdeasTool,
57
+ profile_js_1.setCreatorProfileTool,
58
+ profile_js_1.getCreatorProfileTool,
59
+ local_index_js_1.syncLocalIndexTool,
60
+ local_index_js_1.searchLocalIndexTool,
15
61
  posts_js_1.listPostsTool,
16
62
  posts_js_1.cancelPostTool,
17
63
  captions_js_1.generateCaptionTool,
64
+ engage_js_1.setKeywordLinkTool,
65
+ engage_js_1.listKeywordLinksTool,
66
+ engage_js_1.deleteKeywordLinkTool,
67
+ explainer_index_js_1.indexExplainerSourcesTool,
68
+ kinetic_text_js_1.createKineticTextTool,
69
+ review_js_1.createReviewLinkTool,
70
+ review_js_1.checkReviewStatusTool,
71
+ image_ads_js_1.prepareProductAssetTool,
72
+ image_ads_js_1.assembleImageAdTool,
73
+ image_ads_js_1.publishToMetaTool,
18
74
  ];
19
- const server = new index_js_1.Server({ name: "viralspin", version: "0.1.0" }, { capabilities: { tools: {} } });
75
+ // Shown to the user as the welcome / getting-started message. The agent fills in
76
+ // <picker link> by calling start_photo_picker (per ONBOARDING below).
77
+ const SAMPLE_COMMANDS = [
78
+ "Try one of these — I'll open a DRAFT in your ViralSpin editor for you to review side-by-side:",
79
+ "",
80
+ 'SIMPLE — a quick short:',
81
+ ' "Turn my talking-head at C:/clips/talk.mp4 into a 9:16 TikTok short — pull B-roll from',
82
+ ' C:/my-broll and my Google Photos, add captions, and show me the draft."',
83
+ "",
84
+ 'COMPREHENSIVE — hook + story + scheduled publish (the full loop):',
85
+ ' "Make a hosted reel. Open on the strongest surprise/reveal clip from C:/footage/hooks with the',
86
+ ' title \'Claude just killed video editors\', then tell the story of how AI is replacing video editors',
87
+ ' using the clips in C:/footage/story. Build a story arc that fits the footage, caption it, and show',
88
+ ' me the draft to review. Once I approve, schedule it to Instagram and TikTok next Tuesday at 8pm."',
89
+ ].join("\n");
90
+ const WELCOME = [
91
+ "Welcome to ViralSpin. I turn your footage into polished vertical shorts — B-roll matched to",
92
+ "what's said, captions, a hook headline — then PUBLISH or SCHEDULE them to TikTok, Instagram,",
93
+ "or YouTube. I open every project as a DRAFT in your web editor so you can watch it come together",
94
+ "and tweak it side-by-side. B-roll can be local files, a local folder (auto-indexed), URLs, or Google Photos.",
95
+ "",
96
+ SAMPLE_COMMANDS,
97
+ ].join("\n");
98
+ // Auto-injected into the agent's context when the server connects. Drives proactive onboarding.
99
+ const INSTRUCTIONS = [
100
+ "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.",
101
+ "",
102
+ "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):",
103
+ SAMPLE_COMMANDS,
104
+ "",
105
+ "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.",
106
+ "",
107
+ "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.",
108
+ "",
109
+ "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.",
110
+ "",
111
+ "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.",
112
+ "",
113
+ "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.",
114
+ "",
115
+ "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.",
116
+ "",
117
+ "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.",
118
+ "",
119
+ "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.",
120
+ ].join("\n");
121
+ const server = new index_js_1.Server({ name: "viralspin", version: "0.1.0" }, { capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS });
122
+ server.setRequestHandler(types_js_1.ListPromptsRequestSchema, async () => ({
123
+ prompts: [
124
+ {
125
+ name: "getting_started",
126
+ description: "Welcome + how to use ViralSpin: intro, a Google Photos B-roll picker link, and two sample commands.",
127
+ },
128
+ ],
129
+ }));
130
+ server.setRequestHandler(types_js_1.GetPromptRequestSchema, async (req) => {
131
+ if (req.params.name !== "getting_started") {
132
+ throw new Error(`Unknown prompt: ${req.params.name}`);
133
+ }
134
+ return {
135
+ messages: [
136
+ {
137
+ role: "user",
138
+ content: {
139
+ type: "text",
140
+ text: WELCOME +
141
+ "\n\nNow call start_photo_picker and include the returned Google Photos picker link in your reply, " +
142
+ "so I can pick B-roll. Keep the two sample commands above in your reply.",
143
+ },
144
+ },
145
+ ],
146
+ };
147
+ });
20
148
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
21
149
  tools: tools.map((t) => ({
22
150
  name: t.name,
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ // Strips unpaired UTF-16 surrogates from strings so MCP responses can be
3
+ // safely JSON-serialized and written to the stdio transport. Lone surrogates
4
+ // (e.g. an emoji's surrogate pair cut in half by a fixed-length .slice) cause
5
+ // the transport's UTF-8 encoder to throw "no low surrogate in string".
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.sanitizeSurrogates = sanitizeSurrogates;
8
+ exports.sanitizeObject = sanitizeObject;
9
+ const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
10
+ function sanitizeSurrogates(str) {
11
+ return str.replace(UNPAIRED_SURROGATE, "");
12
+ }
13
+ function sanitizeObject(obj) {
14
+ if (typeof obj === "string")
15
+ return sanitizeSurrogates(obj);
16
+ if (Array.isArray(obj))
17
+ return obj.map(sanitizeObject);
18
+ if (obj && typeof obj === "object") {
19
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, sanitizeObject(v)]));
20
+ }
21
+ return obj;
22
+ }