youtube-knowledge-mcp 1.1.1 → 2.0.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.
Files changed (134) hide show
  1. package/README.md +221 -177
  2. package/dist/http.d.ts +64 -0
  3. package/dist/http.d.ts.map +1 -0
  4. package/dist/http.js +283 -0
  5. package/dist/http.js.map +1 -0
  6. package/dist/index.d.ts +19 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +342 -164
  9. package/dist/index.js.map +1 -1
  10. package/dist/prompts.d.ts +3 -0
  11. package/dist/prompts.d.ts.map +1 -0
  12. package/dist/prompts.js +167 -0
  13. package/dist/prompts.js.map +1 -0
  14. package/dist/resources.d.ts +10 -0
  15. package/dist/resources.d.ts.map +1 -0
  16. package/dist/resources.js +72 -0
  17. package/dist/resources.js.map +1 -0
  18. package/dist/schemas.d.ts +326 -0
  19. package/dist/schemas.d.ts.map +1 -0
  20. package/dist/schemas.js +128 -0
  21. package/dist/schemas.js.map +1 -0
  22. package/dist/tools/batch.d.ts +111 -0
  23. package/dist/tools/batch.d.ts.map +1 -0
  24. package/dist/tools/batch.js +187 -0
  25. package/dist/tools/batch.js.map +1 -0
  26. package/dist/tools/check-health.d.ts +59 -0
  27. package/dist/tools/check-health.d.ts.map +1 -0
  28. package/dist/tools/check-health.js +38 -0
  29. package/dist/tools/check-health.js.map +1 -0
  30. package/dist/tools/clips.d.ts +158 -0
  31. package/dist/tools/clips.d.ts.map +1 -0
  32. package/dist/tools/clips.js +279 -0
  33. package/dist/tools/clips.js.map +1 -0
  34. package/dist/tools/download-video.d.ts +43 -12
  35. package/dist/tools/download-video.d.ts.map +1 -1
  36. package/dist/tools/download-video.js +32 -3
  37. package/dist/tools/download-video.js.map +1 -1
  38. package/dist/tools/fetch-videos.d.ts +39 -7
  39. package/dist/tools/fetch-videos.d.ts.map +1 -1
  40. package/dist/tools/fetch-videos.js +20 -3
  41. package/dist/tools/fetch-videos.js.map +1 -1
  42. package/dist/tools/get-channel-info.d.ts +11 -7
  43. package/dist/tools/get-channel-info.d.ts.map +1 -1
  44. package/dist/tools/get-channel-info.js +5 -3
  45. package/dist/tools/get-channel-info.js.map +1 -1
  46. package/dist/tools/get-chapters.d.ts +28 -6
  47. package/dist/tools/get-chapters.d.ts.map +1 -1
  48. package/dist/tools/get-chapters.js +24 -4
  49. package/dist/tools/get-chapters.js.map +1 -1
  50. package/dist/tools/get-comments.d.ts +26 -7
  51. package/dist/tools/get-comments.d.ts.map +1 -1
  52. package/dist/tools/get-comments.js +10 -4
  53. package/dist/tools/get-comments.js.map +1 -1
  54. package/dist/tools/get-playlist-info.d.ts +13 -6
  55. package/dist/tools/get-playlist-info.d.ts.map +1 -1
  56. package/dist/tools/get-playlist-info.js +4 -2
  57. package/dist/tools/get-playlist-info.js.map +1 -1
  58. package/dist/tools/get-transcript.d.ts +42 -7
  59. package/dist/tools/get-transcript.d.ts.map +1 -1
  60. package/dist/tools/get-transcript.js +139 -10
  61. package/dist/tools/get-transcript.js.map +1 -1
  62. package/dist/tools/get-video-info.d.ts +17 -6
  63. package/dist/tools/get-video-info.d.ts.map +1 -1
  64. package/dist/tools/get-video-info.js +5 -2
  65. package/dist/tools/get-video-info.js.map +1 -1
  66. package/dist/tools/library.d.ts +127 -0
  67. package/dist/tools/library.d.ts.map +1 -0
  68. package/dist/tools/library.js +118 -0
  69. package/dist/tools/library.js.map +1 -0
  70. package/dist/tools/list-library.d.ts +40 -6
  71. package/dist/tools/list-library.d.ts.map +1 -1
  72. package/dist/tools/list-library.js +8 -2
  73. package/dist/tools/list-library.js.map +1 -1
  74. package/dist/tools/save-to-library.d.ts +9 -6
  75. package/dist/tools/save-to-library.d.ts.map +1 -1
  76. package/dist/tools/save-to-library.js +9 -2
  77. package/dist/tools/save-to-library.js.map +1 -1
  78. package/dist/tools/search-channels.d.ts +33 -7
  79. package/dist/tools/search-channels.d.ts.map +1 -1
  80. package/dist/tools/search-channels.js +11 -4
  81. package/dist/tools/search-channels.js.map +1 -1
  82. package/dist/tools/search-transcript.d.ts +52 -0
  83. package/dist/tools/search-transcript.d.ts.map +1 -0
  84. package/dist/tools/search-transcript.js +103 -0
  85. package/dist/tools/search-transcript.js.map +1 -0
  86. package/dist/tools/search-videos.d.ts +39 -7
  87. package/dist/tools/search-videos.d.ts.map +1 -1
  88. package/dist/tools/search-videos.js +21 -3
  89. package/dist/tools/search-videos.js.map +1 -1
  90. package/dist/utils/clips.d.ts +69 -0
  91. package/dist/utils/clips.d.ts.map +1 -0
  92. package/dist/utils/clips.js +202 -0
  93. package/dist/utils/clips.js.map +1 -0
  94. package/dist/utils/context.d.ts +24 -0
  95. package/dist/utils/context.d.ts.map +1 -0
  96. package/dist/utils/context.js +19 -0
  97. package/dist/utils/context.js.map +1 -0
  98. package/dist/utils/errors.d.ts +58 -0
  99. package/dist/utils/errors.d.ts.map +1 -0
  100. package/dist/utils/errors.js +152 -0
  101. package/dist/utils/errors.js.map +1 -0
  102. package/dist/utils/format.d.ts +34 -5
  103. package/dist/utils/format.d.ts.map +1 -1
  104. package/dist/utils/format.js +45 -0
  105. package/dist/utils/format.js.map +1 -1
  106. package/dist/utils/preflight.d.ts +30 -0
  107. package/dist/utils/preflight.d.ts.map +1 -0
  108. package/dist/utils/preflight.js +79 -0
  109. package/dist/utils/preflight.js.map +1 -0
  110. package/dist/utils/search-index.d.ts +58 -0
  111. package/dist/utils/search-index.d.ts.map +1 -0
  112. package/dist/utils/search-index.js +192 -0
  113. package/dist/utils/search-index.js.map +1 -0
  114. package/dist/utils/storage.d.ts +83 -6
  115. package/dist/utils/storage.d.ts.map +1 -1
  116. package/dist/utils/storage.js +204 -21
  117. package/dist/utils/storage.js.map +1 -1
  118. package/dist/utils/transcript.d.ts +142 -0
  119. package/dist/utils/transcript.d.ts.map +1 -0
  120. package/dist/utils/transcript.js +216 -0
  121. package/dist/utils/transcript.js.map +1 -0
  122. package/dist/utils/validate.d.ts +16 -0
  123. package/dist/utils/validate.d.ts.map +1 -0
  124. package/dist/utils/validate.js +64 -0
  125. package/dist/utils/validate.js.map +1 -0
  126. package/dist/utils/youtube.d.ts +15 -1
  127. package/dist/utils/youtube.d.ts.map +1 -1
  128. package/dist/utils/youtube.js +231 -174
  129. package/dist/utils/youtube.js.map +1 -1
  130. package/dist/utils/ytdlp.d.ts +51 -0
  131. package/dist/utils/ytdlp.d.ts.map +1 -0
  132. package/dist/utils/ytdlp.js +191 -0
  133. package/dist/utils/ytdlp.js.map +1 -0
  134. package/package.json +20 -15
