@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/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/yt-mcp 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;
@@ -0,0 +1,218 @@
1
+ import { z } from "zod";
2
+ function toolErr(code, message) {
3
+ const payload = { status: "failed", error: { code, message } };
4
+ return {
5
+ structuredContent: payload,
6
+ isError: true,
7
+ content: [{ type: "text", text: JSON.stringify(payload) }],
8
+ };
9
+ }
10
+ function createRemoteTool(server, def, tokenManager, apiUrl) {
11
+ server.registerTool(def.name, {
12
+ description: def.description,
13
+ inputSchema: def.schema,
14
+ }, async (params) => {
15
+ const token = await tokenManager.getValidToken();
16
+ if (!token) {
17
+ return toolErr("AUTH_EXPIRED", "请重新运行 npx @mkterswingman/yt-mcp setup");
18
+ }
19
+ let res;
20
+ try {
21
+ res = await fetch(`${apiUrl}/${def.remotePath}`, {
22
+ method: "POST",
23
+ headers: {
24
+ Authorization: `Bearer ${token}`,
25
+ "Content-Type": "application/json",
26
+ },
27
+ body: JSON.stringify(params),
28
+ });
29
+ }
30
+ catch (err) {
31
+ return toolErr("REMOTE_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`);
32
+ }
33
+ if (res.status === 401) {
34
+ return toolErr("AUTH_EXPIRED", "Token 已过期");
35
+ }
36
+ if (!res.ok) {
37
+ const text = await res.text().catch(() => "");
38
+ return toolErr("REMOTE_ERROR", `服务端错误 ${res.status}: ${text.slice(0, 200)}`);
39
+ }
40
+ try {
41
+ const body = (await res.json());
42
+ // If the remote already returns MCP-shaped content, pass through
43
+ if (body.content && Array.isArray(body.content)) {
44
+ return body;
45
+ }
46
+ // Otherwise wrap it
47
+ return {
48
+ content: [
49
+ {
50
+ type: "text",
51
+ text: JSON.stringify(body),
52
+ },
53
+ ],
54
+ };
55
+ }
56
+ catch {
57
+ return toolErr("REMOTE_ERROR", "服务端返回了非 JSON 内容");
58
+ }
59
+ });
60
+ }
61
+ const REMOTE_TOOLS = [
62
+ {
63
+ name: "search_videos",
64
+ description: "Search YouTube videos by keyword. Supports up to 300 results.",
65
+ schema: {
66
+ query: z.string().min(1),
67
+ max_results: z.number().int().min(1).max(300).optional(),
68
+ order: z
69
+ .enum([
70
+ "date",
71
+ "rating",
72
+ "relevance",
73
+ "title",
74
+ "videoCount",
75
+ "viewCount",
76
+ ])
77
+ .optional(),
78
+ },
79
+ remotePath: "api/search",
80
+ },
81
+ {
82
+ name: "get_video_stats",
83
+ description: "Get YouTube video stats in one request. Accepts up to 1000 video IDs/URLs.",
84
+ schema: {
85
+ videos: z.array(z.string().min(1)).min(1).max(1000),
86
+ },
87
+ remotePath: "api/video-stats/bulk",
88
+ },
89
+ {
90
+ name: "start_video_stats_job",
91
+ description: "Start an async YouTube video stats job. Accepts up to 1000 items.",
92
+ schema: {
93
+ videos: z.array(z.string().min(1)).min(1).max(1000),
94
+ },
95
+ remotePath: "api/video-stats/job/start",
96
+ },
97
+ {
98
+ name: "poll_video_stats_job",
99
+ description: "Poll job progress and get partial/final results for a job_id.",
100
+ schema: {
101
+ job_id: z.string().min(1),
102
+ },
103
+ remotePath: "api/video-stats/job/poll",
104
+ },
105
+ {
106
+ name: "resume_video_stats_job",
107
+ description: "Resume a previously partial job with a resume_token.",
108
+ schema: {
109
+ resume_token: z.string().min(1),
110
+ },
111
+ remotePath: "api/video-stats/job/resume",
112
+ },
113
+ {
114
+ name: "get_trending",
115
+ description: "Get official YouTube mostPopular videos. Supports region/category filters.",
116
+ schema: {
117
+ region_code: z
118
+ .string()
119
+ .regex(/^[A-Za-z]{2}$/)
120
+ .optional(),
121
+ category_id: z.string().min(1).max(64).optional(),
122
+ max_results: z.number().int().min(1).max(300).optional(),
123
+ },
124
+ remotePath: "api/trending",
125
+ },
126
+ {
127
+ name: "get_channel_stats",
128
+ description: "Get YouTube channel statistics for up to 50 channel inputs.",
129
+ schema: {
130
+ channels: z.array(z.string().min(1)).min(1).max(50).optional(),
131
+ channel_ids: z.array(z.string().min(1)).min(1).max(50).optional(),
132
+ },
133
+ remotePath: "api/channel/stats",
134
+ },
135
+ {
136
+ name: "list_channel_uploads",
137
+ description: "List videos uploaded by a YouTube channel. Supports up to 1000 results.",
138
+ schema: {
139
+ channel: z.string().min(1).optional(),
140
+ channel_id: z.string().min(1).optional(),
141
+ max_results: z.number().int().min(1).max(1000).optional(),
142
+ },
143
+ remotePath: "api/channel/uploads",
144
+ },
145
+ {
146
+ name: "get_comments",
147
+ description: "Get comments for one YouTube video. max_comments up to 1000.",
148
+ schema: {
149
+ video: z.string().min(1),
150
+ max_comments: z.number().int().min(1).max(1000).optional(),
151
+ order: z.enum(["time", "relevance"]).optional(),
152
+ include_replies_preview: z.boolean().optional(),
153
+ replies_preview_per_comment: z.number().int().min(1).max(5).optional(),
154
+ replies_preview_parent_limit: z.number().int().min(1).max(25).optional(),
155
+ },
156
+ remotePath: "api/comments/sync",
157
+ },
158
+ {
159
+ name: "start_comments_job",
160
+ description: "Start a comments export job for one YouTube video.",
161
+ schema: {
162
+ video: z.string().min(1),
163
+ max_comments: z.number().int().min(1).max(1000).optional(),
164
+ order: z.enum(["time", "relevance"]).optional(),
165
+ },
166
+ remotePath: "api/comments/start",
167
+ },
168
+ {
169
+ name: "poll_comments_job",
170
+ description: "Check comments export job progress by job_id.",
171
+ schema: {
172
+ job_id: z.string().min(1),
173
+ },
174
+ remotePath: "api/comments/poll",
175
+ },
176
+ {
177
+ name: "resume_comments_job",
178
+ description: "Resume an unfinished comments export job.",
179
+ schema: {
180
+ resume_token: z.string().min(1),
181
+ },
182
+ remotePath: "api/comments/resume",
183
+ },
184
+ {
185
+ name: "get_comment_replies",
186
+ description: "Get 2nd-level replies for one top-level comment.",
187
+ schema: {
188
+ parent_comment_id: z.string().min(1),
189
+ max_results: z.number().int().min(1).max(1000).optional(),
190
+ page_token: z.string().min(1).optional(),
191
+ },
192
+ remotePath: "api/comments/replies",
193
+ },
194
+ {
195
+ name: "get_quota_usage",
196
+ description: "Get YouTube API quota usage for today or a specific date.",
197
+ schema: {
198
+ date: z
199
+ .string()
200
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
201
+ .optional(),
202
+ },
203
+ remotePath: "api/quota",
204
+ },
205
+ {
206
+ name: "get_patch_notes",
207
+ description: "Read server patch notes for latest MCP/API updates.",
208
+ schema: {
209
+ max_chars: z.number().int().min(500).max(50_000).optional(),
210
+ },
211
+ remotePath: "api/patch-notes",
212
+ },
213
+ ];
214
+ export function registerRemoteTools(server, config, tokenManager) {
215
+ for (const def of REMOTE_TOOLS) {
216
+ createRemoteTool(server, def, tokenManager, config.api_url);
217
+ }
218
+ }
@@ -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 registerSubtitleTools(server: McpServer, config: YtMcpConfig, tokenManager: TokenManager): void;