@mkterswingman/yt-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.
@@ -0,0 +1,393 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { PATHS } from "../utils/config.js";
5
+ import { runYtDlp } from "../utils/ytdlp.js";
6
+ import { hasSIDCookies } from "../utils/cookies.js";
7
+ import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
8
+ const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/yt-mcp setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
9
+ function toolOk(payload) {
10
+ return {
11
+ structuredContent: payload,
12
+ content: [
13
+ {
14
+ type: "text",
15
+ text: JSON.stringify(payload),
16
+ },
17
+ ],
18
+ };
19
+ }
20
+ function toolErr(code, message) {
21
+ const payload = { status: "failed", error: { code, message } };
22
+ return {
23
+ structuredContent: payload,
24
+ isError: true,
25
+ content: [{ type: "text", text: JSON.stringify(payload) }],
26
+ };
27
+ }
28
+ function sleep(ms) {
29
+ return new Promise((r) => setTimeout(r, ms));
30
+ }
31
+ function randomSleep(min, max) {
32
+ return sleep(Math.random() * (max - min) + min);
33
+ }
34
+ /**
35
+ * Convert VTT subtitle content to CSV format (timestamp, text).
36
+ * Each cue becomes one row: "HH:MM:SS.mmm --> HH:MM:SS.mmm","subtitle text"
37
+ */
38
+ function vttToCsv(vtt) {
39
+ const lines = vtt.split("\n");
40
+ const rows = ["timestamp,text"];
41
+ let currentTimestamp = "";
42
+ let currentText = [];
43
+ for (const line of lines) {
44
+ const trimmed = line.trim();
45
+ // Timestamp line: 00:00:01.000 --> 00:00:04.000
46
+ if (trimmed.includes(" --> ")) {
47
+ // Flush previous cue
48
+ if (currentTimestamp && currentText.length > 0) {
49
+ const text = currentText.join(" ").replace(/"/g, '""');
50
+ rows.push(`"${currentTimestamp}","${text}"`);
51
+ }
52
+ currentTimestamp = trimmed;
53
+ currentText = [];
54
+ }
55
+ else if (trimmed && !trimmed.startsWith("WEBVTT") && !trimmed.startsWith("Kind:") && !trimmed.startsWith("Language:") && !/^\d+$/.test(trimmed)) {
56
+ // Strip HTML tags from subtitle text
57
+ const clean = trimmed.replace(/<[^>]*>/g, "");
58
+ if (clean)
59
+ currentText.push(clean);
60
+ }
61
+ }
62
+ // Flush last cue
63
+ if (currentTimestamp && currentText.length > 0) {
64
+ const text = currentText.join(" ").replace(/"/g, '""');
65
+ rows.push(`"${currentTimestamp}","${text}"`);
66
+ }
67
+ return rows.join("\n") + "\n";
68
+ }
69
+ async function downloadSubtitle(videoId, lang, format) {
70
+ mkdirSync(PATHS.subtitlesDir, { recursive: true });
71
+ const outTemplate = join(PATHS.subtitlesDir, `${videoId}_${lang}`);
72
+ // CSV is not a yt-dlp native format — download as VTT then convert
73
+ const dlFormat = format === "csv" ? "vtt" : format;
74
+ const result = await runYtDlp([
75
+ "--skip-download",
76
+ "--write-sub",
77
+ "--write-auto-sub",
78
+ "--sub-langs",
79
+ lang,
80
+ "--sub-format",
81
+ dlFormat,
82
+ "--output",
83
+ outTemplate,
84
+ `https://www.youtube.com/watch?v=${videoId}`,
85
+ ]);
86
+ if (result.exitCode !== 0 &&
87
+ (result.stderr.includes("Sign in") ||
88
+ result.stderr.includes("cookies") ||
89
+ result.stderr.includes("login"))) {
90
+ return { ok: false, cookiesExpired: true, error: "COOKIES_EXPIRED" };
91
+ }
92
+ if (result.exitCode !== 0) {
93
+ return {
94
+ ok: false,
95
+ error: result.stderr.slice(0, 500) || `yt-dlp exited with ${result.exitCode}`,
96
+ };
97
+ }
98
+ // Find the output file - yt-dlp appends lang and format extension
99
+ const possibleExts = [`${lang}.${format}`, `${lang}.vtt`, `${lang}.srt`, `${lang}.ttml`, `${lang}.srv3`];
100
+ let foundFile;
101
+ for (const ext of possibleExts) {
102
+ const candidate = `${outTemplate}.${ext}`;
103
+ if (existsSync(candidate)) {
104
+ foundFile = candidate;
105
+ break;
106
+ }
107
+ }
108
+ if (!foundFile) {
109
+ // Try auto-generated subtitles which may have slightly different naming
110
+ const dir = PATHS.subtitlesDir;
111
+ try {
112
+ const files = readdirSync(dir);
113
+ const prefix = `${videoId}_${lang}`;
114
+ const match = files.find((f) => f.startsWith(prefix));
115
+ if (match) {
116
+ foundFile = join(dir, match);
117
+ }
118
+ }
119
+ catch {
120
+ // ignore
121
+ }
122
+ }
123
+ if (!foundFile) {
124
+ return {
125
+ ok: false,
126
+ error: `No subtitle file found for language '${lang}'`,
127
+ };
128
+ }
129
+ // If CSV format requested, convert VTT to CSV (timestamp, text)
130
+ if (format === "csv") {
131
+ const vttContent = readFileSync(foundFile, "utf8");
132
+ const csvContent = vttToCsv(vttContent);
133
+ const csvPath = foundFile.replace(/\.vtt$/, ".csv");
134
+ writeFileSync(csvPath, csvContent, "utf8");
135
+ foundFile = csvPath;
136
+ }
137
+ const stat = statSync(foundFile);
138
+ if (stat.size <= 100 * 1024) {
139
+ const text = readFileSync(foundFile, "utf8");
140
+ return { ok: true, text, filePath: foundFile };
141
+ }
142
+ return { ok: true, filePath: foundFile };
143
+ }
144
+ export function registerSubtitleTools(server, config, tokenManager) {
145
+ /**
146
+ * Auth guard — every tool call must pass authentication.
147
+ * Both OAuth (JWT) and PAT modes are supported.
148
+ * Returns error message if not authenticated.
149
+ */
150
+ async function requireAuth() {
151
+ const token = await tokenManager.getValidToken();
152
+ if (!token)
153
+ return AUTH_REQUIRED_MSG;
154
+ return null;
155
+ }
156
+ // ── get_subtitles ──
157
+ server.registerTool("get_subtitles", {
158
+ description: "Download subtitles for a YouTube video. Accepts video ID or any YouTube URL (watch, shorts, youtu.be). Each language is fetched separately. Supports CSV output (timestamp + text columns).",
159
+ inputSchema: {
160
+ video: z.string().min(1).describe("YouTube video ID or URL"),
161
+ languages: z.array(z.string().min(1)).optional(),
162
+ format: z.enum(["vtt", "srt", "ttml", "srv3", "csv"]).optional(),
163
+ },
164
+ }, async ({ video, languages, format }) => {
165
+ const authErr = await requireAuth();
166
+ if (authErr)
167
+ return toolErr("AUTH_REQUIRED", authErr);
168
+ const videoId = resolveVideoInput(video);
169
+ if (!videoId)
170
+ return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
171
+ const langs = languages ?? config.default_languages;
172
+ const fmt = format ?? "vtt";
173
+ const results = [];
174
+ for (let i = 0; i < langs.length; i++) {
175
+ if (i > 0) {
176
+ await randomSleep(2000, 5000);
177
+ }
178
+ const lang = langs[i];
179
+ const dl = await downloadSubtitle(videoId, lang, fmt);
180
+ if (dl.cookiesExpired) {
181
+ return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired. Run: npx @mkterswingman/yt-mcp setup-cookies");
182
+ }
183
+ if (!dl.ok) {
184
+ results.push({
185
+ language: lang,
186
+ status: "failed",
187
+ error: dl.error,
188
+ });
189
+ }
190
+ else if (dl.text) {
191
+ results.push({
192
+ language: lang,
193
+ status: "ok",
194
+ text: dl.text,
195
+ file_path: dl.filePath,
196
+ });
197
+ }
198
+ else {
199
+ results.push({
200
+ language: lang,
201
+ status: "ok",
202
+ file_path: dl.filePath,
203
+ });
204
+ }
205
+ }
206
+ return toolOk({
207
+ status: "completed",
208
+ video_id: videoId,
209
+ format: fmt,
210
+ results,
211
+ });
212
+ });
213
+ // ── batch_get_subtitles ──
214
+ server.registerTool("batch_get_subtitles", {
215
+ description: "Download subtitles for multiple YouTube videos. Max 10 per batch. Accepts video IDs or URLs (can mix).",
216
+ inputSchema: {
217
+ videos: z.array(z.string().min(1)).min(1).max(10).describe("Video IDs or YouTube URLs"),
218
+ languages: z.array(z.string().min(1)).optional(),
219
+ format: z.enum(["vtt", "srt", "ttml", "srv3", "csv"]).optional(),
220
+ },
221
+ }, async ({ videos, languages, format }) => {
222
+ const authErr = await requireAuth();
223
+ if (authErr)
224
+ return toolErr("AUTH_REQUIRED", authErr);
225
+ const { resolvedIds, invalidInputs } = normalizeVideoInputs(videos);
226
+ if (resolvedIds.length === 0) {
227
+ return toolErr("INVALID_INPUT", `无法解析任何视频 ID。无效输入: ${invalidInputs.join(", ")}`);
228
+ }
229
+ const langs = languages ?? config.default_languages;
230
+ const fmt = format ?? "vtt";
231
+ // Cookie pre-check
232
+ if (!hasSIDCookies(PATHS.cookiesTxt)) {
233
+ return toolErr("COOKIES_MISSING", "No valid YouTube cookies found. Run: npx @mkterswingman/yt-mcp setup-cookies");
234
+ }
235
+ const results = [];
236
+ let succeeded = 0;
237
+ let failed = 0;
238
+ for (let v = 0; v < resolvedIds.length; v++) {
239
+ if (v > 0) {
240
+ await randomSleep(config.batch_sleep_min_ms, config.batch_sleep_max_ms);
241
+ }
242
+ const videoId = resolvedIds[v];
243
+ const langFound = [];
244
+ let lastError;
245
+ let cookiesExpired = false;
246
+ for (let l = 0; l < langs.length; l++) {
247
+ if (l > 0) {
248
+ await randomSleep(2000, 5000);
249
+ }
250
+ const dl = await downloadSubtitle(videoId, langs[l], fmt);
251
+ if (dl.cookiesExpired) {
252
+ cookiesExpired = true;
253
+ break;
254
+ }
255
+ if (dl.ok) {
256
+ langFound.push(langs[l]);
257
+ }
258
+ else {
259
+ lastError = dl.error;
260
+ }
261
+ }
262
+ if (cookiesExpired) {
263
+ return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired. Run: npx @mkterswingman/yt-mcp setup-cookies");
264
+ }
265
+ if (langFound.length > 0) {
266
+ succeeded++;
267
+ results.push({
268
+ video_id: videoId,
269
+ status: "ok",
270
+ languages_found: langFound,
271
+ file_path: PATHS.subtitlesDir,
272
+ });
273
+ }
274
+ else {
275
+ failed++;
276
+ results.push({
277
+ video_id: videoId,
278
+ status: "failed",
279
+ error: lastError ?? "No subtitles found",
280
+ });
281
+ }
282
+ }
283
+ return toolOk({
284
+ status: "completed",
285
+ total: resolvedIds.length,
286
+ succeeded,
287
+ failed,
288
+ invalid_inputs: invalidInputs.length > 0 ? invalidInputs : undefined,
289
+ results,
290
+ });
291
+ });
292
+ // ── list_available_subtitles ──
293
+ server.registerTool("list_available_subtitles", {
294
+ description: "List available subtitle tracks for a YouTube video. Accepts video ID or URL.",
295
+ inputSchema: {
296
+ video: z.string().min(1).describe("YouTube video ID or URL"),
297
+ },
298
+ }, async ({ video }) => {
299
+ const authErr = await requireAuth();
300
+ if (authErr)
301
+ return toolErr("AUTH_REQUIRED", authErr);
302
+ const videoId = resolveVideoInput(video);
303
+ if (!videoId)
304
+ return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
305
+ const result = await runYtDlp([
306
+ "--list-subs",
307
+ "--skip-download",
308
+ `https://www.youtube.com/watch?v=${videoId}`,
309
+ ]);
310
+ if (result.exitCode !== 0) {
311
+ return toolErr("YT_DLP_ERROR", result.stderr.slice(0, 500) || "Failed to list subtitles");
312
+ }
313
+ const output = result.stdout;
314
+ const manual = [];
315
+ const automatic = [];
316
+ let section = "none";
317
+ for (const line of output.split("\n")) {
318
+ const trimmed = line.trim();
319
+ if (trimmed.includes("Available subtitles") ||
320
+ trimmed.includes("manual subtitles")) {
321
+ section = "manual";
322
+ continue;
323
+ }
324
+ if (trimmed.includes("Available automatic captions") ||
325
+ trimmed.includes("automatic captions")) {
326
+ section = "auto";
327
+ continue;
328
+ }
329
+ if (!trimmed || trimmed.startsWith("Language") || trimmed.startsWith("---")) {
330
+ continue;
331
+ }
332
+ const langCode = trimmed.split(/\s+/)[0];
333
+ if (!langCode)
334
+ continue;
335
+ if (section === "manual")
336
+ manual.push(langCode);
337
+ else if (section === "auto")
338
+ automatic.push(langCode);
339
+ }
340
+ return toolOk({
341
+ status: "completed",
342
+ video_id: videoId,
343
+ manual,
344
+ automatic,
345
+ translatable: automatic,
346
+ });
347
+ });
348
+ // ── validate_cookies ──
349
+ server.registerTool("validate_cookies", {
350
+ description: "Validate current YouTube cookies by testing a subtitle download.",
351
+ inputSchema: {},
352
+ }, async () => {
353
+ const authErr = await requireAuth();
354
+ if (authErr)
355
+ return toolErr("AUTH_REQUIRED", authErr);
356
+ if (!existsSync(PATHS.cookiesTxt)) {
357
+ return toolOk({
358
+ valid: false,
359
+ error: "cookies.txt not found",
360
+ });
361
+ }
362
+ if (!hasSIDCookies(PATHS.cookiesTxt)) {
363
+ return toolOk({
364
+ valid: false,
365
+ error: "cookies.txt does not contain YouTube SID cookies",
366
+ });
367
+ }
368
+ // Test with a known public video (Rick Astley - Never Gonna Give You Up)
369
+ const result = await runYtDlp([
370
+ "--skip-download",
371
+ "--write-auto-sub",
372
+ "--sub-langs",
373
+ "en",
374
+ "--sub-format",
375
+ "vtt",
376
+ "--output",
377
+ join(PATHS.subtitlesDir, "cookie_test"),
378
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
379
+ ]);
380
+ if (result.exitCode === 0) {
381
+ return toolOk({ valid: true });
382
+ }
383
+ const isExpired = result.stderr.includes("Sign in") ||
384
+ result.stderr.includes("cookies") ||
385
+ result.stderr.includes("login");
386
+ return toolOk({
387
+ valid: false,
388
+ error: isExpired
389
+ ? "Cookies expired"
390
+ : result.stderr.slice(0, 300),
391
+ });
392
+ });
393
+ }
@@ -0,0 +1,21 @@
1
+ export declare const PATHS: {
2
+ configDir: string;
3
+ authJson: string;
4
+ cookiesTxt: string;
5
+ browserProfile: string;
6
+ subtitlesDir: string;
7
+ configJson: string;
8
+ };
9
+ export interface YtMcpConfig {
10
+ /** Auth gateway root URL — for OAuth, PAT, introspection (e.g., https://mkterswingman.com) */
11
+ auth_url: string;
12
+ /** v2 API URL — for data tools (e.g., https://mkterswingman.com/mcp/yt) */
13
+ api_url: string;
14
+ default_languages: string[];
15
+ batch_sleep_min_ms: number;
16
+ batch_sleep_max_ms: number;
17
+ batch_max_size: number;
18
+ }
19
+ export declare function ensureConfigDir(): void;
20
+ export declare function loadConfig(): YtMcpConfig;
21
+ export declare function saveConfig(config: Partial<YtMcpConfig>): void;
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".yt-mcp");
5
+ export const PATHS = {
6
+ configDir: CONFIG_DIR,
7
+ authJson: join(CONFIG_DIR, "auth.json"),
8
+ cookiesTxt: join(CONFIG_DIR, "cookies.txt"),
9
+ browserProfile: join(CONFIG_DIR, "browser-profile"),
10
+ subtitlesDir: join(CONFIG_DIR, "subtitles"),
11
+ configJson: join(CONFIG_DIR, "config.json"),
12
+ };
13
+ const DEFAULTS = {
14
+ auth_url: "https://mkterswingman.com",
15
+ api_url: "https://mkterswingman.com/mcp/yt",
16
+ default_languages: ["en"],
17
+ batch_sleep_min_ms: 3000,
18
+ batch_sleep_max_ms: 8000,
19
+ batch_max_size: 10,
20
+ };
21
+ export function ensureConfigDir() {
22
+ mkdirSync(CONFIG_DIR, { recursive: true });
23
+ }
24
+ export function loadConfig() {
25
+ ensureConfigDir();
26
+ if (!existsSync(PATHS.configJson)) {
27
+ return { ...DEFAULTS };
28
+ }
29
+ try {
30
+ const raw = readFileSync(PATHS.configJson, "utf8");
31
+ const parsed = JSON.parse(raw);
32
+ return { ...DEFAULTS, ...parsed };
33
+ }
34
+ catch {
35
+ return { ...DEFAULTS };
36
+ }
37
+ }
38
+ export function saveConfig(config) {
39
+ ensureConfigDir();
40
+ const existing = loadConfig();
41
+ const merged = { ...existing, ...config };
42
+ writeFileSync(PATHS.configJson, JSON.stringify(merged, null, 2), "utf8");
43
+ }
@@ -0,0 +1,11 @@
1
+ export interface CookieEntry {
2
+ name: string;
3
+ value: string;
4
+ domain: string;
5
+ path: string;
6
+ secure: boolean;
7
+ httpOnly: boolean;
8
+ expires: number;
9
+ }
10
+ export declare function cookiesToNetscape(cookies: CookieEntry[]): string;
11
+ export declare function hasSIDCookies(cookiesPath: string): boolean;
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ export function cookiesToNetscape(cookies) {
3
+ const lines = [
4
+ "# Netscape HTTP Cookie File",
5
+ "# https://curl.haxx.se/rfc/cookie_spec.html",
6
+ "# This is a generated file! Do not edit.",
7
+ "",
8
+ ];
9
+ for (const c of cookies) {
10
+ const domain = c.domain.startsWith(".") ? c.domain : c.domain;
11
+ const includeSubdomains = c.domain.startsWith(".") ? "TRUE" : "FALSE";
12
+ const secure = c.secure ? "TRUE" : "FALSE";
13
+ const expiry = Math.floor(c.expires);
14
+ lines.push(`${domain}\t${includeSubdomains}\t${c.path}\t${secure}\t${expiry}\t${c.name}\t${c.value}`);
15
+ }
16
+ return lines.join("\n") + "\n";
17
+ }
18
+ export function hasSIDCookies(cookiesPath) {
19
+ if (!existsSync(cookiesPath))
20
+ return false;
21
+ try {
22
+ const content = readFileSync(cookiesPath, "utf8");
23
+ return content.includes("SID") && content.includes(".youtube.com");
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
@@ -0,0 +1,5 @@
1
+ export declare function resolveVideoInput(input: string): string | null;
2
+ export declare function normalizeVideoInputs(inputs: string[]): {
3
+ resolvedIds: string[];
4
+ invalidInputs: string[];
5
+ };
@@ -0,0 +1,55 @@
1
+ const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
2
+ function toValidVideoId(value) {
3
+ const v = value.trim();
4
+ return VIDEO_ID_RE.test(v) ? v : null;
5
+ }
6
+ function extractFromUrl(raw) {
7
+ let url;
8
+ try {
9
+ url = new URL(raw.trim());
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ const host = url.hostname.toLowerCase();
15
+ const normalizedHost = host.startsWith("www.") ? host.slice(4) : host;
16
+ if (normalizedHost === "youtu.be") {
17
+ const id = url.pathname.split("/").filter(Boolean)[0] ?? "";
18
+ return toValidVideoId(id);
19
+ }
20
+ if (normalizedHost === "youtube.com" || normalizedHost === "m.youtube.com") {
21
+ if (url.pathname === "/watch") {
22
+ return toValidVideoId(url.searchParams.get("v") ?? "");
23
+ }
24
+ const parts = url.pathname.split("/").filter(Boolean);
25
+ if (parts.length >= 2 && ["shorts", "live", "embed"].includes(parts[0])) {
26
+ return toValidVideoId(parts[1]);
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ export function resolveVideoInput(input) {
32
+ const candidate = input.trim();
33
+ if (!candidate) {
34
+ return null;
35
+ }
36
+ const directId = toValidVideoId(candidate);
37
+ return directId ?? extractFromUrl(candidate);
38
+ }
39
+ export function normalizeVideoInputs(inputs) {
40
+ const resolvedIds = [];
41
+ const invalidInputs = [];
42
+ const seen = new Set();
43
+ for (const raw of inputs) {
44
+ const parsedId = resolveVideoInput(raw);
45
+ if (!parsedId) {
46
+ invalidInputs.push(raw);
47
+ continue;
48
+ }
49
+ if (!seen.has(parsedId)) {
50
+ seen.add(parsedId);
51
+ resolvedIds.push(parsedId);
52
+ }
53
+ }
54
+ return { resolvedIds, invalidInputs };
55
+ }
@@ -0,0 +1,7 @@
1
+ export interface YtDlpResult {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ durationMs: number;
6
+ }
7
+ export declare function runYtDlp(args: string[], timeoutMs?: number): Promise<YtDlpResult>;
@@ -0,0 +1,51 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { PATHS } from "./config.js";
4
+ export function runYtDlp(args, timeoutMs = 45_000) {
5
+ return new Promise((resolve, reject) => {
6
+ const start = Date.now();
7
+ const finalArgs = ["--force-ipv4", "--no-warnings", ...args];
8
+ if (existsSync(PATHS.cookiesTxt)) {
9
+ finalArgs.push("--cookies", PATHS.cookiesTxt);
10
+ }
11
+ const proc = spawn("yt-dlp", finalArgs, {
12
+ stdio: ["ignore", "pipe", "pipe"],
13
+ });
14
+ const stdoutChunks = [];
15
+ const stderrChunks = [];
16
+ proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
17
+ proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
18
+ let settled = false;
19
+ const timer = setTimeout(() => {
20
+ if (!settled) {
21
+ settled = true;
22
+ proc.kill("SIGKILL");
23
+ resolve({
24
+ exitCode: -1,
25
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
26
+ stderr: `yt-dlp timed out after ${timeoutMs}ms`,
27
+ durationMs: Date.now() - start,
28
+ });
29
+ }
30
+ }, timeoutMs);
31
+ proc.on("close", (code) => {
32
+ clearTimeout(timer);
33
+ if (!settled) {
34
+ settled = true;
35
+ resolve({
36
+ exitCode: code ?? 1,
37
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
38
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
39
+ durationMs: Date.now() - start,
40
+ });
41
+ }
42
+ });
43
+ proc.on("error", (err) => {
44
+ clearTimeout(timer);
45
+ if (!settled) {
46
+ settled = true;
47
+ reject(err);
48
+ }
49
+ });
50
+ });
51
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@mkterswingman/yt-mcp",
3
+ "version": "0.1.0",
4
+ "description": "YouTube MCP client — local subtitles + remote API proxy",
5
+ "type": "module",
6
+ "bin": {
7
+ "yt-mcp": "./dist/cli/index.js"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/server.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "dev": "tsx src/cli/index.ts",
15
+ "start": "node dist/cli/index.js"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.27.1",
19
+ "zod": "^4.3.6"
20
+ },
21
+ "optionalDependencies": {
22
+ "playwright": "^1.58.0",
23
+ "puppeteer-core": "^24.0.0"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "youtube",
28
+ "subtitles",
29
+ "yt-dlp"
30
+ ],
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "files": [
36
+ "dist/",
37
+ "README.md"
38
+ ],
39
+ "devDependencies": {
40
+ "typescript": "^5.9.3"
41
+ }
42
+ }