package/dist/index.js CHANGED
@@ -1,27 +1,40 @@
1
1
  #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto';
3
2
  import { readFileSync } from 'fs';
4
3
  import { dirname, join } from 'path';
5
- import { fileURLToPath } from 'url';
4
+ import { z } from 'zod';
5
+ import { fileURLToPath, pathToFileURL } from 'url';
6
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
- import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
10
- import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
11
- import { fetchVideosSchema, fetchVideosHandler } from './tools/fetch-videos.js';
12
- import { getVideoInfoSchema, getVideoInfoHandler } from './tools/get-video-info.js';
13
- import { getTranscriptSchema, getTranscriptHandler } from './tools/get-transcript.js';
14
- import { searchVideosSchema, searchVideosHandler } from './tools/search-videos.js';
15
- import { getChaptersSchema, getChaptersHandler } from './tools/get-chapters.js';
16
- import { getCommentsSchema, getCommentsHandler } from './tools/get-comments.js';
17
- import { getChannelInfoSchema, getChannelInfoHandler } from './tools/get-channel-info.js';
18
- import { searchChannelsSchema, searchChannelsHandler } from './tools/search-channels.js';
19
- import { getPlaylistInfoSchema, getPlaylistInfoHandler } from './tools/get-playlist-info.js';
20
- import { saveToLibrarySchema, saveToLibraryHandler } from './tools/save-to-library.js';
21
- import { listLibrarySchema, listLibraryHandler } from './tools/list-library.js';
22
- import { listFormatsSchema, listFormatsHandler, downloadVideoSchema, downloadVideoHandler, } from './tools/download-video.js';
8
+ import { fetchVideosSchema, fetchVideosOutputSchema, fetchVideosHandler, } from './tools/fetch-videos.js';
9
+ import { getVideoInfoSchema, getVideoInfoOutputSchema, getVideoInfoHandler, } from './tools/get-video-info.js';
10
+ import { getTranscriptSchema, getTranscriptOutputSchema, getTranscriptHandler, } from './tools/get-transcript.js';
11
+ import { searchVideosSchema, searchVideosOutputSchema, searchVideosHandler, } from './tools/search-videos.js';
12
+ import { getChaptersSchema, getChaptersOutputSchema, getChaptersHandler, } from './tools/get-chapters.js';
13
+ import { getCommentsSchema, getCommentsOutputSchema, getCommentsHandler, } from './tools/get-comments.js';
14
+ import { getChannelInfoSchema, getChannelInfoOutputSchema, getChannelInfoHandler, } from './tools/get-channel-info.js';
15
+ import { searchChannelsSchema, searchChannelsOutputSchema, searchChannelsHandler, } from './tools/search-channels.js';
16
+ import { getPlaylistInfoSchema, getPlaylistInfoOutputSchema, getPlaylistInfoHandler, } from './tools/get-playlist-info.js';
17
+ import { saveToLibrarySchema, saveToLibraryOutputSchema, saveToLibraryHandler, } from './tools/save-to-library.js';
18
+ import { listLibrarySchema, listLibraryOutputSchema, listLibraryHandler, } from './tools/list-library.js';
19
+ import { listFormatsSchema, listFormatsOutputSchema, listFormatsHandler, downloadVideoSchema, downloadVideoOutputSchema, downloadVideoHandler, } from './tools/download-video.js';
20
+ import { checkHealthSchema, checkHealthOutputSchema, checkHealthHandler, } from './tools/check-health.js';
21
+ import { searchTranscriptSchema, searchTranscriptOutputSchema, searchTranscriptHandler, } from './tools/search-transcript.js';
22
+ import { getTranscriptsSchema, getTranscriptsOutputSchema, getTranscriptsHandler, digestPlaylistSchema, digestPlaylistOutputSchema, digestPlaylistHandler, } from './tools/batch.js';
23
+ import { extractClipSchema, extractClipOutputSchema, extractClipHandler, extractAudioClipSchema, extractAudioClipOutputSchema, extractAudioClipHandler, extractClipsSchema, extractClipsOutputSchema, extractClipsHandler, extractFrameSchema, extractFrameOutputSchema, extractFrameHandler, exportSubtitlesSchema, exportSubtitlesOutputSchema, exportSubtitlesHandler, } from './tools/clips.js';
24
+ import { getLibraryItemSchema, getLibraryItemOutputSchema, getLibraryItemHandler, searchLibrarySchema, searchLibraryOutputSchema, searchLibraryHandler, deleteLibraryItemSchema, deleteLibraryItemOutputSchema, deleteLibraryItemHandler, updateLibraryTagsSchema, updateLibraryTagsOutputSchema, updateLibraryTagsHandler, rebuildLibraryIndexSchema, rebuildLibraryIndexOutputSchema, rebuildLibraryIndexHandler, } from './tools/library.js';
25
+ import { runWithRequestContext } from './utils/context.js';
26
+ import { toToolError } from './utils/errors.js';
27
+ import { formatPreflightReport, runPreflight } from './utils/preflight.js';
28
+ import { startHttp } from './http.js';
29
+ import { registerPrompts } from './prompts.js';
30
+ import { registerResources } from './resources.js';
23
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
32
+ // The version is advertised in the MCP initialize response, so a package.json
33
+ // that has lost it should fail loudly at startup rather than telling every
34
+ // client the server is version `undefined`.
35
+ const pkg = z
36
+ .object({ version: z.string() })
37
+ .parse(JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')));
25
38
  const SERVER_INSTRUCTIONS = `YouTube Knowledge MCP provides tools to search, analyze, and extract knowledge from YouTube videos.
26
39
 
27
40
  Recommended workflows:
@@ -32,158 +45,420 @@ Recommended workflows:
32
45
  - Combine transcript + chapters for structured, timestamped summaries
33
46
 
34
47
  All tools accept YouTube video IDs (e.g., dQw4w9WgXcQ) or full URLs.`;
35
- function createServer(mode = 'stdio') {
36
- const server = new McpServer({ name: 'youtube-knowledge-mcp', version: pkg.version }, { instructions: SERVER_INSTRUCTIONS });
48
+ /**
49
+ * Wrap a tool handler so that every call gets a request context and no call can
50
+ * throw out of the server.
51
+ *
52
+ * Two things happen here, once, instead of thirteen times in thirteen files:
53
+ * the MCP request's AbortSignal is published to the request context so an
54
+ * in-flight yt-dlp is killed when the client cancels, and anything thrown is
55
+ * rendered as an `isError` result carrying an actionable message rather than a
56
+ * raw stack trace or yt-dlp command line.
57
+ */
58
+ /**
59
+ * The parts of the SDK's `RequestHandlerExtra` this server uses.
60
+ *
61
+ * `sendNotification` is described with `z.custom` because a function is not
62
+ * something Zod can take apart structurally — the predicate is a real runtime
63
+ * check, and it is what lets the property be called without asserting a type
64
+ * over it.
65
+ */
66
+ const requestExtraSchema = z.object({
67
+ signal: z.instanceof(AbortSignal).optional(),
68
+ sendNotification: z
69
+ .custom((value) => typeof value === 'function')
70
+ .optional(),
71
+ _meta: z.object({ progressToken: z.union([z.string(), z.number()]).optional() }).optional(),
72
+ });
73
+ function guarded(handler) {
74
+ return async (...args) => {
75
+ // Handlers declare only their own parameters; the SDK appends
76
+ // RequestHandlerExtra to every call, so it is whatever arrived last.
77
+ const parsed = requestExtraSchema.safeParse(args[args.length - 1]);
78
+ const context = parsed.success ? parsed.data : undefined;
79
+ const signal = context?.signal;
80
+ return runWithRequestContext({
81
+ signal,
82
+ // Progress is only meaningful when the client asked for it by sending a
83
+ // token; without one the notification would be dropped anyway.
84
+ reportProgress: context?.sendNotification && context._meta?.progressToken !== undefined
85
+ ? (progress, total, message) => {
86
+ void context.sendNotification?.({
87
+ method: 'notifications/progress',
88
+ params: {
89
+ progressToken: context._meta?.progressToken,
90
+ progress,
91
+ ...(total === undefined ? {} : { total }),
92
+ ...(message === undefined ? {} : { message }),
93
+ },
94
+ });
95
+ }
96
+ : undefined,
97
+ log: context?.sendNotification
98
+ ? (level, message) => {
99
+ void context.sendNotification?.({
100
+ method: 'notifications/message',
101
+ params: { level, logger: 'youtube-knowledge-mcp', data: message },
102
+ });
103
+ }
104
+ : undefined,
105
+ }, async () => {
106
+ try {
107
+ return await handler(...args);
108
+ }
109
+ catch (error) {
110
+ return toToolError(error);
111
+ }
112
+ });
113
+ };
114
+ }
115
+ /**
116
+ * Build a fully configured server.
117
+ *
118
+ * Exported so the test suite can drive it over an in-memory transport as a real
119
+ * MCP client, rather than only testing handlers in isolation.
120
+ */
121
+ export function createServer(mode = 'stdio') {
122
+ const server = new McpServer({ name: 'youtube-knowledge-mcp', version: pkg.version }, { instructions: SERVER_INSTRUCTIONS, capabilities: { logging: {} } });
37
123
  // -- Remote-safe tools (registered in all modes) --
38
124
  server.registerTool('search_videos', {
39
125
  title: 'Search YouTube Videos',
40
126
  description: 'Search YouTube for videos by keyword or phrase. Returns video IDs, titles, durations, channels, view counts, and URLs. Results sorted by relevance.',
41
127
  inputSchema: searchVideosSchema,
128
+ outputSchema: searchVideosOutputSchema,
42
129
  annotations: {
43
130
  readOnlyHint: true,
44
131
  destructiveHint: false,
45
132
  idempotentHint: true,
46
133
  openWorldHint: true,
47
134
  },
48
- }, searchVideosHandler);
135
+ }, guarded(searchVideosHandler));
49
136
  server.registerTool('fetch_videos', {
50
137
  title: 'Fetch YouTube Videos',
51
138
  description: 'List videos from a YouTube playlist or channel. Returns video IDs, titles, durations, upload dates, and URLs. Sorted by playlist or channel order.',
52
139
  inputSchema: fetchVideosSchema,
140
+ outputSchema: fetchVideosOutputSchema,
53
141
  annotations: {
54
142
  readOnlyHint: true,
55
143
  destructiveHint: false,
56
144
  idempotentHint: true,
57
145
  openWorldHint: true,
58
146
  },
59
- }, fetchVideosHandler);
147
+ }, guarded(fetchVideosHandler));
60
148
  server.registerTool('get_video_info', {
61
149
  title: 'Get YouTube Video Info',
62
150
  description: 'Get detailed metadata for a single YouTube video. Returns title, channel, duration, upload date, view count, like count, comment count, description, tags, and thumbnail URL.',
63
151
  inputSchema: getVideoInfoSchema,
152
+ outputSchema: getVideoInfoOutputSchema,
64
153
  annotations: {
65
154
  readOnlyHint: true,
66
155
  destructiveHint: false,
67
156
  idempotentHint: true,
68
157
  openWorldHint: true,
69
158
  },
70
- }, getVideoInfoHandler);
159
+ }, guarded(getVideoInfoHandler));
71
160
  server.registerTool('get_transcript', {
72
161
  title: 'Get YouTube Video Transcript',
73
162
  description: 'Extract the full transcript from a YouTube video. Supports auto-generated and manual captions. Returns plain text with word count and detected language. Results are cached locally.',
74
163
  inputSchema: getTranscriptSchema,
164
+ outputSchema: getTranscriptOutputSchema,
75
165
  annotations: {
76
166
  readOnlyHint: true,
77
167
  destructiveHint: false,
78
168
  idempotentHint: true,
79
169
  openWorldHint: true,
80
170
  },
81
- }, getTranscriptHandler);
171
+ }, guarded(getTranscriptHandler));
82
172
  server.registerTool('get_chapters', {
83
173
  title: 'Get YouTube Video Chapters',
84
174
  description: 'Extract chapter markers and timestamps from a YouTube video. Returns chapter titles with start and end times. Not all videos have chapters. Returns empty list if none found.',
85
175
  inputSchema: getChaptersSchema,
176
+ outputSchema: getChaptersOutputSchema,
86
177
  annotations: {
87
178
  readOnlyHint: true,
88
179
  destructiveHint: false,
89
180
  idempotentHint: true,
90
181
  openWorldHint: true,
91
182
  },
92
- }, getChaptersHandler);
183
+ }, guarded(getChaptersHandler));
93
184
  server.registerTool('get_comments', {
94
185
  title: 'Get YouTube Video Comments',
95
186
  description: 'Get top comments from a YouTube video sorted by popularity. Returns author, text, like count, and pinned status. Only top-level comments, no replies.',
96
187
  inputSchema: getCommentsSchema,
188
+ outputSchema: getCommentsOutputSchema,
97
189
  annotations: {
98
190
  readOnlyHint: true,
99
191
  destructiveHint: false,
100
192
  idempotentHint: true,
101
193
  openWorldHint: true,
102
194
  },
103
- }, getCommentsHandler);
195
+ }, guarded(getCommentsHandler));
104
196
  server.registerTool('get_channel_info', {
105
197
  title: 'Get YouTube Channel Info',
106
198
  description: 'Get metadata for a YouTube channel. Returns channel name, handle, subscriber count, description, and channel URL.',
107
199
  inputSchema: getChannelInfoSchema,
200
+ outputSchema: getChannelInfoOutputSchema,
108
201
  annotations: {
109
202
  readOnlyHint: true,
110
203
  destructiveHint: false,
111
204
  idempotentHint: true,
112
205
  openWorldHint: true,
113
206
  },
114
- }, getChannelInfoHandler);
207
+ }, guarded(getChannelInfoHandler));
115
208
  server.registerTool('search_channels', {
116
209
  title: 'Search YouTube Channels',
117
210
  description: 'Search YouTube for channels by keyword or phrase. Returns channel names, handles, subscriber counts, descriptions, and URLs.',
118
211
  inputSchema: searchChannelsSchema,
212
+ outputSchema: searchChannelsOutputSchema,
119
213
  annotations: {
120
214
  readOnlyHint: true,
121
215
  destructiveHint: false,
122
216
  idempotentHint: true,
123
217
  openWorldHint: true,
124
218
  },
125
- }, searchChannelsHandler);
219
+ }, guarded(searchChannelsHandler));
126
220
  server.registerTool('get_playlist_info', {
127
221
  title: 'Get YouTube Playlist Info',
128
222
  description: 'Get metadata for a YouTube playlist. Returns title, channel, video count, last updated date, and description.',
129
223
  inputSchema: getPlaylistInfoSchema,
224
+ outputSchema: getPlaylistInfoOutputSchema,
130
225
  annotations: {
131
226
  readOnlyHint: true,
132
227
  destructiveHint: false,
133
228
  idempotentHint: true,
134
229
  openWorldHint: true,
135
230
  },
136
- }, getPlaylistInfoHandler);
231
+ }, guarded(getPlaylistInfoHandler));
137
232
  server.registerTool('list_formats', {
138
233
  title: 'List YouTube Video Formats',
139
234
  description: 'List all available download formats for a YouTube video. Returns format IDs, extensions, resolutions, FPS, codecs, and file sizes. Grouped by video+audio, video-only, and audio-only.',
140
235
  inputSchema: listFormatsSchema,
236
+ outputSchema: listFormatsOutputSchema,
237
+ annotations: {
238
+ readOnlyHint: true,
239
+ destructiveHint: false,
240
+ idempotentHint: true,
241
+ openWorldHint: true,
242
+ },
243
+ }, guarded(listFormatsHandler));
244
+ server.registerTool('search_transcript', {
245
+ title: 'Search Inside a Transcript',
246
+ description: 'Find a phrase or pattern inside a video transcript and return each match with its timestamp and a link that opens the video at that moment. Use this instead of reading a whole transcript when you need to locate or cite a specific moment.',
247
+ inputSchema: searchTranscriptSchema,
248
+ outputSchema: searchTranscriptOutputSchema,
249
+ annotations: {
250
+ readOnlyHint: true,
251
+ destructiveHint: false,
252
+ idempotentHint: true,
253
+ openWorldHint: true,
254
+ },
255
+ }, guarded(searchTranscriptHandler));
256
+ server.registerTool('get_transcripts', {
257
+ title: 'Get Transcripts for Several Videos',
258
+ description: 'Fetch transcripts for up to 25 videos in one call, each capped so the batch cannot flood the context. Videos that have no captions are reported individually rather than failing the whole call.',
259
+ inputSchema: getTranscriptsSchema,
260
+ outputSchema: getTranscriptsOutputSchema,
141
261
  annotations: {
142
262
  readOnlyHint: true,
143
263
  destructiveHint: false,
144
264
  idempotentHint: true,
145
265
  openWorldHint: true,
146
266
  },
147
- }, listFormatsHandler);
267
+ }, guarded(getTranscriptsHandler));
268
+ server.registerTool('digest_playlist', {
269
+ title: 'Digest a Playlist or Channel',
270
+ description: 'Summarize a playlist or channel in one call: per-video metadata, chapter markers, and optionally transcript word counts. Use this to survey a body of content before deciding what to read in full.',
271
+ inputSchema: digestPlaylistSchema,
272
+ outputSchema: digestPlaylistOutputSchema,
273
+ annotations: {
274
+ readOnlyHint: true,
275
+ destructiveHint: false,
276
+ idempotentHint: true,
277
+ openWorldHint: true,
278
+ },
279
+ }, guarded(digestPlaylistHandler));
280
+ server.registerTool('check_health', {
281
+ title: 'Check Server Health',
282
+ description: 'Report whether yt-dlp and ffmpeg are installed, their versions, and whether yt-dlp is stale. Call this first when tools start failing unexpectedly — an outdated yt-dlp is the most common cause.',
283
+ inputSchema: checkHealthSchema,
284
+ outputSchema: checkHealthOutputSchema,
285
+ annotations: {
286
+ readOnlyHint: true,
287
+ destructiveHint: false,
288
+ idempotentHint: true,
289
+ openWorldHint: false,
290
+ },
291
+ }, guarded(checkHealthHandler));
148
292
  // -- Local-only tools (stdio mode only) --
