@mkterswingman/yt-mcp 0.3.6 → 0.3.7
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/README.md +19 -0
- package/dist/cli/setup.js +28 -13
- package/dist/utils/config.d.ts +2 -0
- package/dist/utils/config.js +3 -0
- package/dist/utils/launcher.d.ts +12 -0
- package/dist/utils/launcher.js +82 -0
- package/dist/utils/mcpRegistration.d.ts +7 -0
- package/dist/utils/mcpRegistration.js +23 -0
- package/package.json +1 -1
- package/scripts/download-ytdlp.mjs +2 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# yt-mcp
|
|
2
|
+
|
|
3
|
+
Local MCP server for YouTube workflows.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @mkterswingman/yt-mcp setup
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The setup command configures auth, optional YouTube cookies, and prints MCP config for supported AI clients.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
- `setup` — first-time setup
|
|
16
|
+
- `serve` — start the stdio MCP server
|
|
17
|
+
- `setup-cookies` — refresh YouTube cookies
|
|
18
|
+
- `update` — update to the latest npm version
|
|
19
|
+
- `version` — print the installed version
|
package/dist/cli/setup.js
CHANGED
|
@@ -8,6 +8,8 @@ import { TokenManager } from "../auth/tokenManager.js";
|
|
|
8
8
|
import { runOAuthFlow } from "../auth/oauthFlow.js";
|
|
9
9
|
import { hasSIDCookies } from "../utils/cookies.js";
|
|
10
10
|
import { getYtDlpPath, getYtDlpVersion } from "../utils/ytdlpPath.js";
|
|
11
|
+
import { buildLauncherCommand, writeLauncherFile } from "../utils/launcher.js";
|
|
12
|
+
import { MCP_REGISTER_TIMEOUT_MS, classifyRegistrationFailure, } from "../utils/mcpRegistration.js";
|
|
11
13
|
function detectCli(name) {
|
|
12
14
|
try {
|
|
13
15
|
execSync(`${name} --version`, { stdio: "pipe" });
|
|
@@ -19,26 +21,36 @@ function detectCli(name) {
|
|
|
19
21
|
}
|
|
20
22
|
function tryRegisterMcp(cmd, label) {
|
|
21
23
|
try {
|
|
22
|
-
execSync(cmd, { stdio: "pipe" });
|
|
24
|
+
execSync(cmd, { stdio: "pipe", timeout: MCP_REGISTER_TIMEOUT_MS });
|
|
23
25
|
console.log(` ✅ MCP registered in ${label}`);
|
|
24
26
|
return true;
|
|
25
27
|
}
|
|
26
28
|
catch (err) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
? String(err.stderr)
|
|
30
|
-
: "";
|
|
31
|
-
const stdout = err instanceof Error && "stdout" in err
|
|
32
|
-
? String(err.stdout)
|
|
33
|
-
: "";
|
|
34
|
-
const output = stderr + stdout;
|
|
35
|
-
if (output.includes("already exists")) {
|
|
29
|
+
const failure = classifyRegistrationFailure(err);
|
|
30
|
+
if (failure.kind === "already_exists") {
|
|
36
31
|
console.log(` ✅ MCP already registered in ${label}`);
|
|
37
32
|
return true;
|
|
38
33
|
}
|
|
34
|
+
if (failure.kind === "authentication") {
|
|
35
|
+
console.log(` ⚠️ ${label} is waiting for authentication — skipped auto-register.`);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (failure.kind === "timeout") {
|
|
39
|
+
console.log(` ⚠️ ${label} registration timed out after ${MCP_REGISTER_TIMEOUT_MS}ms — skipped auto-register.`);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
39
42
|
return false;
|
|
40
43
|
}
|
|
41
44
|
}
|
|
45
|
+
function quoteForShell(value) {
|
|
46
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
47
|
+
}
|
|
48
|
+
function quoteForCmd(value) {
|
|
49
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
50
|
+
}
|
|
51
|
+
function quoteArg(value) {
|
|
52
|
+
return process.platform === "win32" ? quoteForCmd(value) : quoteForShell(value);
|
|
53
|
+
}
|
|
42
54
|
/**
|
|
43
55
|
* Detect if we can open a browser (local machine with display).
|
|
44
56
|
* Cloud environments (SSH, Docker, cloud IDE) typically can't.
|
|
@@ -129,7 +141,9 @@ export async function runSetup() {
|
|
|
129
141
|
console.log("Step 2/5: Initializing config...");
|
|
130
142
|
const config = loadConfig();
|
|
131
143
|
saveConfig(config);
|
|
144
|
+
writeLauncherFile();
|
|
132
145
|
console.log(` ✅ Config: ${PATHS.configJson}`);
|
|
146
|
+
console.log(` ✅ Launcher: ${PATHS.launcherJs}`);
|
|
133
147
|
// ── Step 3: Authentication ──
|
|
134
148
|
console.log("Step 3/5: Authentication...");
|
|
135
149
|
const tokenManager = new TokenManager(config.auth_url);
|
|
@@ -238,7 +252,8 @@ export async function runSetup() {
|
|
|
238
252
|
}
|
|
239
253
|
// ── Step 5: MCP Registration ──
|
|
240
254
|
console.log("Step 5/5: Registering MCP in AI clients...");
|
|
241
|
-
const
|
|
255
|
+
const launcherCommand = buildLauncherCommand();
|
|
256
|
+
const mcpArgs = [launcherCommand.command, ...launcherCommand.args].map(quoteArg).join(" ");
|
|
242
257
|
let registered = false;
|
|
243
258
|
const cliCandidates = [
|
|
244
259
|
// Claude Code: {bin} mcp add yt-mcp -- npx ... serve
|
|
@@ -286,8 +301,8 @@ export async function runSetup() {
|
|
|
286
301
|
{
|
|
287
302
|
"mcpServers": {
|
|
288
303
|
"yt-mcp": {
|
|
289
|
-
"command": "
|
|
290
|
-
"args": [
|
|
304
|
+
"command": "node",
|
|
305
|
+
"args": [${JSON.stringify(PATHS.launcherJs)}, "serve"]
|
|
291
306
|
}
|
|
292
307
|
}
|
|
293
308
|
}
|
package/dist/utils/config.d.ts
CHANGED
package/dist/utils/config.js
CHANGED
|
@@ -8,12 +8,15 @@ export const PATHS = {
|
|
|
8
8
|
authJson: join(CONFIG_DIR, "auth.json"),
|
|
9
9
|
cookiesTxt: join(CONFIG_DIR, "cookies.txt"),
|
|
10
10
|
browserProfile: join(CONFIG_DIR, "browser-profile"),
|
|
11
|
+
launcherJs: join(CONFIG_DIR, "launcher.mjs"),
|
|
12
|
+
npmCacheDir: join(CONFIG_DIR, "npm-cache"),
|
|
11
13
|
subtitlesDir: SUBTITLES_DIR,
|
|
12
14
|
configJson: join(CONFIG_DIR, "config.json"),
|
|
13
15
|
};
|
|
14
16
|
const DEFAULTS = {
|
|
15
17
|
auth_url: "https://mkterswingman.com",
|
|
16
18
|
api_url: "https://mkterswingman.com/mcp/yt",
|
|
19
|
+
// Fixed list, not probed: --list-subs costs ~10s extra and YouTube has no "source language" concept
|
|
17
20
|
default_languages: ["en", "zh-Hans"],
|
|
18
21
|
batch_sleep_min_ms: 3000,
|
|
19
22
|
batch_sleep_max_ms: 8000,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface LauncherSourceOptions {
|
|
2
|
+
packageSpec?: string;
|
|
3
|
+
npmCacheDir?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface LauncherCommand {
|
|
6
|
+
command: string;
|
|
7
|
+
args: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function buildLauncherCommand(launcherPath?: string): LauncherCommand;
|
|
10
|
+
export declare function isRepairableNpxFailure(stderr: string): boolean;
|
|
11
|
+
export declare function buildLauncherSource(options?: LauncherSourceOptions): string;
|
|
12
|
+
export declare function writeLauncherFile(options?: LauncherSourceOptions): string;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { PATHS } from "./config.js";
|
|
4
|
+
const DEFAULT_PACKAGE_SPEC = "@mkterswingman/yt-mcp@latest";
|
|
5
|
+
export function buildLauncherCommand(launcherPath = PATHS.launcherJs) {
|
|
6
|
+
return {
|
|
7
|
+
command: "node",
|
|
8
|
+
args: [launcherPath, "serve"],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function isRepairableNpxFailure(stderr) {
|
|
12
|
+
const lower = stderr.toLowerCase();
|
|
13
|
+
const referencesNpxDir = lower.includes("_npx/") || lower.includes("_npx\\");
|
|
14
|
+
return referencesNpxDir && (lower.includes("enotempty") || lower.includes("rename"));
|
|
15
|
+
}
|
|
16
|
+
export function buildLauncherSource(options = {}) {
|
|
17
|
+
const packageSpec = options.packageSpec ?? DEFAULT_PACKAGE_SPEC;
|
|
18
|
+
const npmCacheDir = options.npmCacheDir ?? PATHS.npmCacheDir;
|
|
19
|
+
return `#!/usr/bin/env node
|
|
20
|
+
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
const packageSpec = ${JSON.stringify(packageSpec)};
|
|
25
|
+
const npmCacheDir = ${JSON.stringify(npmCacheDir)};
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const targetArgs = args.length > 0 ? args : ["serve"];
|
|
28
|
+
|
|
29
|
+
function isRepairableNpxFailure(stderr) {
|
|
30
|
+
const lower = stderr.toLowerCase();
|
|
31
|
+
return (lower.includes("_npx/") || lower.includes("_npx\\"))
|
|
32
|
+
&& (lower.includes("enotempty") || lower.includes("rename"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runNpx(subArgs, captureStdErrOnly = false) {
|
|
36
|
+
mkdirSync(npmCacheDir, { recursive: true });
|
|
37
|
+
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
38
|
+
return spawnSync(npxBin, ["--yes", packageSpec, ...subArgs], {
|
|
39
|
+
env: { ...process.env, npm_config_cache: npmCacheDir },
|
|
40
|
+
// Why: probe runs before MCP starts; stdout must stay silent or it will corrupt stdio transport.
|
|
41
|
+
stdio: captureStdErrOnly ? ["ignore", "ignore", "pipe"] : "inherit",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rotateNpxDir() {
|
|
46
|
+
const npxDir = join(npmCacheDir, "_npx");
|
|
47
|
+
if (!existsSync(npxDir)) return false;
|
|
48
|
+
const backup = \`\${npxDir}.bad.\${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14)}\`;
|
|
49
|
+
renameSync(npxDir, backup);
|
|
50
|
+
process.stderr.write(\`[yt-mcp launcher] repaired corrupted npx cache: \${backup}\\n\`);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensurePackageReady() {
|
|
55
|
+
const first = runNpx(["version"], true);
|
|
56
|
+
if ((first.status ?? 1) === 0) return;
|
|
57
|
+
|
|
58
|
+
const stderr = first.stderr ? String(first.stderr) : "";
|
|
59
|
+
if (!isRepairableNpxFailure(stderr) || !rotateNpxDir()) {
|
|
60
|
+
if (stderr) process.stderr.write(stderr);
|
|
61
|
+
process.exit(first.status ?? 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const second = runNpx(["version"], true);
|
|
65
|
+
const secondStderr = second.stderr ? String(second.stderr) : "";
|
|
66
|
+
if ((second.status ?? 1) !== 0) {
|
|
67
|
+
if (secondStderr) process.stderr.write(secondStderr);
|
|
68
|
+
process.exit(second.status ?? 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ensurePackageReady();
|
|
73
|
+
const finalRun = runNpx(targetArgs, false);
|
|
74
|
+
process.exit(finalRun.status ?? 0);
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
export function writeLauncherFile(options = {}) {
|
|
78
|
+
mkdirSync(dirname(PATHS.launcherJs), { recursive: true });
|
|
79
|
+
const source = buildLauncherSource(options);
|
|
80
|
+
writeFileSync(PATHS.launcherJs, source, { encoding: "utf8", mode: 0o755 });
|
|
81
|
+
return PATHS.launcherJs;
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const MCP_REGISTER_TIMEOUT_MS = 5000;
|
|
2
|
+
export type RegistrationFailureKind = "already_exists" | "authentication" | "timeout" | "other";
|
|
3
|
+
export interface RegistrationFailure {
|
|
4
|
+
kind: RegistrationFailureKind;
|
|
5
|
+
output: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function classifyRegistrationFailure(err: unknown): RegistrationFailure;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const MCP_REGISTER_TIMEOUT_MS = 5000;
|
|
2
|
+
function readOutput(err) {
|
|
3
|
+
if (!(err instanceof Error))
|
|
4
|
+
return "";
|
|
5
|
+
const stderr = "stderr" in err ? String(err.stderr ?? "") : "";
|
|
6
|
+
const stdout = "stdout" in err ? String(err.stdout ?? "") : "";
|
|
7
|
+
return `${stderr}${stdout}`;
|
|
8
|
+
}
|
|
9
|
+
export function classifyRegistrationFailure(err) {
|
|
10
|
+
const output = readOutput(err);
|
|
11
|
+
const lower = output.toLowerCase();
|
|
12
|
+
if (lower.includes("already exists")) {
|
|
13
|
+
return { kind: "already_exists", output };
|
|
14
|
+
}
|
|
15
|
+
if (lower.includes("waiting for authentication")) {
|
|
16
|
+
return { kind: "authentication", output };
|
|
17
|
+
}
|
|
18
|
+
if (err instanceof Error &&
|
|
19
|
+
("code" in err && err.code === "ETIMEDOUT")) {
|
|
20
|
+
return { kind: "timeout", output };
|
|
21
|
+
}
|
|
22
|
+
return { kind: "other", output };
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -18,6 +18,8 @@ import { pipeline } from "node:stream/promises";
|
|
|
18
18
|
|
|
19
19
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
20
20
|
|
|
21
|
+
// Pinned version: upgrade by changing this constant + publishing new npm version.
|
|
22
|
+
// Avoids auto-update breaking compat. postinstall (not optionalDeps) because yt-dlp is a standalone binary.
|
|
21
23
|
const YT_DLP_VERSION = "2026.03.17";
|
|
22
24
|
|
|
23
25
|
const BASE_URL =
|