@mkterswingman/yt-mcp 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/oauthFlow.js +12 -2
- package/dist/cli/setup.js +27 -6
- package/dist/tools/subtitles.js +10 -6
- package/dist/utils/config.js +1 -1
- package/dist/utils/ytdlp.js +2 -1
- package/dist/utils/ytdlpPath.d.ts +22 -0
- package/dist/utils/ytdlpPath.js +64 -0
- package/package.json +4 -2
- package/scripts/download-ytdlp.mjs +111 -0
package/dist/auth/oauthFlow.js
CHANGED
|
@@ -9,9 +9,10 @@ function base64url(buf) {
|
|
|
9
9
|
.replace(/=+$/, "");
|
|
10
10
|
}
|
|
11
11
|
export async function runOAuthFlow(authUrl) {
|
|
12
|
-
// 1. Generate PKCE
|
|
12
|
+
// 1. Generate PKCE + state
|
|
13
13
|
const codeVerifier = base64url(randomBytes(32));
|
|
14
14
|
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
|
|
15
|
+
const state = base64url(randomBytes(32));
|
|
15
16
|
// 2. Start temp HTTP server to get the actual port for redirect_uri
|
|
16
17
|
const { server: httpServer, port } = await startCallbackServer();
|
|
17
18
|
const redirectUri = `http://127.0.0.1:${port}`;
|
|
@@ -55,6 +56,7 @@ export async function runOAuthFlow(authUrl) {
|
|
|
55
56
|
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
56
57
|
const code = url.searchParams.get("code");
|
|
57
58
|
const error = url.searchParams.get("error");
|
|
59
|
+
const returnedState = url.searchParams.get("state");
|
|
58
60
|
if (error) {
|
|
59
61
|
const safeError = error.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
60
62
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -68,6 +70,14 @@ export async function runOAuthFlow(authUrl) {
|
|
|
68
70
|
res.end("<h1>Waiting for authorization...</h1>");
|
|
69
71
|
return;
|
|
70
72
|
}
|
|
73
|
+
// Verify state to prevent CSRF
|
|
74
|
+
if (returnedState !== state) {
|
|
75
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
76
|
+
res.end("<h1>Authorization failed</h1><p>State mismatch — possible CSRF attack.</p>");
|
|
77
|
+
cleanup();
|
|
78
|
+
reject(new Error("OAuth state mismatch"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
71
81
|
// Exchange code for tokens
|
|
72
82
|
const tokenBody = {
|
|
73
83
|
grant_type: "authorization_code",
|
|
@@ -111,7 +121,7 @@ export async function runOAuthFlow(authUrl) {
|
|
|
111
121
|
}
|
|
112
122
|
});
|
|
113
123
|
// Open browser
|
|
114
|
-
const authorizeUrl = `${authUrl}/oauth/authorize?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${encodeURIComponent(codeChallenge)}&code_challenge_method=S256`;
|
|
124
|
+
const authorizeUrl = `${authUrl}/oauth/authorize?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${encodeURIComponent(codeChallenge)}&code_challenge_method=S256&state=${encodeURIComponent(state)}`;
|
|
115
125
|
console.log("\n\x1b[1mOpen this URL in your browser to authorize:\x1b[0m");
|
|
116
126
|
console.log(`\n ${authorizeUrl}\n`);
|
|
117
127
|
import("node:child_process").then(({ exec }) => {
|
package/dist/cli/setup.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
import { loadConfig, saveConfig, PATHS, ensureConfigDir } from "../utils/config.js";
|
|
4
6
|
import { TokenManager } from "../auth/tokenManager.js";
|
|
5
7
|
import { runOAuthFlow } from "../auth/oauthFlow.js";
|
|
6
8
|
import { hasSIDCookies } from "../utils/cookies.js";
|
|
9
|
+
import { getYtDlpVersion } from "../utils/ytdlpPath.js";
|
|
7
10
|
function detectCli(name) {
|
|
8
11
|
try {
|
|
9
12
|
execSync(`${name} --version`, { stdio: "pipe" });
|
|
@@ -77,13 +80,31 @@ export async function runSetup() {
|
|
|
77
80
|
}
|
|
78
81
|
// ── Step 1: Check yt-dlp ──
|
|
79
82
|
console.log("Step 1/5: Checking yt-dlp...");
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
console.log(` ✅ yt-dlp ${
|
|
83
|
+
const ytdlpInfo = getYtDlpVersion();
|
|
84
|
+
if (ytdlpInfo) {
|
|
85
|
+
console.log(` ✅ yt-dlp ${ytdlpInfo.version} (${ytdlpInfo.source})`);
|
|
83
86
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
console.log("
|
|
87
|
+
else {
|
|
88
|
+
// Try running the postinstall download script
|
|
89
|
+
console.log(" ⏳ yt-dlp not found, attempting download...");
|
|
90
|
+
try {
|
|
91
|
+
execSync("node scripts/download-ytdlp.mjs", {
|
|
92
|
+
cwd: join(dirname(fileURLToPath(import.meta.url)), "..", ".."),
|
|
93
|
+
stdio: "inherit",
|
|
94
|
+
});
|
|
95
|
+
const retryInfo = getYtDlpVersion();
|
|
96
|
+
if (retryInfo) {
|
|
97
|
+
console.log(` ✅ yt-dlp ${retryInfo.version} (${retryInfo.source})`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.log(" ⚠️ yt-dlp download failed (subtitle features will be unavailable)");
|
|
101
|
+
console.log(" 💡 Install manually: https://github.com/yt-dlp/yt-dlp#installation");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
console.log(" ⚠️ yt-dlp not found (subtitle features will be unavailable)");
|
|
106
|
+
console.log(" 💡 Install: https://github.com/yt-dlp/yt-dlp#installation");
|
|
107
|
+
}
|
|
87
108
|
}
|
|
88
109
|
// ── Step 2: Config ──
|
|
89
110
|
console.log("Step 2/5: Initializing config...");
|
package/dist/tools/subtitles.js
CHANGED
|
@@ -157,7 +157,7 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
157
157
|
}
|
|
158
158
|
// ── get_subtitles ──
|
|
159
159
|
server.registerTool("get_subtitles", {
|
|
160
|
-
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).",
|
|
160
|
+
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). If languages is omitted, defaults to English + Simplified Chinese (unavailable languages are silently skipped).",
|
|
161
161
|
inputSchema: {
|
|
162
162
|
video: z.string().min(1).describe("YouTube video ID or URL"),
|
|
163
163
|
languages: z.array(z.string().min(1)).optional(),
|
|
@@ -170,6 +170,7 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
170
170
|
const videoId = resolveVideoInput(video);
|
|
171
171
|
if (!videoId)
|
|
172
172
|
return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
|
|
173
|
+
const usingDefaults = !languages;
|
|
173
174
|
const langs = languages ?? config.default_languages;
|
|
174
175
|
const fmt = format ?? "vtt";
|
|
175
176
|
const results = [];
|
|
@@ -183,11 +184,14 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
183
184
|
return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired. Run: npx @mkterswingman/yt-mcp setup-cookies");
|
|
184
185
|
}
|
|
185
186
|
if (!dl.ok) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
// When using default languages, silently skip unavailable ones
|
|
188
|
+
if (!usingDefaults) {
|
|
189
|
+
results.push({
|
|
190
|
+
language: lang,
|
|
191
|
+
status: "failed",
|
|
192
|
+
error: dl.error,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
191
195
|
}
|
|
192
196
|
else if (dl.text) {
|
|
193
197
|
results.push({
|
package/dist/utils/config.js
CHANGED
|
@@ -13,7 +13,7 @@ export const PATHS = {
|
|
|
13
13
|
const DEFAULTS = {
|
|
14
14
|
auth_url: "https://mkterswingman.com",
|
|
15
15
|
api_url: "https://mkterswingman.com/mcp/yt",
|
|
16
|
-
default_languages: ["en"],
|
|
16
|
+
default_languages: ["en", "zh-Hans"],
|
|
17
17
|
batch_sleep_min_ms: 3000,
|
|
18
18
|
batch_sleep_max_ms: 8000,
|
|
19
19
|
batch_max_size: 10,
|
package/dist/utils/ytdlp.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { PATHS } from "./config.js";
|
|
4
|
+
import { getYtDlpPath } from "./ytdlpPath.js";
|
|
4
5
|
export function runYtDlp(args, timeoutMs = 45_000) {
|
|
5
6
|
return new Promise((resolve, reject) => {
|
|
6
7
|
const start = Date.now();
|
|
@@ -8,7 +9,7 @@ export function runYtDlp(args, timeoutMs = 45_000) {
|
|
|
8
9
|
if (existsSync(PATHS.cookiesTxt)) {
|
|
9
10
|
finalArgs.push("--cookies", PATHS.cookiesTxt);
|
|
10
11
|
}
|
|
11
|
-
const proc = spawn(
|
|
12
|
+
const proc = spawn(getYtDlpPath(), finalArgs, {
|
|
12
13
|
stdio: ["ignore", "pipe", "pipe"],
|
|
13
14
|
});
|
|
14
15
|
const stdoutChunks = [];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the yt-dlp binary path.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. YT_DLP_PATH env var (explicit override)
|
|
6
|
+
* 2. Bundled binary at <pkg>/bin/yt-dlp (downloaded by postinstall)
|
|
7
|
+
* 3. "yt-dlp" — fall back to system PATH
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Returns the absolute path to the yt-dlp binary, or the bare command name
|
|
11
|
+
* "yt-dlp" if only available on system PATH.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getYtDlpPath(): string;
|
|
14
|
+
export interface YtDlpVersionInfo {
|
|
15
|
+
version: string;
|
|
16
|
+
source: "bundled" | "system" | "env";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get the version string and source of the resolved yt-dlp binary.
|
|
20
|
+
* Returns null if yt-dlp is not available at all.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getYtDlpVersion(binPath?: string): YtDlpVersionInfo | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the yt-dlp binary path.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. YT_DLP_PATH env var (explicit override)
|
|
6
|
+
* 2. Bundled binary at <pkg>/bin/yt-dlp (downloaded by postinstall)
|
|
7
|
+
* 3. "yt-dlp" — fall back to system PATH
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
// From dist/utils/ → package root is ../../
|
|
15
|
+
const pkgRoot = join(__dirname, "..", "..");
|
|
16
|
+
function bundledPath() {
|
|
17
|
+
const name = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
|
|
18
|
+
return join(pkgRoot, "bin", name);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns the absolute path to the yt-dlp binary, or the bare command name
|
|
22
|
+
* "yt-dlp" if only available on system PATH.
|
|
23
|
+
*/
|
|
24
|
+
export function getYtDlpPath() {
|
|
25
|
+
// 1. Explicit env override
|
|
26
|
+
const envPath = process.env.YT_DLP_PATH;
|
|
27
|
+
if (envPath && existsSync(envPath))
|
|
28
|
+
return envPath;
|
|
29
|
+
// 2. Bundled binary
|
|
30
|
+
const bundled = bundledPath();
|
|
31
|
+
if (existsSync(bundled))
|
|
32
|
+
return bundled;
|
|
33
|
+
// 3. System PATH
|
|
34
|
+
return "yt-dlp";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get the version string and source of the resolved yt-dlp binary.
|
|
38
|
+
* Returns null if yt-dlp is not available at all.
|
|
39
|
+
*/
|
|
40
|
+
export function getYtDlpVersion(binPath) {
|
|
41
|
+
const resolved = binPath ?? getYtDlpPath();
|
|
42
|
+
let source;
|
|
43
|
+
if (process.env.YT_DLP_PATH && resolved === process.env.YT_DLP_PATH) {
|
|
44
|
+
source = "env";
|
|
45
|
+
}
|
|
46
|
+
else if (resolved === bundledPath()) {
|
|
47
|
+
source = "bundled";
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
source = "system";
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const ver = execFileSync(resolved, ["--version"], {
|
|
54
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
55
|
+
timeout: 10_000,
|
|
56
|
+
})
|
|
57
|
+
.toString()
|
|
58
|
+
.trim();
|
|
59
|
+
return { version: ver, source };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mkterswingman/yt-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "YouTube MCP client — local subtitles + remote API proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc -p tsconfig.json",
|
|
14
14
|
"dev": "tsx src/cli/index.ts",
|
|
15
|
-
"start": "node dist/cli/index.js"
|
|
15
|
+
"start": "node dist/cli/index.js",
|
|
16
|
+
"postinstall": "node scripts/download-ytdlp.mjs"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
36
37
|
"dist/",
|
|
38
|
+
"scripts/download-ytdlp.mjs",
|
|
37
39
|
"README.md"
|
|
38
40
|
],
|
|
39
41
|
"devDependencies": {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* postinstall script — downloads yt-dlp binary for the current platform.
|
|
5
|
+
*
|
|
6
|
+
* Runs automatically on `npm install`. Uses only Node.js built-ins (no deps).
|
|
7
|
+
*
|
|
8
|
+
* Env overrides:
|
|
9
|
+
* YT_MCP_SKIP_YTDLP=1 — skip download entirely (CI / air-gapped)
|
|
10
|
+
* YT_MCP_YTDLP_MIRROR — custom base URL (default: GitHub releases)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createWriteStream, chmodSync, mkdirSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { pipeline } from "node:stream/promises";
|
|
17
|
+
|
|
18
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const YT_DLP_VERSION = "2026.03.17";
|
|
21
|
+
|
|
22
|
+
const BASE_URL =
|
|
23
|
+
process.env.YT_MCP_YTDLP_MIRROR ||
|
|
24
|
+
`https://github.com/yt-dlp/yt-dlp/releases/download/${YT_DLP_VERSION}`;
|
|
25
|
+
|
|
26
|
+
/** Map (platform, arch) → binary file name on GitHub Releases. */
|
|
27
|
+
const PLATFORM_MAP = {
|
|
28
|
+
"darwin-arm64": "yt-dlp_macos",
|
|
29
|
+
"darwin-x64": "yt-dlp_macos",
|
|
30
|
+
"linux-x64": "yt-dlp_linux",
|
|
31
|
+
"linux-arm64": "yt-dlp_linux_aarch64",
|
|
32
|
+
"win32-x64": "yt-dlp.exe",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const pkgRoot = join(__dirname, "..");
|
|
39
|
+
const binDir = join(pkgRoot, "bin");
|
|
40
|
+
|
|
41
|
+
function localName() {
|
|
42
|
+
return process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Follow redirects (GitHub releases → S3) and stream to disk.
|
|
47
|
+
* Uses global fetch() (Node 18+).
|
|
48
|
+
*/
|
|
49
|
+
async function download(url, dest) {
|
|
50
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`);
|
|
53
|
+
}
|
|
54
|
+
const fileStream = createWriteStream(dest);
|
|
55
|
+
// res.body is a ReadableStream; convert to Node stream for pipeline
|
|
56
|
+
await pipeline(res.body, fileStream);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
// Allow skipping for CI or air-gapped environments
|
|
63
|
+
if (process.env.YT_MCP_SKIP_YTDLP === "1") {
|
|
64
|
+
console.log("[yt-mcp] YT_MCP_SKIP_YTDLP=1 — skipping yt-dlp download");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const key = `${process.platform}-${process.arch}`;
|
|
69
|
+
const remoteName = PLATFORM_MAP[key];
|
|
70
|
+
|
|
71
|
+
if (!remoteName) {
|
|
72
|
+
console.warn(
|
|
73
|
+
`[yt-mcp] ⚠️ No pre-built yt-dlp binary for ${key}. ` +
|
|
74
|
+
`Install yt-dlp manually: https://github.com/yt-dlp/yt-dlp#installation`
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const dest = join(binDir, localName());
|
|
80
|
+
|
|
81
|
+
// Already downloaded?
|
|
82
|
+
if (existsSync(dest)) {
|
|
83
|
+
console.log(`[yt-mcp] yt-dlp already present at ${dest}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
mkdirSync(binDir, { recursive: true });
|
|
88
|
+
|
|
89
|
+
const url = `${BASE_URL}/${remoteName}`;
|
|
90
|
+
console.log(`[yt-mcp] Downloading yt-dlp ${YT_DLP_VERSION} for ${key}...`);
|
|
91
|
+
console.log(`[yt-mcp] ${url}`);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await download(url, dest);
|
|
95
|
+
|
|
96
|
+
// chmod +x on non-Windows
|
|
97
|
+
if (process.platform !== "win32") {
|
|
98
|
+
chmodSync(dest, 0o755);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(`[yt-mcp] ✅ yt-dlp installed to ${dest}`);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`[yt-mcp] ⚠️ Failed to download yt-dlp: ${err.message}`);
|
|
104
|
+
console.error(
|
|
105
|
+
`[yt-mcp] Install manually: https://github.com/yt-dlp/yt-dlp#installation`
|
|
106
|
+
);
|
|
107
|
+
// Non-fatal — remote tools still work without yt-dlp
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main();
|