149
293
  if (mode === 'stdio') {
150
294
  server.registerTool('save_to_library', {
151
295
  title: 'Save to YouTube Knowledge Library',
152
296
  description: 'Save a summary or skill note to the local YouTube knowledge library. Overwrites existing content of the same type for the same video. Returns the saved file path.',
153
297
  inputSchema: saveToLibrarySchema,
298
+ outputSchema: saveToLibraryOutputSchema,
154
299
  annotations: {
155
300
  readOnlyHint: false,
156
- destructiveHint: false,
301
+ // Overwrites an existing note of the same type in place, which is a
302
+ // destructive update: the previous content is not recoverable.
303
+ destructiveHint: true,
157
304
  idempotentHint: true,
158
305
  openWorldHint: false,
159
306
  },
160
- }, saveToLibraryHandler);
307
+ }, guarded(saveToLibraryHandler));
161
308
  server.registerTool('list_library', {
162
309
  title: 'List YouTube Knowledge Library',
163
310
  description: 'List all saved items in the local YouTube knowledge library. Returns titles, channels, content types, tags, and save dates. Optionally filter by tag. Sorted by most recently saved.',
164
311
  inputSchema: listLibrarySchema,
312
+ outputSchema: listLibraryOutputSchema,
165
313
  annotations: {
166
314
  readOnlyHint: true,
167
315
  destructiveHint: false,
168
316
  idempotentHint: true,
169
317
  openWorldHint: false,
170
318
  },
171
- }, listLibraryHandler);
319
+ }, guarded(listLibraryHandler));
320
+ server.registerTool('get_library_item', {
321
+ title: 'Read a Saved Library Item',
322
+ description: 'Read back a summary or skill note previously saved with save_to_library. Returns the markdown content plus the saved metadata.',
323
+ inputSchema: getLibraryItemSchema,
324
+ outputSchema: getLibraryItemOutputSchema,
325
+ annotations: {
326
+ readOnlyHint: true,
327
+ destructiveHint: false,
328
+ idempotentHint: true,
329
+ openWorldHint: false,
330
+ },
331
+ }, guarded(getLibraryItemHandler));
332
+ server.registerTool('search_library', {
333
+ title: 'Search the Knowledge Library',
334
+ description: 'Full-text search across every saved summary and skill note, ranked by relevance. Returns matching excerpts with the video IDs needed to read the full note.',
335
+ inputSchema: searchLibrarySchema,
336
+ outputSchema: searchLibraryOutputSchema,
337
+ annotations: {
338
+ readOnlyHint: true,
339
+ destructiveHint: false,
340
+ idempotentHint: true,
341
+ openWorldHint: false,
342
+ },
343
+ }, guarded(searchLibraryHandler));
344
+ server.registerTool('update_library_tags', {
345
+ title: 'Update Library Tags',
346
+ description: 'Add, remove or replace the tags on a saved library item. Tags are how list_library filters, so this is the way to reorganize a growing library. The replace parameter discards all existing tags.',
347
+ inputSchema: updateLibraryTagsSchema,
348
+ outputSchema: updateLibraryTagsOutputSchema,
349
+ annotations: {
350
+ readOnlyHint: false,
351
+ // The replace parameter discards every existing tag.
352
+ destructiveHint: true,
353
+ idempotentHint: true,
354
+ openWorldHint: false,
355
+ },
356
+ }, guarded(updateLibraryTagsHandler));
357
+ server.registerTool('delete_library_item', {
358
+ title: 'Delete a Library Item',
359
+ description: 'Permanently delete a saved summary or skill note, or the entire library entry for a video. This removes files from disk and cannot be undone.',
360
+ inputSchema: deleteLibraryItemSchema,
361
+ outputSchema: deleteLibraryItemOutputSchema,
362
+ annotations: {
363
+ readOnlyHint: false,
364
+ destructiveHint: true,
365
+ idempotentHint: true,
366
+ openWorldHint: false,
367
+ },
368
+ }, guarded(deleteLibraryItemHandler));
369
+ server.registerTool('rebuild_library_index', {
370
+ title: 'Rebuild the Library Search Index',
371
+ description: 'Rebuild the full-text search index from the notes on disk. Use this if search_library results look stale or incomplete, for example after editing files by hand.',
372
+ inputSchema: rebuildLibraryIndexSchema,
373
+ outputSchema: rebuildLibraryIndexOutputSchema,
374
+ annotations: {
375
+ readOnlyHint: false,
376
+ destructiveHint: false,
377
+ idempotentHint: true,
378
+ openWorldHint: false,
379
+ },
380
+ }, guarded(rebuildLibraryIndexHandler));
381
+ server.registerTool('extract_clip', {
382
+ title: 'Extract a Video Clip',
383
+ description: 'Cut a time range out of a YouTube video without downloading the whole thing. Give start and end, or a chapter name. Pair with search_transcript to find the moment first. Requires ffmpeg.',
384
+ inputSchema: extractClipSchema,
385
+ outputSchema: extractClipOutputSchema,
386
+ annotations: {
387
+ readOnlyHint: false,
388
+ destructiveHint: false,
389
+ idempotentHint: true,
390
+ openWorldHint: true,
391
+ },
392
+ }, guarded(extractClipHandler));
393
+ server.registerTool('extract_audio_clip', {
394
+ title: 'Extract an Audio Clip',
395
+ description: 'Cut a time range out of a video as audio only, in an editor-friendly format (mp3, m4a, wav, flac, opus). Use for podcast pulls and voice-over sourcing. Requires ffmpeg.',
396
+ inputSchema: extractAudioClipSchema,
397
+ outputSchema: extractAudioClipOutputSchema,
398
+ annotations: {
399
+ readOnlyHint: false,
400
+ destructiveHint: false,
401
+ idempotentHint: true,
402
+ openWorldHint: true,
403
+ },
404
+ }, guarded(extractAudioClipHandler));
405
+ server.registerTool('extract_clips', {
406
+ title: 'Extract Several Clips',
407
+ description: 'Cut several time ranges out of one video in a single call. A range that fails is reported individually rather than losing the clips that succeeded. Requires ffmpeg.',
408
+ inputSchema: extractClipsSchema,
409
+ outputSchema: extractClipsOutputSchema,
410
+ annotations: {
411
+ readOnlyHint: false,
412
+ destructiveHint: false,
413
+ idempotentHint: true,
414
+ openWorldHint: true,
415
+ },
416
+ }, guarded(extractClipsHandler));
417
+ server.registerTool('extract_frame', {
418
+ title: 'Capture a Frame',
419
+ description: 'Capture a single still image from a video at a given timestamp, without downloading the file. Use for thumbnails and reference frames. Requires ffmpeg.',
420
+ inputSchema: extractFrameSchema,
421
+ outputSchema: extractFrameOutputSchema,
422
+ annotations: {
423
+ readOnlyHint: false,
424
+ destructiveHint: false,
425
+ idempotentHint: true,
426
+ openWorldHint: true,
427
+ },
428
+ }, guarded(extractFrameHandler));
429
+ server.registerTool('export_subtitles', {
430
+ title: 'Export Subtitles',
431
+ description: 'Write a video transcript to disk as SRT, WebVTT or plain text, ready to import into a video editor such as Premiere, Resolve or CapCut.',
432
+ inputSchema: exportSubtitlesSchema,
433
+ outputSchema: exportSubtitlesOutputSchema,
434
+ annotations: {
435
+ readOnlyHint: false,
436
+ destructiveHint: false,
437
+ idempotentHint: true,
438
+ openWorldHint: true,
439
+ },
440
+ }, guarded(exportSubtitlesHandler));
172
441
  server.registerTool('download_video', {
173
442
  title: 'Download YouTube Video',
174
443
  description: 'Download a YouTube video to local disk. Use the quality parameter for automatic format selection with smart fallbacks, or formatId for a specific format from list_formats. Returns the downloaded file path, title, and format details.',
175
444
  inputSchema: downloadVideoSchema,
445
+ outputSchema: downloadVideoOutputSchema,
176
446
  annotations: {
177
447
  readOnlyHint: false,
178
448
  destructiveHint: false,
179
- idempotentHint: false,
449
+ // Writes to a deterministic path and overwrites, exactly like the
450
+ // extract_* tools; repeating the call leaves the same state.
451
+ idempotentHint: true,
180
452
  openWorldHint: true,
181
453
  },
182
- }, downloadVideoHandler);
454
+ }, guarded(downloadVideoHandler));
183
455
  }
