@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,6 @@
1
+ export declare function runOAuthFlow(authUrl: string): Promise<{
2
+ accessToken: string;
3
+ refreshToken: string;
4
+ expiresIn: number;
5
+ clientId: string;
6
+ }>;
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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
+ }
@@ -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>;