@mkterswingman/5mghost-yonder 0.0.1

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,318 @@
1
+ import { existsSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadConfig, saveConfig, PATHS, ensureConfigDir } from "../utils/config.js";
7
+ import { TokenManager } from "../auth/tokenManager.js";
8
+ import { runOAuthFlow } from "../auth/oauthFlow.js";
9
+ import { hasSIDCookies } from "../utils/cookies.js";
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";
13
+ function detectCli(name) {
14
+ try {
15
+ execSync(`${name} --version`, { stdio: "pipe" });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function tryRegisterMcp(cmd, label) {
23
+ try {
24
+ execSync(cmd, { stdio: "pipe", timeout: MCP_REGISTER_TIMEOUT_MS });
25
+ console.log(` ✅ MCP registered in ${label}`);
26
+ return true;
27
+ }
28
+ catch (err) {
29
+ const failure = classifyRegistrationFailure(err);
30
+ if (failure.kind === "already_exists") {
31
+ console.log(` ✅ MCP already registered in ${label}`);
32
+ return true;
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
+ }
42
+ return false;
43
+ }
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
+ }
54
+ /**
55
+ * Detect if we can open a browser (local machine with display).
56
+ * Cloud environments (SSH, Docker, cloud IDE) typically can't.
57
+ */
58
+ function canOpenBrowser() {
59
+ // Explicit override
60
+ if (process.env.YT_MCP_NO_BROWSER === "1")
61
+ return false;
62
+ // Check for display (Linux/macOS GUI)
63
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
64
+ return false;
65
+ }
66
+ // Check if running in common cloud/container environments
67
+ if (process.env.CODESPACES || process.env.GITPOD_WORKSPACE_ID || process.env.CLOUD_SHELL) {
68
+ return false;
69
+ }
70
+ // Check if we have a TTY (interactive terminal) — headless environments often don't
71
+ if (!process.stdin.isTTY) {
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+ function openUrl(url) {
77
+ try {
78
+ const cmd = process.platform === "darwin" ? `open "${url}"` :
79
+ process.platform === "win32" ? `start "${url}"` :
80
+ `xdg-open "${url}"`;
81
+ execSync(cmd, { stdio: "ignore" });
82
+ }
83
+ catch {
84
+ // Can't open — user will see the URL in console
85
+ }
86
+ }
87
+ /**
88
+ * Prompt user for input in terminal.
89
+ */
90
+ function prompt(question) {
91
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
92
+ return new Promise((resolve) => {
93
+ rl.question(question, (answer) => {
94
+ rl.close();
95
+ resolve(answer.trim());
96
+ });
97
+ });
98
+ }
99
+ export async function runSetup() {
100
+ console.log("\n🚀 yt-mcp setup\n");
101
+ ensureConfigDir();
102
+ const hasBrowser = canOpenBrowser();
103
+ if (!hasBrowser) {
104
+ console.log(" ℹ️ Cloud/headless environment detected — using PAT mode\n");
105
+ }
106
+ // ── Step 1: Check yt-dlp ──
107
+ console.log("Step 1/5: Checking subtitle engine...");
108
+ const ytdlpPath = getYtDlpPath();
109
+ const bundledExists = existsSync(ytdlpPath) && ytdlpPath !== "yt-dlp";
110
+ const ytdlpInfo = getYtDlpVersion();
111
+ if (ytdlpInfo) {
112
+ console.log(` ✅ Subtitle engine ready`);
113
+ }
114
+ else if (bundledExists) {
115
+ // Binary exists but execFileSync("--version") timed out.
116
+ // Cause: macOS Gatekeeper network verification on first run (~15-20s).
117
+ console.log(" ✅ yt-dlp ready");
118
+ }
119
+ else {
120
+ // Binary truly missing — download it
121
+ process.stdout.write(" ⏳ Preparing subtitle engine...");
122
+ try {
123
+ execSync("node scripts/download-ytdlp.mjs", {
124
+ cwd: join(dirname(fileURLToPath(import.meta.url)), "..", ".."),
125
+ stdio: "pipe",
126
+ env: { ...process.env, YT_MCP_QUIET: "1" },
127
+ });
128
+ const retryInfo = getYtDlpVersion();
129
+ if (retryInfo) {
130
+ console.log(`\r ✅ Subtitle engine ready `);
131
+ }
132
+ else {
133
+ console.log(`\r ✅ Subtitle engine ready `);
134
+ }
135
+ }
136
+ catch {
137
+ console.log(`\r ⚠️ Subtitle engine setup failed — subtitles will be unavailable`);
138
+ }
139
+ }
140
+ // ── Step 2: Config ──
141
+ console.log("Step 2/5: Initializing config...");
142
+ const config = loadConfig();
143
+ saveConfig(config);
144
+ writeLauncherFile();
145
+ console.log(` ✅ Config: ${PATHS.configJson}`);
146
+ console.log(` ✅ Launcher: ${PATHS.launcherJs}`);
147
+ // ── Step 3: Authentication ──
148
+ console.log("Step 3/5: Authentication...");
149
+ const tokenManager = new TokenManager(config.auth_url);
150
+ const pat = process.env.YT_MCP_TOKEN;
151
+ if (pat) {
152
+ // Explicit PAT provided via env var
153
+ await tokenManager.savePAT(pat);
154
+ console.log(" ✅ PAT saved from YT_MCP_TOKEN");
155
+ }
156
+ else if (hasBrowser) {
157
+ // Local or cloud desktop — let user choose auth method
158
+ console.log(" 请选择登录方式:");
159
+ console.log(" [1] OAuth 自动登录(推荐 — 自动弹出浏览器完成登录)");
160
+ console.log(" [2] PAT Token 登录(手动 — 在浏览器中生成 token 后粘贴)");
161
+ console.log("");
162
+ const choice = await prompt(" 请输入 1 或 2 (默认 1): ");
163
+ const usePAT = choice === "2";
164
+ if (usePAT) {
165
+ // User chose PAT
166
+ const patUrl = `${config.auth_url}/pat/login`;
167
+ console.log("");
168
+ console.log(` 🔗 Opening PAT login page: ${patUrl}`);
169
+ openUrl(patUrl);
170
+ console.log("");
171
+ console.log(" 请在浏览器中登录并生成 PAT token。");
172
+ const patInput = await prompt(" 粘贴你的 PAT token (pat_xxx): ");
173
+ if (patInput) {
174
+ await tokenManager.savePAT(patInput);
175
+ console.log(" ✅ PAT saved");
176
+ }
177
+ else {
178
+ console.log(" ⚠️ 未输入 token,稍后可通过环境变量配置:");
179
+ console.log(' "env": { "YT_MCP_TOKEN": "pat_xxx" }');
180
+ }
181
+ }
182
+ else {
183
+ // User chose OAuth (default)
184
+ console.log("");
185
+ console.log(" 🌐 Opening browser for OAuth login...");
186
+ console.log(" ⚠️ 如果你在云桌面上运行,请在云桌面的浏览器中完成登录!");
187
+ console.log(" 在本地电脑打开链接将无法完成回调。");
188
+ try {
189
+ const tokens = await runOAuthFlow(config.auth_url);
190
+ await tokenManager.saveTokens(tokens.accessToken, tokens.refreshToken, tokens.expiresIn, tokens.clientId);
191
+ console.log(" ✅ OAuth login successful");
192
+ }
193
+ catch (err) {
194
+ // OAuth failed — auto fallback to PAT
195
+ console.log(` ⚠️ OAuth failed: ${err instanceof Error ? err.message : String(err)}`);
196
+ console.log("");
197
+ console.log(" 📋 Falling back to PAT login...");
198
+ const patUrl = `${config.auth_url}/pat/login`;
199
+ console.log(` 🔗 Opening PAT login page: ${patUrl}`);
200
+ openUrl(patUrl);
201
+ console.log("");
202
+ const patInput = await prompt(" 粘贴你的 PAT token (pat_xxx), 或直接回车跳过: ");
203
+ if (patInput) {
204
+ await tokenManager.savePAT(patInput);
205
+ console.log(" ✅ PAT saved");
206
+ }
207
+ else {
208
+ console.log(" ⚠️ 稍后可通过环境变量配置:");
209
+ console.log(' "env": { "YT_MCP_TOKEN": "pat_xxx" }');
210
+ }
211
+ }
212
+ }
213
+ }
214
+ else {
215
+ // Cloud/headless — can't open browser, PAT only
216
+ const patUrl = `${config.auth_url}/pat/login`;
217
+ console.log(` 🔗 请在本地电脑浏览器中打开此链接获取 PAT token:`);
218
+ console.log(` ${patUrl}`);
219
+ console.log("");
220
+ const patInput = await prompt(" 粘贴你的 PAT token (pat_xxx), 或直接回车跳过: ");
221
+ if (patInput) {
222
+ await tokenManager.savePAT(patInput);
223
+ console.log(" ✅ PAT saved");
224
+ }
225
+ else {
226
+ console.log(" ⚠️ 稍后可通过环境变量配置:");
227
+ console.log(' "env": { "YT_MCP_TOKEN": "pat_xxx" }');
228
+ }
229
+ }
230
+ // ── Step 4: YouTube Cookies ──
231
+ console.log("Step 4/5: YouTube cookies...");
232
+ if (!hasBrowser) {
233
+ console.log(" ⏭️ Skipped (no browser — subtitle features unavailable in cloud)");
234
+ console.log(" 💡 Run on your local machine: npx @mkterswingman/5mghost-yonder setup-cookies");
235
+ }
236
+ else if (hasSIDCookies(PATHS.cookiesTxt)) {
237
+ console.log(" ✅ Cookies already present");
238
+ }
239
+ else {
240
+ console.log(" 🌐 Opening browser for YouTube login...");
241
+ console.log(" ⚠️ 如果你在云桌面上运行,请在云桌面的浏览器窗口中登录 YouTube!");
242
+ console.log(" (Please log in to YouTube in the browser window)");
243
+ try {
244
+ const { runSetupCookies } = await import("./setupCookies.js");
245
+ await runSetupCookies();
246
+ console.log(" ✅ YouTube cookies saved");
247
+ }
248
+ catch (err) {
249
+ console.log(` ⚠️ Cookie setup skipped: ${err instanceof Error ? err.message : String(err)}`);
250
+ console.log(" 💡 Run later: npx @mkterswingman/5mghost-yonder setup-cookies");
251
+ }
252
+ }
253
+ // ── Step 5: MCP Registration ──
254
+ console.log("Step 5/5: Registering MCP in AI clients...");
255
+ const launcherCommand = buildLauncherCommand();
256
+ const mcpArgs = [launcherCommand.command, ...launcherCommand.args].map(quoteArg).join(" ");
257
+ let registered = false;
258
+ const cliCandidates = [
259
+ // Claude Code: {bin} mcp add yt-mcp -- npx ... serve
260
+ { bin: "claude-internal", label: "Claude Code (internal)",
261
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp -- ${a}` },
262
+ { bin: "claude", label: "Claude Code",
263
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp -- ${a}` },
264
+ // Codex (public): {bin} mcp add yt-mcp -- npx ... serve
265
+ { bin: "codex", label: "Codex CLI / Codex App",
266
+ cmd: (b, a) => `${b} mcp add yt-mcp -- ${a}` },
267
+ // Codex-internal doesn't support mcp add — needs manual config
268
+ { bin: "codex-internal", label: "Codex CLI (internal)",
269
+ cmd: () => null },
270
+ // Gemini: {bin} mcp add -s user yt-mcp node <launcher> serve (no --)
271
+ { bin: "gemini-internal", label: "Gemini CLI (internal)",
272
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp ${a}` },
273
+ { bin: "gemini", label: "Gemini CLI",
274
+ cmd: (b, a) => `${b} mcp add -s user yt-mcp ${a}` },
275
+ // Others: assume Claude-style syntax
276
+ { bin: "opencode", label: "OpenCode",
277
+ cmd: (b, a) => `${b} mcp add yt-mcp -- ${a}` },
278
+ { bin: "openclaw", label: "OpenClaw",
279
+ cmd: (b, a) => `${b} mcp add yt-mcp -- ${a}` },
280
+ ];
281
+ for (const { bin, label, cmd } of cliCandidates) {
282
+ if (!detectCli(bin))
283
+ continue;
284
+ const command = cmd(bin, mcpArgs);
285
+ if (!command) {
286
+ // CLI detected but doesn't support auto-registration
287
+ console.log(` ⚠️ ${label} detected but requires manual MCP config.`);
288
+ continue;
289
+ }
290
+ if (tryRegisterMcp(command, label)) {
291
+ registered = true;
292
+ }
293
+ }
294
+ if (!registered) {
295
+ console.log(" ℹ️ No supported CLI found. Add manually to your AI client:");
296
+ }
297
+ // Always print manual config
298
+ console.log("");
299
+ console.log(" MCP config (for manual setup / other clients):");
300
+ console.log(`
301
+ {
302
+ "mcpServers": {
303
+ "yt-mcp": {
304
+ "command": "node",
305
+ "args": [${JSON.stringify(PATHS.launcherJs)}, "serve"]
306
+ }
307
+ }
308
+ }
309
+ `);
310
+ console.log("✅ Setup complete!");
311
+ if (hasBrowser) {
312
+ console.log(' Open your AI client and try: "搜索 Python 教程"');
313
+ }
314
+ else {
315
+ console.log(" Set YT_MCP_TOKEN in your MCP env config, then restart your AI client.");
316
+ }
317
+ console.log("");
318
+ }
@@ -0,0 +1,34 @@
1
+ export type PlaywrightCookie = {
2
+ name: string;
3
+ value: string;
4
+ domain: string;
5
+ path: string;
6
+ secure: boolean;
7
+ httpOnly: boolean;
8
+ expires: number;
9
+ };
10
+ /**
11
+ * Detect which browser channel is available on the system.
12
+ * Prefers Chrome → Edge → falls back to bundled Chromium.
13
+ */
14
+ export declare function detectBrowserChannel(chromium: typeof import("playwright").chromium): Promise<string>;
15
+ export declare const CHANNEL_LABELS: Record<string, string>;
16
+ /** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
17
+ export declare function hasYouTubeSession(cookies: Array<{
18
+ name: string;
19
+ domain: string;
20
+ }>): boolean;
21
+ /**
22
+ * Save cookies to Netscape format file and close the browser context.
23
+ */
24
+ export declare function saveCookiesAndClose(context: {
25
+ close(): Promise<void>;
26
+ }, rawCookies: PlaywrightCookie[], silent?: boolean): Promise<void>;
27
+ /**
28
+ * Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
29
+ */
30
+ export declare function saveCookiesToDisk(rawCookies: PlaywrightCookie[]): void;
31
+ /**
32
+ * Interactive cookie setup — opens a visible browser for user to log in.
33
+ */
34
+ export declare function runSetupCookies(): Promise<void>;
@@ -0,0 +1,196 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { PATHS, ensureConfigDir } from "../utils/config.js";
3
+ import { cookiesToNetscape } from "../utils/cookies.js";
4
+ /**
5
+ * Detect which browser channel is available on the system.
6
+ * Prefers Chrome → Edge → falls back to bundled Chromium.
7
+ */
8
+ export async function detectBrowserChannel(chromium) {
9
+ for (const channel of ["chrome", "msedge"]) {
10
+ try {
11
+ const browser = await chromium.launch({ channel, headless: true });
12
+ await browser.close();
13
+ return channel;
14
+ }
15
+ catch {
16
+ // channel not available, try next
17
+ }
18
+ }
19
+ return "chromium";
20
+ }
21
+ export const CHANNEL_LABELS = {
22
+ chrome: "Google Chrome",
23
+ msedge: "Microsoft Edge",
24
+ chromium: "Playwright Chromium",
25
+ };
26
+ /** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
27
+ export function hasYouTubeSession(cookies) {
28
+ return cookies.some((c) => (c.name === "SID" || c.name === "HSID" || c.name === "SSID") &&
29
+ c.domain.includes("youtube.com"));
30
+ }
31
+ /**
32
+ * Save cookies to Netscape format file and close the browser context.
33
+ */
34
+ export async function saveCookiesAndClose(context, rawCookies, silent = false) {
35
+ try {
36
+ await context.close();
37
+ }
38
+ catch { /* already closed */ }
39
+ const entries = rawCookies.map((c) => ({
40
+ name: c.name,
41
+ value: c.value,
42
+ domain: c.domain,
43
+ path: c.path,
44
+ secure: c.secure,
45
+ httpOnly: c.httpOnly,
46
+ expires: c.expires,
47
+ }));
48
+ const netscape = cookiesToNetscape(entries);
49
+ writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
50
+ if (!silent) {
51
+ const httpOnlyCount = entries.filter((c) => c.httpOnly).length;
52
+ console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
53
+ console.log(` ${entries.length} cookies extracted (${httpOnlyCount} httpOnly)\n`);
54
+ }
55
+ }
56
+ /**
57
+ * Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
58
+ */
59
+ export function saveCookiesToDisk(rawCookies) {
60
+ const entries = rawCookies.map((c) => ({
61
+ name: c.name,
62
+ value: c.value,
63
+ domain: c.domain,
64
+ path: c.path,
65
+ secure: c.secure,
66
+ httpOnly: c.httpOnly,
67
+ expires: c.expires,
68
+ }));
69
+ const netscape = cookiesToNetscape(entries);
70
+ writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
71
+ }
72
+ /**
73
+ * Poll cookies at intervals until YouTube session cookies appear or timeout.
74
+ * Returns the full cookie list on success, or null on timeout / browser closed.
75
+ */
76
+ async function waitForLogin(context, isClosed, timeoutMs, pollIntervalMs = 2000) {
77
+ const deadline = Date.now() + timeoutMs;
78
+ while (Date.now() < deadline) {
79
+ if (isClosed())
80
+ return null;
81
+ try {
82
+ const cookies = await context.cookies("https://www.youtube.com");
83
+ if (hasYouTubeSession(cookies)) {
84
+ await new Promise((r) => setTimeout(r, 2000));
85
+ return await context.cookies("https://www.youtube.com");
86
+ }
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * Interactive cookie setup — opens a visible browser for user to log in.
97
+ */
98
+ export async function runSetupCookies() {
99
+ console.log("\n🍪 YouTube Cookie Setup\n");
100
+ ensureConfigDir();
101
+ let chromium;
102
+ try {
103
+ const pw = await import("playwright");
104
+ chromium = pw.chromium;
105
+ }
106
+ catch {
107
+ throw new Error("Playwright is not installed.\nRun: npm install playwright");
108
+ }
109
+ const channel = await detectBrowserChannel(chromium);
110
+ console.log(`Using browser: ${CHANNEL_LABELS[channel] ?? channel}`);
111
+ if (channel === "chromium") {
112
+ console.log("⚠️ No system Chrome or Edge found. Using bundled Chromium.\n" +
113
+ " If it fails, run: npx playwright install chromium\n");
114
+ }
115
+ let context;
116
+ try {
117
+ context = await chromium.launchPersistentContext(PATHS.browserProfile, {
118
+ headless: false,
119
+ channel,
120
+ args: ["--disable-blink-features=AutomationControlled"],
121
+ });
122
+ }
123
+ catch (err) {
124
+ const msg = err instanceof Error ? err.message : String(err);
125
+ if (channel === "chromium" && msg.includes("Executable doesn't exist")) {
126
+ throw new Error("Chromium browser not found.\nRun: npx playwright install chromium");
127
+ }
128
+ throw err;
129
+ }
130
+ let browserClosed = false;
131
+ context.on("close", () => { browserClosed = true; });
132
+ const page = context.pages()[0] ?? (await context.newPage());
133
+ page.on("close", () => {
134
+ if (context.pages().length === 0) {
135
+ browserClosed = true;
136
+ }
137
+ });
138
+ try {
139
+ await page.goto("https://www.youtube.com");
140
+ await page.waitForLoadState("domcontentloaded");
141
+ }
142
+ catch {
143
+ if (browserClosed) {
144
+ console.log("\n⚠️ Browser was closed. Setup cancelled.\n");
145
+ return;
146
+ }
147
+ throw new Error("Failed to navigate to YouTube");
148
+ }
149
+ const existingCookies = await context.cookies("https://www.youtube.com");
150
+ if (hasYouTubeSession(existingCookies)) {
151
+ console.log("✅ Already logged in to YouTube!\n");
152
+ await saveCookiesAndClose(context, existingCookies);
153
+ return;
154
+ }
155
+ try {
156
+ const signInBtn = page.locator('a[href*="accounts.google.com/ServiceLogin"], ' +
157
+ 'tp-yt-paper-button#button:has-text("Sign in"), ' +
158
+ 'a:has-text("Sign in"), ' +
159
+ 'ytd-button-renderer a:has-text("Sign in")').first();
160
+ await signInBtn.waitFor({ state: "visible", timeout: 5000 });
161
+ await signInBtn.click();
162
+ console.log("🔑 Opened sign-in page. Please log in to your Google account.\n");
163
+ }
164
+ catch {
165
+ try {
166
+ await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com");
167
+ console.log("🔑 Please log in to your Google account in the browser window.\n");
168
+ }
169
+ catch {
170
+ if (browserClosed) {
171
+ console.log("\n⚠️ Browser was closed. Setup cancelled.\n");
172
+ return;
173
+ }
174
+ throw new Error("Failed to navigate to Google login");
175
+ }
176
+ }
177
+ const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
178
+ console.log("⏳ Waiting for login (up to 2 minutes)...");
179
+ console.log(" Login will be detected automatically once you sign in.\n");
180
+ const finalCookies = await waitForLogin(context, () => browserClosed, LOGIN_TIMEOUT_MS);
181
+ if (browserClosed) {
182
+ console.log("\n⚠️ Browser was closed before login completed.");
183
+ console.log(" Run again: npx @mkterswingman/5mghost-yonder setup-cookies\n");
184
+ return;
185
+ }
186
+ if (!finalCookies) {
187
+ try {
188
+ await context.close();
189
+ }
190
+ catch { /* already closed */ }
191
+ console.log("\n⏰ Login timed out (2 minutes).");
192
+ console.log(" Run again: npx @mkterswingman/5mghost-yonder setup-cookies\n");
193
+ return;
194
+ }
195
+ await saveCookiesAndClose(context, finalCookies);
196
+ }
@@ -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>;
package/dist/server.js ADDED
@@ -0,0 +1,79 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerSubtitleTools } from "./tools/subtitles.js";
3
+ import { registerRemoteTools } from "./tools/remote.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 async function createServer(config, tokenManager) {
22
+ const server = new McpServer({
23
+ name: "@mkterswingman/yt-mcp",
24
+ version: "0.1.0",
25
+ });
26
+ const token = await tokenManager.getValidToken();
27
+ if (!token) {
28
+ // Not authenticated — register guidance tool only
29
+ server.registerTool("setup_required", {
30
+ description: "yt-mcp is not configured yet. Call this to get setup instructions for the user.",
31
+ }, async () => {
32
+ // Try to auto-open PAT login page in browser
33
+ const patUrl = `${config.auth_url}/pat/login`;
34
+ let opened = false;
35
+ try {
36
+ const { exec } = await import("node:child_process");
37
+ const cmd = process.platform === "darwin" ? `open "${patUrl}"` :
38
+ process.platform === "win32" ? `start "${patUrl}"` :
39
+ `xdg-open "${patUrl}"`;
40
+ exec(cmd);
41
+ opened = true;
42
+ }
43
+ catch { /* can't open browser, fall through */ }
44
+ return {
45
+ content: [
46
+ {
47
+ type: "text",
48
+ text: [
49
+ "🔧 **yt-mcp 需要登录才能使用**",
50
+ "",
51
+ opened
52
+ ? `✅ 已自动打开登录页面: ${patUrl}`
53
+ : `请在浏览器中打开: ${patUrl}`,
54
+ "",
55
+ "⚠️ **云桌面用户注意:** 如果你在云桌面(如 Win 云桌面版 OpenClaw)上运行,",
56
+ "请在 **云桌面的浏览器** 中完成登录,不要在本地电脑打开链接(回调地址是云桌面的 localhost)。",
57
+ "",
58
+ "**登录步骤:**",
59
+ "1. 用 Google 账号登录(首次需要邀请码,已有 x-mcp 账号可跳过)",
60
+ "2. 生成 PAT token(和 x-mcp 通用,已有可跳过)",
61
+ "3. 将 token 配置到 MCP:",
62
+ ' 在 MCP 配置中添加 `"env": { "YT_MCP_TOKEN": "pat_xxx" }`',
63
+ "4. 重启 AI 客户端",
64
+ "",
65
+ "**完整设置(含字幕功能):**",
66
+ "在终端运行:`npx @mkterswingman/5mghost-yonder setup`",
67
+ "这会同时设置 OAuth 登录和 YouTube cookie(字幕下载需要)",
68
+ ].join("\n"),
69
+ },
70
+ ],
71
+ };
72
+ });
73
+ return server;
74
+ }
75
+ // Authenticated — register all tools
76
+ registerSubtitleTools(server, config, tokenManager);
77
+ registerRemoteTools(server, config, tokenManager);
78
+ return server;
79
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { YtMcpConfig } from "../utils/config.js";
3
+ import type { TokenManager } from "../auth/tokenManager.js";
4
+ export declare function registerRemoteTools(server: McpServer, config: YtMcpConfig, tokenManager: TokenManager): void;