456
+ registerPrompts(server, mode);
457
+ registerResources(server, mode);
184
458
  return server;
185
459
  }
186
- function getTransportMode() {
460
+ /** An explicit flag wins over the environment, so a launcher can always override. */
461
+ export function getTransportMode() {
187
462
  if (process.argv.includes('--http'))
188
463
  return 'http';
189
464
  if (process.argv.includes('--stdio'))
@@ -197,140 +472,43 @@ async function startStdio() {
197
472
  const transport = new StdioServerTransport();
198
473
  await server.connect(transport);
199
474
  }
200
- // Rate limiting for public HTTP server
201
- const RATE_LIMIT = 60;
202
- const RATE_WINDOW_MS = 60_000;
203
- const rateLimits = new Map();
204
- function checkRateLimit(ip) {
205
- const now = Date.now();
206
- const entry = rateLimits.get(ip);
207
- if (!entry || now > entry.resetAt) {
208
- rateLimits.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
209
- return true;
210
- }
211
- entry.count++;
212
- return entry.count <= RATE_LIMIT;
475
+ /**
476
+ * Report missing or stale external binaries at boot.
477
+ *
478
+ * Deliberately non-fatal: the server still starts and still lists its tools, so
479
+ * a client can call check_health and be told exactly what to install. Exiting
480
+ * here would surface as an opaque "server failed to start" in the client.
481
+ */
482
+ export async function announcePreflight() {
483
+ const report = await runPreflight();
484
+ if (report.ok && !report.ytDlp.warning && report.ffmpeg.installed)
485
+ return;
486
+ console.error(formatPreflightReport(report));
213
487
  }
214
- function startHttp() {
215
- const port = parseInt(process.env.PORT ?? '3000', 10);
216
- // Cleanup stale rate limit entries every 5 minutes
217
- setInterval(() => {
218
- const now = Date.now();
219
- for (const [ip, entry] of rateLimits) {
220
- if (now > entry.resetAt)
221
- rateLimits.delete(ip);
222
- }
223
- }, 300_000);
224
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- createMcpExpressApp returns untyped Express (no @types/express for v5)
225
- const app = createMcpExpressApp({ host: '0.0.0.0' });
226
- const transports = new Map();
227
- app.post('/mcp', async (req, res) => {
228
- // Rate limiting
229
- const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ??
230
- req.socket.remoteAddress ??
231
- 'unknown';
232
- if (!checkRateLimit(clientIp)) {
233
- res.writeHead(429, { 'Content-Type': 'application/json' });
234
- res.end(JSON.stringify({
235
- jsonrpc: '2.0',
236
- error: { code: -32000, message: 'Rate limit exceeded' },
237
- id: null,
238
- }));
239
- return;
240
- }
241
- const sessionId = req.headers['mcp-session-id'];
242
- try {
243
- const existingTransport = sessionId ? transports.get(sessionId) : undefined;
244
- if (existingTransport) {
245
- await existingTransport.handleRequest(req, res, req.body);
246
- return;
247
- }
248
- if (!sessionId && isInitializeRequest(req.body)) {
249
- const transport = new StreamableHTTPServerTransport({
250
- sessionIdGenerator: () => randomUUID(),
251
- onsessioninitialized: (sid) => {
252
- transports.set(sid, transport);
253
- },
254
- });
255
- transport.onclose = () => {
256
- const sid = transport.sessionId;
257
- if (sid)
258
- transports.delete(sid);
259
- };
260
- const server = createServer('http');
261
- await server.connect(transport);
262
- await transport.handleRequest(req, res, req.body);
263
- return;
264
- }
265
- res.writeHead(400, { 'Content-Type': 'application/json' });
266
- res.end(JSON.stringify({
267
- jsonrpc: '2.0',
268
- error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
269
- id: null,
270
- }));
271
- }
272
- catch (error) {
273
- console.error('Error handling MCP request:', error);
274
- if (!res.headersSent) {
275
- res.writeHead(500, { 'Content-Type': 'application/json' });
276
- res.end(JSON.stringify({
277
- jsonrpc: '2.0',
278
- error: { code: -32603, message: 'Internal server error' },
279
- id: null,
280
- }));
281
- }
282
- }
283
- });
284
- app.get('/mcp', async (req, res) => {
285
- const sessionId = req.headers['mcp-session-id'];
286
- const transport = sessionId ? transports.get(sessionId) : undefined;
287
- if (!transport) {
288
- res.writeHead(400, { 'Content-Type': 'text/plain' });
289
- res.end('Invalid or missing session ID');
290
- return;
291
- }
292
- await transport.handleRequest(req, res);
293
- });
294
- app.delete('/mcp', async (req, res) => {
295
- const sessionId = req.headers['mcp-session-id'];
296
- const transport = sessionId ? transports.get(sessionId) : undefined;
297
- if (!transport) {
298
- res.writeHead(400, { 'Content-Type': 'text/plain' });
299
- res.end('Invalid or missing session ID');
300
- return;
301
- }
302
- await transport.handleRequest(req, res);
303
- });
304
- app.get('/health', (_req, res) => {
305
- res.writeHead(200, { 'Content-Type': 'application/json' });
306
- res.end(JSON.stringify({ status: 'ok' }));
307
- });
308
- app.listen(port, () => {
309
- console.error(`MCP Streamable HTTP server listening on port ${port}`);
310
- console.error(`MCP endpoint: http://localhost:${port}/mcp`);
311
- });
312
- process.on('SIGINT', () => {
313
- const closeAll = async () => {
314
- for (const [sid, transport] of transports) {
315
- await transport.close();
316
- transports.delete(sid);
317
- }
318
- process.exit(0);
319
- };
320
- void closeAll();
321
- });
322
- }
323
- async function main() {
488
+ export async function main() {
324
489
  const mode = getTransportMode();
490
+ await announcePreflight();
325
491
  if (mode === 'http') {
326
- startHttp();
492
+ startHttp(createServer);
327
493
  }
328
494
  else {
329
495
  await startStdio();
330
496
  }
331
497
  }
332
- main().catch((error) => {
333
- console.error('Failed to start MCP server:', error);
334
- process.exit(1);
335
- });
498
+ // Only start when executed directly. Importing this module — which the test
499
+ // suite does — must not spawn a transport or read argv.
500
+ //
501
+ // The three lines below run only in a real process launch, which is what
502
+ // `scripts/smoke.mjs` does in CI: it boots `dist/index.js` as a subprocess and
503
+ // completes an MCP handshake against it. That exercises this path for real,
504
+ // but out of process, where the coverage instrumentation cannot see it.
505
+ /* v8 ignore start */
506
+ const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
507
+ if (invokedDirectly) {
508
+ main().catch((error) => {
509
+ console.error('Failed to start MCP server:', error);
510
+ process.exit(1);
511
+ });
512
+ }
513
+ /* v8 ignore stop */
336
514
  //# sourceMappingURL=index.js.map