@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.
- package/dist/auth/oauthFlow.d.ts +6 -0
- package/dist/auth/oauthFlow.js +141 -0
- package/dist/auth/tokenManager.d.ts +9 -0
- package/dist/auth/tokenManager.js +100 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +39 -0
- package/dist/cli/serve.d.ts +1 -0
- package/dist/cli/serve.js +24 -0
- package/dist/cli/setup.d.ts +1 -0
- package/dist/cli/setup.js +246 -0
- package/dist/cli/setupCookies.d.ts +1 -0
- package/dist/cli/setupCookies.js +54 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.js +79 -0
- package/dist/tools/remote.d.ts +4 -0
- package/dist/tools/remote.js +218 -0
- package/dist/tools/subtitles.d.ts +4 -0
- package/dist/tools/subtitles.js +393 -0
- package/dist/utils/config.d.ts +21 -0
- package/dist/utils/config.js +43 -0
- package/dist/utils/cookies.d.ts +11 -0
- package/dist/utils/cookies.js +28 -0
- package/dist/utils/videoInput.d.ts +5 -0
- package/dist/utils/videoInput.js +55 -0
- package/dist/utils/ytdlp.d.ts +7 -0
- package/dist/utils/ytdlp.js +51 -0
- package/package.json +42 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
function base64url(buf) {
|
|
5
|
+
return buf
|
|
6
|
+
.toString("base64")
|
|
7
|
+
.replace(/\+/g, "-")
|
|
8
|
+
.replace(/\//g, "_")
|
|
9
|
+
.replace(/=+$/, "");
|
|
10
|
+
}
|
|
11
|
+
export async function runOAuthFlow(authUrl) {
|
|
12
|
+
// 1. Generate PKCE
|
|
13
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
14
|
+
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
|
|
15
|
+
// 2. Start temp HTTP server to get the actual port for redirect_uri
|
|
16
|
+
const { server: httpServer, port } = await startCallbackServer();
|
|
17
|
+
const redirectUri = `http://127.0.0.1:${port}`;
|
|
18
|
+
// 3. DCR register client with actual redirect_uri (including port)
|
|
19
|
+
let clientId;
|
|
20
|
+
let clientSecret;
|
|
21
|
+
try {
|
|
22
|
+
const dcrRes = await fetch(`${authUrl}/oauth/register`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
client_name: "yt-mcp-cli",
|
|
27
|
+
redirect_uris: [redirectUri],
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
if (!dcrRes.ok) {
|
|
31
|
+
const text = await dcrRes.text().catch(() => "");
|
|
32
|
+
httpServer.close();
|
|
33
|
+
throw new Error(`DCR registration failed: ${dcrRes.status} ${text}`);
|
|
34
|
+
}
|
|
35
|
+
const dcrBody = (await dcrRes.json());
|
|
36
|
+
clientId = dcrBody.client_id;
|
|
37
|
+
clientSecret = dcrBody.client_secret;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
httpServer.close();
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
// 4. Wait for OAuth callback
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const timeout = setTimeout(() => {
|
|
46
|
+
httpServer.close();
|
|
47
|
+
reject(new Error("OAuth flow timed out after 5 minutes"));
|
|
48
|
+
}, 5 * 60 * 1000);
|
|
49
|
+
function cleanup() {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
httpServer.close();
|
|
52
|
+
}
|
|
53
|
+
httpServer.on("request", async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
56
|
+
const code = url.searchParams.get("code");
|
|
57
|
+
const error = url.searchParams.get("error");
|
|
58
|
+
if (error) {
|
|
59
|
+
const safeError = error.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
60
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
61
|
+
res.end(`<h1>Authorization failed</h1><p>${safeError}</p>`);
|
|
62
|
+
cleanup();
|
|
63
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!code) {
|
|
67
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
68
|
+
res.end("<h1>Waiting for authorization...</h1>");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Exchange code for tokens
|
|
72
|
+
const tokenBody = {
|
|
73
|
+
grant_type: "authorization_code",
|
|
74
|
+
code,
|
|
75
|
+
redirect_uri: redirectUri,
|
|
76
|
+
client_id: clientId,
|
|
77
|
+
code_verifier: codeVerifier,
|
|
78
|
+
};
|
|
79
|
+
if (clientSecret) {
|
|
80
|
+
tokenBody.client_secret = clientSecret;
|
|
81
|
+
}
|
|
82
|
+
const tokenRes = await fetch(`${authUrl}/oauth/token`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify(tokenBody),
|
|
86
|
+
});
|
|
87
|
+
if (!tokenRes.ok) {
|
|
88
|
+
const text = await tokenRes.text().catch(() => "");
|
|
89
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
90
|
+
res.end(`<h1>Token exchange failed</h1><p>${text}</p>`);
|
|
91
|
+
cleanup();
|
|
92
|
+
reject(new Error(`Token exchange failed: ${tokenRes.status} ${text}`));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const tokens = (await tokenRes.json());
|
|
96
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
97
|
+
res.end("<h1>Authorization successful!</h1><p>You can close this window.</p>");
|
|
98
|
+
cleanup();
|
|
99
|
+
resolve({
|
|
100
|
+
accessToken: tokens.access_token,
|
|
101
|
+
refreshToken: tokens.refresh_token,
|
|
102
|
+
expiresIn: tokens.expires_in,
|
|
103
|
+
clientId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
res.writeHead(500);
|
|
108
|
+
res.end("Internal error");
|
|
109
|
+
cleanup();
|
|
110
|
+
reject(err);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
// 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`;
|
|
115
|
+
console.log("\n\x1b[1mOpen this URL in your browser to authorize:\x1b[0m");
|
|
116
|
+
console.log(`\n ${authorizeUrl}\n`);
|
|
117
|
+
import("node:child_process").then(({ exec }) => {
|
|
118
|
+
const cmd = process.platform === "darwin" ? "open" :
|
|
119
|
+
process.platform === "win32" ? "start" :
|
|
120
|
+
"xdg-open";
|
|
121
|
+
exec(`${cmd} "${authorizeUrl}"`);
|
|
122
|
+
}).catch(() => {
|
|
123
|
+
// ignore — user can open manually
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Start an HTTP server on a random port and return the server + port. */
|
|
128
|
+
async function startCallbackServer() {
|
|
129
|
+
const server = createServer();
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
server.listen(0, "127.0.0.1", () => {
|
|
132
|
+
const addr = server.address();
|
|
133
|
+
if (!addr || typeof addr === "string") {
|
|
134
|
+
server.close();
|
|
135
|
+
reject(new Error("Failed to start callback server"));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
resolve({ server, port: addr.port });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare class TokenManager {
|
|
2
|
+
private authUrl;
|
|
3
|
+
constructor(authUrl: string);
|
|
4
|
+
getValidToken(): Promise<string | null>;
|
|
5
|
+
saveTokens(accessToken: string, refreshToken: string, expiresIn: number, clientId?: string): Promise<void>;
|
|
6
|
+
savePAT(pat: string): Promise<void>;
|
|
7
|
+
private readAuth;
|
|
8
|
+
private refreshTokens;
|
|
9
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { PATHS, ensureConfigDir } from "../utils/config.js";
|
|
3
|
+
export class TokenManager {
|
|
4
|
+
authUrl;
|
|
5
|
+
constructor(authUrl) {
|
|
6
|
+
this.authUrl = authUrl;
|
|
7
|
+
}
|
|
8
|
+
async getValidToken() {
|
|
9
|
+
// Check env var PAT first
|
|
10
|
+
const envPat = process.env.YT_MCP_TOKEN;
|
|
11
|
+
if (envPat)
|
|
12
|
+
return envPat;
|
|
13
|
+
const auth = this.readAuth();
|
|
14
|
+
if (!auth)
|
|
15
|
+
return null;
|
|
16
|
+
if (auth.type === "pat" && auth.pat) {
|
|
17
|
+
return auth.pat;
|
|
18
|
+
}
|
|
19
|
+
if (auth.type === "jwt") {
|
|
20
|
+
if (auth.access_token && auth.expires_at && Date.now() < auth.expires_at) {
|
|
21
|
+
return auth.access_token;
|
|
22
|
+
}
|
|
23
|
+
// Try refresh
|
|
24
|
+
if (auth.refresh_token) {
|
|
25
|
+
try {
|
|
26
|
+
const refreshed = await this.refreshTokens(auth.refresh_token, auth.client_id);
|
|
27
|
+
if (refreshed) {
|
|
28
|
+
await this.saveTokens(refreshed.access_token, refreshed.refresh_token, refreshed.expires_in, auth.client_id);
|
|
29
|
+
return refreshed.access_token;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// refresh failed → AUTH_EXPIRED
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
async saveTokens(accessToken, refreshToken, expiresIn, clientId) {
|
|
41
|
+
ensureConfigDir();
|
|
42
|
+
const data = {
|
|
43
|
+
type: "jwt",
|
|
44
|
+
access_token: accessToken,
|
|
45
|
+
refresh_token: refreshToken,
|
|
46
|
+
expires_at: Date.now() + expiresIn * 1000,
|
|
47
|
+
client_id: clientId,
|
|
48
|
+
};
|
|
49
|
+
writeFileSync(PATHS.authJson, JSON.stringify(data, null, 2), {
|
|
50
|
+
mode: 0o600,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async savePAT(pat) {
|
|
54
|
+
ensureConfigDir();
|
|
55
|
+
const data = {
|
|
56
|
+
type: "pat",
|
|
57
|
+
pat,
|
|
58
|
+
};
|
|
59
|
+
writeFileSync(PATHS.authJson, JSON.stringify(data, null, 2), {
|
|
60
|
+
mode: 0o600,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
readAuth() {
|
|
64
|
+
if (!existsSync(PATHS.authJson))
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(PATHS.authJson, "utf8");
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async refreshTokens(refreshToken, clientId) {
|
|
75
|
+
const url = `${this.authUrl}/oauth/token`;
|
|
76
|
+
const body = {
|
|
77
|
+
grant_type: "refresh_token",
|
|
78
|
+
refresh_token: refreshToken,
|
|
79
|
+
};
|
|
80
|
+
if (clientId) {
|
|
81
|
+
body.client_id = clientId;
|
|
82
|
+
}
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
const body = (await res.json());
|
|
92
|
+
if (!body.access_token)
|
|
93
|
+
return null;
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const command = process.argv[2];
|
|
3
|
+
async function main() {
|
|
4
|
+
switch (command) {
|
|
5
|
+
case "setup": {
|
|
6
|
+
const { runSetup } = await import("./setup.js");
|
|
7
|
+
await runSetup();
|
|
8
|
+
break;
|
|
9
|
+
}
|
|
10
|
+
case "serve": {
|
|
11
|
+
const { runServe } = await import("./serve.js");
|
|
12
|
+
await runServe();
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
case "setup-cookies": {
|
|
16
|
+
const { runSetupCookies } = await import("./setupCookies.js");
|
|
17
|
+
await runSetupCookies();
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
default:
|
|
21
|
+
console.log(`
|
|
22
|
+
Usage: yt-mcp <command>
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
setup Run first-time setup (OAuth + cookies + MCP registration)
|
|
26
|
+
serve Start the MCP server (stdio transport)
|
|
27
|
+
setup-cookies Refresh YouTube cookies using browser login
|
|
28
|
+
|
|
29
|
+
Environment variables:
|
|
30
|
+
YT_MCP_TOKEN Personal Access Token (skips OAuth)
|
|
31
|
+
`);
|
|
32
|
+
process.exit(command ? 1 : 0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
main().catch((err) => {
|
|
36
|
+
console.error("Fatal error:", err);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runServe(): Promise<void>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { loadConfig } from "../utils/config.js";
|
|
3
|
+
import { TokenManager } from "../auth/tokenManager.js";
|
|
4
|
+
import { createServer } from "../server.js";
|
|
5
|
+
export async function runServe() {
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
const tokenManager = new TokenManager(config.auth_url);
|
|
8
|
+
// PAT mode via env var (don't persist — just keep in memory for this session)
|
|
9
|
+
const pat = process.env.YT_MCP_TOKEN;
|
|
10
|
+
if (pat) {
|
|
11
|
+
await tokenManager.savePAT(pat);
|
|
12
|
+
}
|
|
13
|
+
const server = await createServer(config, tokenManager);
|
|
14
|
+
const transport = new StdioServerTransport();
|
|
15
|
+
await server.connect(transport);
|
|
16
|
+
process.on("SIGINT", async () => {
|
|
17
|
+
await server.close();
|
|
18
|
+
process.exit(0);
|
|
19
|
+
});
|
|
20
|
+
process.on("SIGTERM", async () => {
|
|
21
|
+
await server.close();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runSetup(): Promise<void>;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { loadConfig, saveConfig, PATHS, ensureConfigDir } from "../utils/config.js";
|
|
4
|
+
import { TokenManager } from "../auth/tokenManager.js";
|
|
5
|
+
import { runOAuthFlow } from "../auth/oauthFlow.js";
|
|
6
|
+
import { hasSIDCookies } from "../utils/cookies.js";
|
|
7
|
+
function detectCli(name) {
|
|
8
|
+
try {
|
|
9
|
+
execSync(`${name} --version`, { stdio: "pipe" });
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function tryRegisterMcp(cmd, label) {
|
|
17
|
+
try {
|
|
18
|
+
execSync(cmd, { stdio: "pipe" });
|
|
19
|
+
console.log(` ✅ MCP registered in ${label}`);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Detect if we can open a browser (local machine with display).
|
|
28
|
+
* Cloud environments (SSH, Docker, cloud IDE) typically can't.
|
|
29
|
+
*/
|
|
30
|
+
function canOpenBrowser() {
|
|
31
|
+
// Explicit override
|
|
32
|
+
if (process.env.YT_MCP_NO_BROWSER === "1")
|
|
33
|
+
return false;
|
|
34
|
+
// Check for display (Linux/macOS GUI)
|
|
35
|
+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// Check if running in common cloud/container environments
|
|
39
|
+
if (process.env.CODESPACES || process.env.GITPOD_WORKSPACE_ID || process.env.CLOUD_SHELL) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// Check if we have a TTY (interactive terminal) — headless environments often don't
|
|
43
|
+
if (!process.stdin.isTTY) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
function openUrl(url) {
|
|
49
|
+
try {
|
|
50
|
+
const cmd = process.platform === "darwin" ? `open "${url}"` :
|
|
51
|
+
process.platform === "win32" ? `start "${url}"` :
|
|
52
|
+
`xdg-open "${url}"`;
|
|
53
|
+
execSync(cmd, { stdio: "ignore" });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Can't open — user will see the URL in console
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Prompt user for input in terminal.
|
|
61
|
+
*/
|
|
62
|
+
function prompt(question) {
|
|
63
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
rl.question(question, (answer) => {
|
|
66
|
+
rl.close();
|
|
67
|
+
resolve(answer.trim());
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export async function runSetup() {
|
|
72
|
+
console.log("\n🚀 yt-mcp setup\n");
|
|
73
|
+
ensureConfigDir();
|
|
74
|
+
const hasBrowser = canOpenBrowser();
|
|
75
|
+
if (!hasBrowser) {
|
|
76
|
+
console.log(" ℹ️ Cloud/headless environment detected — using PAT mode\n");
|
|
77
|
+
}
|
|
78
|
+
// ── Step 1: Check yt-dlp ──
|
|
79
|
+
console.log("Step 1/5: Checking yt-dlp...");
|
|
80
|
+
try {
|
|
81
|
+
const ver = execSync("yt-dlp --version", { stdio: "pipe" }).toString().trim();
|
|
82
|
+
console.log(` ✅ yt-dlp ${ver}`);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
console.log(" ⚠️ yt-dlp not found (subtitle features will be unavailable)");
|
|
86
|
+
console.log(" 💡 Install later: https://github.com/yt-dlp/yt-dlp#installation");
|
|
87
|
+
}
|
|
88
|
+
// ── Step 2: Config ──
|
|
89
|
+
console.log("Step 2/5: Initializing config...");
|
|
90
|
+
const config = loadConfig();
|
|
91
|
+
saveConfig(config);
|
|
92
|
+
console.log(` ✅ Config: ${PATHS.configJson}`);
|
|
93
|
+
// ── Step 3: Authentication ──
|
|
94
|
+
console.log("Step 3/5: Authentication...");
|
|
95
|
+
const tokenManager = new TokenManager(config.auth_url);
|
|
96
|
+
const pat = process.env.YT_MCP_TOKEN;
|
|
97
|
+
if (pat) {
|
|
98
|
+
// Explicit PAT provided via env var
|
|
99
|
+
await tokenManager.savePAT(pat);
|
|
100
|
+
console.log(" ✅ PAT saved from YT_MCP_TOKEN");
|
|
101
|
+
}
|
|
102
|
+
else if (hasBrowser) {
|
|
103
|
+
// Local or cloud desktop — let user choose auth method
|
|
104
|
+
console.log(" 请选择登录方式:");
|
|
105
|
+
console.log(" [1] OAuth 自动登录(推荐 — 自动弹出浏览器完成登录)");
|
|
106
|
+
console.log(" [2] PAT Token 登录(手动 — 在浏览器中生成 token 后粘贴)");
|
|
107
|
+
console.log("");
|
|
108
|
+
const choice = await prompt(" 请输入 1 或 2 (默认 1): ");
|
|
109
|
+
const usePAT = choice === "2";
|
|
110
|
+
if (usePAT) {
|
|
111
|
+
// User chose PAT
|
|
112
|
+
const patUrl = `${config.auth_url}/pat/login`;
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log(` 🔗 Opening PAT login page: ${patUrl}`);
|
|
115
|
+
openUrl(patUrl);
|
|
116
|
+
console.log("");
|
|
117
|
+
console.log(" 请在浏览器中登录并生成 PAT token。");
|
|
118
|
+
const patInput = await prompt(" 粘贴你的 PAT token (pat_xxx): ");
|
|
119
|
+
if (patInput) {
|
|
120
|
+
await tokenManager.savePAT(patInput);
|
|
121
|
+
console.log(" ✅ PAT saved");
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
console.log(" ⚠️ 未输入 token,稍后可通过环境变量配置:");
|
|
125
|
+
console.log(' "env": { "YT_MCP_TOKEN": "pat_xxx" }');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// User chose OAuth (default)
|
|
130
|
+
console.log("");
|
|
131
|
+
console.log(" 🌐 Opening browser for OAuth login...");
|
|
132
|
+
console.log(" ⚠️ 如果你在云桌面上运行,请在云桌面的浏览器中完成登录!");
|
|
133
|
+
console.log(" 在本地电脑打开链接将无法完成回调。");
|
|
134
|
+
try {
|
|
135
|
+
const tokens = await runOAuthFlow(config.auth_url);
|
|
136
|
+
await tokenManager.saveTokens(tokens.accessToken, tokens.refreshToken, tokens.expiresIn, tokens.clientId);
|
|
137
|
+
console.log(" ✅ OAuth login successful");
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// OAuth failed — auto fallback to PAT
|
|
141
|
+
console.log(` ⚠️ OAuth failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log(" 📋 Falling back to PAT login...");
|
|
144
|
+
const patUrl = `${config.auth_url}/pat/login`;
|
|
145
|
+
console.log(` 🔗 Opening PAT login page: ${patUrl}`);
|
|
146
|
+
openUrl(patUrl);
|
|
147
|
+
console.log("");
|
|
148
|
+
const patInput = await prompt(" 粘贴你的 PAT token (pat_xxx), 或直接回车跳过: ");
|
|
149
|
+
if (patInput) {
|
|
150
|
+
await tokenManager.savePAT(patInput);
|
|
151
|
+
console.log(" ✅ PAT saved");
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.log(" ⚠️ 稍后可通过环境变量配置:");
|
|
155
|
+
console.log(' "env": { "YT_MCP_TOKEN": "pat_xxx" }');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Cloud/headless — can't open browser, PAT only
|
|
162
|
+
const patUrl = `${config.auth_url}/pat/login`;
|
|
163
|
+
console.log(` 🔗 请在本地电脑浏览器中打开此链接获取 PAT token:`);
|
|
164
|
+
console.log(` ${patUrl}`);
|
|
165
|
+
console.log("");
|
|
166
|
+
const patInput = await prompt(" 粘贴你的 PAT token (pat_xxx), 或直接回车跳过: ");
|
|
167
|
+
if (patInput) {
|
|
168
|
+
await tokenManager.savePAT(patInput);
|
|
169
|
+
console.log(" ✅ PAT saved");
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
console.log(" ⚠️ 稍后可通过环境变量配置:");
|
|
173
|
+
console.log(' "env": { "YT_MCP_TOKEN": "pat_xxx" }');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ── Step 4: YouTube Cookies ──
|
|
177
|
+
console.log("Step 4/5: YouTube cookies...");
|
|
178
|
+
if (!hasBrowser) {
|
|
179
|
+
console.log(" ⏭️ Skipped (no browser — subtitle features unavailable in cloud)");
|
|
180
|
+
console.log(" 💡 Run on your local machine: npx @mkterswingman/yt-mcp setup-cookies");
|
|
181
|
+
}
|
|
182
|
+
else if (hasSIDCookies(PATHS.cookiesTxt)) {
|
|
183
|
+
console.log(" ✅ Cookies already present");
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log(" 🌐 Opening browser for YouTube login...");
|
|
187
|
+
console.log(" ⚠️ 如果你在云桌面上运行,请在云桌面的浏览器窗口中登录 YouTube!");
|
|
188
|
+
console.log(" (Please log in to YouTube in the browser window)");
|
|
189
|
+
try {
|
|
190
|
+
const { runSetupCookies } = await import("./setupCookies.js");
|
|
191
|
+
await runSetupCookies();
|
|
192
|
+
console.log(" ✅ YouTube cookies saved");
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.log(` ⚠️ Cookie setup skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
196
|
+
console.log(" 💡 Run later: npx @mkterswingman/yt-mcp setup-cookies");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// ── Step 5: MCP Registration ──
|
|
200
|
+
console.log("Step 5/5: Registering MCP in AI clients...");
|
|
201
|
+
const mcpCmd = "npx @mkterswingman/yt-mcp@latest serve";
|
|
202
|
+
let registered = false;
|
|
203
|
+
const cliCandidates = [
|
|
204
|
+
// Internal (company) variants
|
|
205
|
+
{ bin: "claude-internal", label: "Claude Code (internal)" },
|
|
206
|
+
{ bin: "codex-internal", label: "Codex CLI (internal)" },
|
|
207
|
+
{ bin: "gemini-internal", label: "Gemini CLI (internal)" },
|
|
208
|
+
// Public versions
|
|
209
|
+
{ bin: "claude", label: "Claude Code" },
|
|
210
|
+
{ bin: "codex", label: "Codex CLI / Codex App" },
|
|
211
|
+
{ bin: "gemini", label: "Gemini CLI" },
|
|
212
|
+
{ bin: "opencode", label: "OpenCode" },
|
|
213
|
+
{ bin: "openclaw", label: "OpenClaw" },
|
|
214
|
+
];
|
|
215
|
+
for (const { bin, label } of cliCandidates) {
|
|
216
|
+
if (detectCli(bin)) {
|
|
217
|
+
if (tryRegisterMcp(`${bin} mcp add yt-mcp -- ${mcpCmd}`, label)) {
|
|
218
|
+
registered = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!registered) {
|
|
223
|
+
console.log(" ℹ️ No supported CLI found. Add manually to your AI client:");
|
|
224
|
+
}
|
|
225
|
+
// Always print manual config
|
|
226
|
+
console.log("");
|
|
227
|
+
console.log(" MCP config (for manual setup / other clients):");
|
|
228
|
+
console.log(`
|
|
229
|
+
{
|
|
230
|
+
"mcpServers": {
|
|
231
|
+
"yt-mcp": {
|
|
232
|
+
"command": "npx",
|
|
233
|
+
"args": ["@mkterswingman/yt-mcp@latest", "serve"]
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
`);
|
|
238
|
+
console.log("✅ Setup complete!");
|
|
239
|
+
if (hasBrowser) {
|
|
240
|
+
console.log(' Open your AI client and try: "搜索 Python 教程"');
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
console.log(" Set YT_MCP_TOKEN in your MCP env config, then restart your AI client.");
|
|
244
|
+
}
|
|
245
|
+
console.log("");
|
|
246
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runSetupCookies(): Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { PATHS, ensureConfigDir } from "../utils/config.js";
|
|
3
|
+
import { cookiesToNetscape } from "../utils/cookies.js";
|
|
4
|
+
export async function runSetupCookies() {
|
|
5
|
+
console.log("\n🍪 YouTube Cookie Setup\n");
|
|
6
|
+
console.log("A browser window will open. Please log in to YouTube.\n");
|
|
7
|
+
ensureConfigDir();
|
|
8
|
+
// Dynamic import of playwright to avoid pulling it in at serve time
|
|
9
|
+
let chromium;
|
|
10
|
+
try {
|
|
11
|
+
const pw = await import("playwright");
|
|
12
|
+
chromium = pw.chromium;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error("Playwright is not installed. Run: npm install playwright && npx playwright install chromium");
|
|
16
|
+
}
|
|
17
|
+
const context = await chromium.launchPersistentContext(PATHS.browserProfile, {
|
|
18
|
+
headless: false,
|
|
19
|
+
channel: "chromium",
|
|
20
|
+
args: [
|
|
21
|
+
"--disable-blink-features=AutomationControlled",
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
25
|
+
await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com");
|
|
26
|
+
console.log("Waiting for YouTube login (up to 5 minutes)...");
|
|
27
|
+
console.log("After logging in, the browser will close automatically.\n");
|
|
28
|
+
try {
|
|
29
|
+
await page.waitForURL("**/youtube.com/**", { timeout: 5 * 60 * 1000 });
|
|
30
|
+
// Wait a bit for cookies to settle
|
|
31
|
+
await page.waitForTimeout(3000);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
console.error("Timed out waiting for YouTube login.");
|
|
35
|
+
await context.close();
|
|
36
|
+
throw new Error("Login timed out");
|
|
37
|
+
}
|
|
38
|
+
// Extract cookies
|
|
39
|
+
const rawCookies = await context.cookies("https://www.youtube.com");
|
|
40
|
+
await context.close();
|
|
41
|
+
const entries = rawCookies.map((c) => ({
|
|
42
|
+
name: c.name,
|
|
43
|
+
value: c.value,
|
|
44
|
+
domain: c.domain,
|
|
45
|
+
path: c.path,
|
|
46
|
+
secure: c.secure,
|
|
47
|
+
httpOnly: c.httpOnly,
|
|
48
|
+
expires: c.expires,
|
|
49
|
+
}));
|
|
50
|
+
const netscape = cookiesToNetscape(entries);
|
|
51
|
+
writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
|
|
52
|
+
console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
|
|
53
|
+
console.log(` ${entries.length} cookies extracted\n`);
|
|
54
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { YtMcpConfig } from "./utils/config.js";
|
|
3
|
+
import type { TokenManager } from "./auth/tokenManager.js";
|
|
4
|
+
/**
|
|
5
|
+
* Creates the MCP server.
|
|
6
|
+
*
|
|
7
|
+
* Authentication flow:
|
|
8
|
+
*
|
|
9
|
+
* serve startup
|
|
10
|
+
* │
|
|
11
|
+
* ├─ YT_MCP_TOKEN env var? ──▶ PAT mode ──▶ full server (19 tools)
|
|
12
|
+
* │
|
|
13
|
+
* ├─ auth.json exists? ──▶ JWT mode ──▶ full server (19 tools)
|
|
14
|
+
* │
|
|
15
|
+
* └─ no token? ──▶ register "setup_required" tool only
|
|
16
|
+
* │
|
|
17
|
+
* └─ AI calls setup_required ──▶ returns:
|
|
18
|
+
* ├─ PAT login URL (user clicks to get token)
|
|
19
|
+
* └─ setup command (for full OAuth + cookies)
|
|
20
|
+
*/
|
|
21
|
+
export declare function createServer(config: YtMcpConfig, tokenManager: TokenManager): Promise<McpServer>;
|