youtube-knowledge-mcp 1.1.0 → 1.2.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/README.md +227 -175
- package/dist/http.d.ts +64 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +283 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +342 -164
- package/dist/index.js.map +1 -1
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +167 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resources.d.ts +10 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +72 -0
- package/dist/resources.js.map +1 -0
- package/dist/schemas.d.ts +326 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +128 -0
- package/dist/schemas.js.map +1 -0
- package/dist/tools/batch.d.ts +111 -0
- package/dist/tools/batch.d.ts.map +1 -0
- package/dist/tools/batch.js +187 -0
- package/dist/tools/batch.js.map +1 -0
- package/dist/tools/check-health.d.ts +59 -0
- package/dist/tools/check-health.d.ts.map +1 -0
- package/dist/tools/check-health.js +38 -0
- package/dist/tools/check-health.js.map +1 -0
- package/dist/tools/clips.d.ts +158 -0
- package/dist/tools/clips.d.ts.map +1 -0
- package/dist/tools/clips.js +279 -0
- package/dist/tools/clips.js.map +1 -0
- package/dist/tools/download-video.d.ts +43 -12
- package/dist/tools/download-video.d.ts.map +1 -1
- package/dist/tools/download-video.js +32 -3
- package/dist/tools/download-video.js.map +1 -1
- package/dist/tools/fetch-videos.d.ts +39 -7
- package/dist/tools/fetch-videos.d.ts.map +1 -1
- package/dist/tools/fetch-videos.js +20 -3
- package/dist/tools/fetch-videos.js.map +1 -1
- package/dist/tools/get-channel-info.d.ts +11 -7
- package/dist/tools/get-channel-info.d.ts.map +1 -1
- package/dist/tools/get-channel-info.js +5 -3
- package/dist/tools/get-channel-info.js.map +1 -1
- package/dist/tools/get-chapters.d.ts +28 -6
- package/dist/tools/get-chapters.d.ts.map +1 -1
- package/dist/tools/get-chapters.js +24 -4
- package/dist/tools/get-chapters.js.map +1 -1
- package/dist/tools/get-comments.d.ts +26 -7
- package/dist/tools/get-comments.d.ts.map +1 -1
- package/dist/tools/get-comments.js +10 -4
- package/dist/tools/get-comments.js.map +1 -1
- package/dist/tools/get-playlist-info.d.ts +13 -6
- package/dist/tools/get-playlist-info.d.ts.map +1 -1
- package/dist/tools/get-playlist-info.js +4 -2
- package/dist/tools/get-playlist-info.js.map +1 -1
- package/dist/tools/get-transcript.d.ts +42 -7
- package/dist/tools/get-transcript.d.ts.map +1 -1
- package/dist/tools/get-transcript.js +139 -10
- package/dist/tools/get-transcript.js.map +1 -1
- package/dist/tools/get-video-info.d.ts +17 -6
- package/dist/tools/get-video-info.d.ts.map +1 -1
- package/dist/tools/get-video-info.js +5 -2
- package/dist/tools/get-video-info.js.map +1 -1
- package/dist/tools/library.d.ts +127 -0
- package/dist/tools/library.d.ts.map +1 -0
- package/dist/tools/library.js +118 -0
- package/dist/tools/library.js.map +1 -0
- package/dist/tools/list-library.d.ts +40 -6
- package/dist/tools/list-library.d.ts.map +1 -1
- package/dist/tools/list-library.js +8 -2
- package/dist/tools/list-library.js.map +1 -1
- package/dist/tools/save-to-library.d.ts +14 -11
- package/dist/tools/save-to-library.d.ts.map +1 -1
- package/dist/tools/save-to-library.js +15 -8
- package/dist/tools/save-to-library.js.map +1 -1
- package/dist/tools/search-channels.d.ts +33 -7
- package/dist/tools/search-channels.d.ts.map +1 -1
- package/dist/tools/search-channels.js +11 -4
- package/dist/tools/search-channels.js.map +1 -1
- package/dist/tools/search-transcript.d.ts +52 -0
- package/dist/tools/search-transcript.d.ts.map +1 -0
- package/dist/tools/search-transcript.js +103 -0
- package/dist/tools/search-transcript.js.map +1 -0
- package/dist/tools/search-videos.d.ts +39 -7
- package/dist/tools/search-videos.d.ts.map +1 -1
- package/dist/tools/search-videos.js +21 -3
- package/dist/tools/search-videos.js.map +1 -1
- package/dist/utils/clips.d.ts +69 -0
- package/dist/utils/clips.d.ts.map +1 -0
- package/dist/utils/clips.js +202 -0
- package/dist/utils/clips.js.map +1 -0
- package/dist/utils/context.d.ts +24 -0
- package/dist/utils/context.d.ts.map +1 -0
- package/dist/utils/context.js +19 -0
- package/dist/utils/context.js.map +1 -0
- package/dist/utils/errors.d.ts +58 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +152 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/format.d.ts +34 -5
- package/dist/utils/format.d.ts.map +1 -1
- package/dist/utils/format.js +45 -0
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/preflight.d.ts +30 -0
- package/dist/utils/preflight.d.ts.map +1 -0
- package/dist/utils/preflight.js +79 -0
- package/dist/utils/preflight.js.map +1 -0
- package/dist/utils/search-index.d.ts +58 -0
- package/dist/utils/search-index.d.ts.map +1 -0
- package/dist/utils/search-index.js +192 -0
- package/dist/utils/search-index.js.map +1 -0
- package/dist/utils/storage.d.ts +83 -6
- package/dist/utils/storage.d.ts.map +1 -1
- package/dist/utils/storage.js +204 -21
- package/dist/utils/storage.js.map +1 -1
- package/dist/utils/transcript.d.ts +142 -0
- package/dist/utils/transcript.d.ts.map +1 -0
- package/dist/utils/transcript.js +216 -0
- package/dist/utils/transcript.js.map +1 -0
- package/dist/utils/validate.d.ts +16 -0
- package/dist/utils/validate.d.ts.map +1 -0
- package/dist/utils/validate.js +64 -0
- package/dist/utils/validate.js.map +1 -0
- package/dist/utils/youtube.d.ts +15 -1
- package/dist/utils/youtube.d.ts.map +1 -1
- package/dist/utils/youtube.js +231 -174
- package/dist/utils/youtube.js.map +1 -1
- package/dist/utils/ytdlp.d.ts +51 -0
- package/dist/utils/ytdlp.d.ts.map +1 -0
- package/dist/utils/ytdlp.js +191 -0
- package/dist/utils/ytdlp.js.map +1 -0
- package/package.json +19 -14
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 {
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|