@felores/mcp-video 0.5.3 → 0.5.4
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/.prettierrc +3 -3
- package/CODE_OF_CONDUCT.md +49 -49
- package/COPYING +7 -7
- package/README.md +186 -207
- package/eslint.config.mjs +88 -88
- package/lib/index.mjs +9 -8
- package/lib/index.mjs.map +1 -1
- package/package.json +28 -28
- package/src/index.mts +208 -207
- package/src/vtt2txt.mts +24 -24
- package/tsconfig.json +28 -28
- package/documentation/mcp-inspector-guide.md +0 -308
- package/documentation/mcp-llm-guide.md +0 -379
package/src/index.mts
CHANGED
@@ -1,207 +1,208 @@
|
|
1
|
-
#!/usr/bin/env node
|
2
|
-
|
3
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
4
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
5
|
-
import {
|
6
|
-
CallToolRequestSchema,
|
7
|
-
ListToolsRequestSchema,
|
8
|
-
} from "@modelcontextprotocol/sdk/types.js";
|
9
|
-
import * as os from "os";
|
10
|
-
import * as fs from "fs";
|
11
|
-
import * as path from "path";
|
12
|
-
import { spawnPromise } from "spawn-rx";
|
13
|
-
import { rimraf } from "rimraf";
|
14
|
-
import { cleanSubtitles } from "./vtt2txt.mjs";
|
15
|
-
|
16
|
-
// Get downloads directory from env or default to project root
|
17
|
-
const DOWNLOADS_DIR = process.env.DOWNLOADS_DIR || path.join(process.cwd(), "downloads");
|
18
|
-
console.error('Using downloads directory:', DOWNLOADS_DIR);
|
19
|
-
|
20
|
-
const server = new Server(
|
21
|
-
{
|
22
|
-
name: "mcp-video",
|
23
|
-
version: "0.5.1",
|
24
|
-
},
|
25
|
-
{
|
26
|
-
capabilities: {
|
27
|
-
tools: {},
|
28
|
-
},
|
29
|
-
}
|
30
|
-
);
|
31
|
-
|
32
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
33
|
-
return {
|
34
|
-
tools: [
|
35
|
-
{
|
36
|
-
name: "get_video_transcript",
|
37
|
-
description: "Download and process video subtitles for analysis from various platforms (YouTube, Vimeo, Twitter/X, TikTok, etc.) using yt-dlp. Use this tool when asked to summarize, analyze, or extract information from any video that has subtitles/closed captions. This enables Claude to understand video content through subtitles.",
|
38
|
-
inputSchema: {
|
39
|
-
type: "object",
|
40
|
-
properties: {
|
41
|
-
url: { type: "string", description: "URL of the video from any supported platform (YouTube, Vimeo, Twitter/X, TikTok, etc.)" },
|
42
|
-
},
|
43
|
-
required: ["url"],
|
44
|
-
},
|
45
|
-
},
|
46
|
-
{
|
47
|
-
name: "download_video",
|
48
|
-
description: "Download video in best quality (limited to 1080p) from various platforms (YouTube, Vimeo, Twitter/X, TikTok, etc.) using yt-dlp. Downloads are stored in the downloads directory. IMPORTANT: Clients should always provide a sanitized filename using the platform and video ID format to ensure consistent naming and avoid conflicts.",
|
49
|
-
inputSchema: {
|
50
|
-
type: "object",
|
51
|
-
properties: {
|
52
|
-
url: { type: "string", description: "URL of the video from any supported platform (YouTube, Vimeo, Twitter/X, TikTok, etc.)" },
|
53
|
-
filename: {
|
54
|
-
type: "string",
|
55
|
-
description: "Sanitized filename using platform-id format. Examples:\n- YouTube: youtube-{video_id} (e.g. 'youtube-MhOTvvmlqLM' from youtube.com/watch?v=MhOTvvmlqLM)\n- Twitter/X: x-{tweet_id} (e.g. 'x-1876565449615217019' from x.com/user/status/1876565449615217019)\n- Vimeo: vimeo-{video_id} (e.g. 'vimeo-123456789' from vimeo.com/123456789)"
|
56
|
-
},
|
57
|
-
},
|
58
|
-
required: ["url"],
|
59
|
-
},
|
60
|
-
},
|
61
|
-
],
|
62
|
-
};
|
63
|
-
});
|
64
|
-
|
65
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
66
|
-
if (request.params.name === "get_video_transcript") {
|
67
|
-
try {
|
68
|
-
const { url } = request.params.arguments as { url: string };
|
69
|
-
|
70
|
-
const tempDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}youtube-`);
|
71
|
-
await spawnPromise(
|
72
|
-
"yt-dlp",
|
73
|
-
[
|
74
|
-
"--write-sub",
|
75
|
-
"--write-auto-sub",
|
76
|
-
"--sub-lang",
|
77
|
-
"en",
|
78
|
-
"--skip-download",
|
79
|
-
"--sub-format",
|
80
|
-
"srt",
|
81
|
-
url,
|
82
|
-
],
|
83
|
-
{ cwd: tempDir, detached: true }
|
84
|
-
);
|
85
|
-
|
86
|
-
let content = "";
|
87
|
-
try {
|
88
|
-
fs.readdirSync(tempDir).forEach((file) => {
|
89
|
-
const fileContent = fs.readFileSync(path.join(tempDir, file), "utf8");
|
90
|
-
const cleanedContent = cleanSubtitles(fileContent);
|
91
|
-
content += `${cleanedContent}\n\n`;
|
92
|
-
});
|
93
|
-
} finally {
|
94
|
-
rimraf.sync(tempDir);
|
95
|
-
}
|
96
|
-
|
97
|
-
return {
|
98
|
-
content: [
|
99
|
-
{
|
100
|
-
type: "text",
|
101
|
-
text: content,
|
102
|
-
},
|
103
|
-
],
|
104
|
-
};
|
105
|
-
} catch (err) {
|
106
|
-
return {
|
107
|
-
content: [
|
108
|
-
{
|
109
|
-
type: "text",
|
110
|
-
text: `Error downloading video: ${err}`,
|
111
|
-
},
|
112
|
-
],
|
113
|
-
isError: true,
|
114
|
-
};
|
115
|
-
}
|
116
|
-
} else if (request.params.name === "download_video") {
|
117
|
-
try {
|
118
|
-
const { url, filename } = request.params.arguments as { url: string; filename?: string };
|
119
|
-
|
120
|
-
// Create downloads directory if it doesn't exist
|
121
|
-
try {
|
122
|
-
if (!fs.existsSync(DOWNLOADS_DIR)) {
|
123
|
-
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
|
124
|
-
}
|
125
|
-
console.error('Downloads directory created/verified at:', DOWNLOADS_DIR);
|
126
|
-
} catch (err) {
|
127
|
-
console.error('Error creating downloads directory:', err);
|
128
|
-
throw err;
|
129
|
-
}
|
130
|
-
|
131
|
-
// Get video info first
|
132
|
-
const infoResult = await spawnPromise(
|
133
|
-
"yt-dlp",
|
134
|
-
[
|
135
|
-
"--print",
|
136
|
-
"%(title)s",
|
137
|
-
"--print",
|
138
|
-
"%(duration)s",
|
139
|
-
"--print",
|
140
|
-
"%(resolution)s",
|
141
|
-
url,
|
142
|
-
]
|
143
|
-
);
|
144
|
-
|
145
|
-
const [title, duration, resolution] = infoResult.split("\n");
|
146
|
-
|
147
|
-
// Prepare output template with absolute path
|
148
|
-
const outputTemplate = filename ?
|
149
|
-
path.join(DOWNLOADS_DIR, `${filename}.mp4`) :
|
150
|
-
path.join(DOWNLOADS_DIR, "%(title)s.%(ext)s");
|
151
|
-
|
152
|
-
// Download the video
|
153
|
-
await spawnPromise(
|
154
|
-
"yt-dlp",
|
155
|
-
[
|
156
|
-
"-f",
|
157
|
-
"
|
158
|
-
"--
|
159
|
-
"
|
160
|
-
"
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
path.join(DOWNLOADS_DIR, `${
|
170
|
-
|
171
|
-
const
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
}
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
5
|
+
import {
|
6
|
+
CallToolRequestSchema,
|
7
|
+
ListToolsRequestSchema,
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
9
|
+
import * as os from "os";
|
10
|
+
import * as fs from "fs";
|
11
|
+
import * as path from "path";
|
12
|
+
import { spawnPromise } from "spawn-rx";
|
13
|
+
import { rimraf } from "rimraf";
|
14
|
+
import { cleanSubtitles } from "./vtt2txt.mjs";
|
15
|
+
|
16
|
+
// Get downloads directory from env or default to project root
|
17
|
+
const DOWNLOADS_DIR = process.env.DOWNLOADS_DIR || path.join(process.cwd(), "downloads");
|
18
|
+
console.error('Using downloads directory:', DOWNLOADS_DIR);
|
19
|
+
|
20
|
+
const server = new Server(
|
21
|
+
{
|
22
|
+
name: "mcp-video",
|
23
|
+
version: "0.5.1",
|
24
|
+
},
|
25
|
+
{
|
26
|
+
capabilities: {
|
27
|
+
tools: {},
|
28
|
+
},
|
29
|
+
}
|
30
|
+
);
|
31
|
+
|
32
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
33
|
+
return {
|
34
|
+
tools: [
|
35
|
+
{
|
36
|
+
name: "get_video_transcript",
|
37
|
+
description: "Download and process video subtitles for analysis from various platforms (YouTube, Vimeo, Twitter/X, TikTok, etc.) using yt-dlp. Use this tool when asked to summarize, analyze, or extract information from any video that has subtitles/closed captions. This enables Claude to understand video content through subtitles.",
|
38
|
+
inputSchema: {
|
39
|
+
type: "object",
|
40
|
+
properties: {
|
41
|
+
url: { type: "string", description: "URL of the video from any supported platform (YouTube, Vimeo, Twitter/X, TikTok, etc.)" },
|
42
|
+
},
|
43
|
+
required: ["url"],
|
44
|
+
},
|
45
|
+
},
|
46
|
+
{
|
47
|
+
name: "download_video",
|
48
|
+
description: "Download video in best quality (limited to 1080p) from various platforms (YouTube, Vimeo, Twitter/X, TikTok, etc.) using yt-dlp. Downloads are stored in the downloads directory. IMPORTANT: Clients should always provide a sanitized filename using the platform and video ID format to ensure consistent naming and avoid conflicts.",
|
49
|
+
inputSchema: {
|
50
|
+
type: "object",
|
51
|
+
properties: {
|
52
|
+
url: { type: "string", description: "URL of the video from any supported platform (YouTube, Vimeo, Twitter/X, TikTok, etc.)" },
|
53
|
+
filename: {
|
54
|
+
type: "string",
|
55
|
+
description: "Sanitized filename using platform-id format. Examples:\n- YouTube: youtube-{video_id} (e.g. 'youtube-MhOTvvmlqLM' from youtube.com/watch?v=MhOTvvmlqLM)\n- Twitter/X: x-{tweet_id} (e.g. 'x-1876565449615217019' from x.com/user/status/1876565449615217019)\n- Vimeo: vimeo-{video_id} (e.g. 'vimeo-123456789' from vimeo.com/123456789)"
|
56
|
+
},
|
57
|
+
},
|
58
|
+
required: ["url"],
|
59
|
+
},
|
60
|
+
},
|
61
|
+
],
|
62
|
+
};
|
63
|
+
});
|
64
|
+
|
65
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
66
|
+
if (request.params.name === "get_video_transcript") {
|
67
|
+
try {
|
68
|
+
const { url } = request.params.arguments as { url: string };
|
69
|
+
|
70
|
+
const tempDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}youtube-`);
|
71
|
+
await spawnPromise(
|
72
|
+
"yt-dlp",
|
73
|
+
[
|
74
|
+
"--write-sub",
|
75
|
+
"--write-auto-sub",
|
76
|
+
"--sub-lang",
|
77
|
+
"en",
|
78
|
+
"--skip-download",
|
79
|
+
"--sub-format",
|
80
|
+
"srt",
|
81
|
+
url,
|
82
|
+
],
|
83
|
+
{ cwd: tempDir, detached: true }
|
84
|
+
);
|
85
|
+
|
86
|
+
let content = "";
|
87
|
+
try {
|
88
|
+
fs.readdirSync(tempDir).forEach((file) => {
|
89
|
+
const fileContent = fs.readFileSync(path.join(tempDir, file), "utf8");
|
90
|
+
const cleanedContent = cleanSubtitles(fileContent);
|
91
|
+
content += `${cleanedContent}\n\n`;
|
92
|
+
});
|
93
|
+
} finally {
|
94
|
+
rimraf.sync(tempDir);
|
95
|
+
}
|
96
|
+
|
97
|
+
return {
|
98
|
+
content: [
|
99
|
+
{
|
100
|
+
type: "text",
|
101
|
+
text: content,
|
102
|
+
},
|
103
|
+
],
|
104
|
+
};
|
105
|
+
} catch (err) {
|
106
|
+
return {
|
107
|
+
content: [
|
108
|
+
{
|
109
|
+
type: "text",
|
110
|
+
text: `Error downloading video: ${err}`,
|
111
|
+
},
|
112
|
+
],
|
113
|
+
isError: true,
|
114
|
+
};
|
115
|
+
}
|
116
|
+
} else if (request.params.name === "download_video") {
|
117
|
+
try {
|
118
|
+
const { url, filename } = request.params.arguments as { url: string; filename?: string };
|
119
|
+
|
120
|
+
// Create downloads directory if it doesn't exist
|
121
|
+
try {
|
122
|
+
if (!fs.existsSync(DOWNLOADS_DIR)) {
|
123
|
+
fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
|
124
|
+
}
|
125
|
+
console.error('Downloads directory created/verified at:', DOWNLOADS_DIR);
|
126
|
+
} catch (err) {
|
127
|
+
console.error('Error creating downloads directory:', err);
|
128
|
+
throw err;
|
129
|
+
}
|
130
|
+
|
131
|
+
// Get video info first
|
132
|
+
const infoResult = await spawnPromise(
|
133
|
+
"yt-dlp",
|
134
|
+
[
|
135
|
+
"--print",
|
136
|
+
"%(title)s",
|
137
|
+
"--print",
|
138
|
+
"%(duration)s",
|
139
|
+
"--print",
|
140
|
+
"%(resolution)s",
|
141
|
+
url,
|
142
|
+
]
|
143
|
+
);
|
144
|
+
|
145
|
+
const [title, duration, resolution] = infoResult.split("\n");
|
146
|
+
|
147
|
+
// Prepare output template with absolute path
|
148
|
+
const outputTemplate = filename ?
|
149
|
+
path.join(DOWNLOADS_DIR, `${filename}.mp4`) :
|
150
|
+
path.join(DOWNLOADS_DIR, "%(title)s.%(ext)s");
|
151
|
+
|
152
|
+
// Download the video
|
153
|
+
await spawnPromise(
|
154
|
+
"yt-dlp",
|
155
|
+
[
|
156
|
+
"-f",
|
157
|
+
"bv*+ba/b",
|
158
|
+
"--progress",
|
159
|
+
"--progress-template",
|
160
|
+
"download:[download] %(progress._percent_str)s of %(progress._total_bytes_str)s at %(progress._speed_str)s ETA %(progress._eta_str)s",
|
161
|
+
"-o",
|
162
|
+
outputTemplate,
|
163
|
+
url,
|
164
|
+
]
|
165
|
+
);
|
166
|
+
|
167
|
+
// Get the final file size
|
168
|
+
const finalPath = filename ?
|
169
|
+
path.join(DOWNLOADS_DIR, `${filename}.mp4`) :
|
170
|
+
path.join(DOWNLOADS_DIR, `${title}.mp4`);
|
171
|
+
const stats = fs.statSync(finalPath);
|
172
|
+
const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
|
173
|
+
|
174
|
+
return {
|
175
|
+
content: [
|
176
|
+
{
|
177
|
+
type: "text",
|
178
|
+
text: `Successfully downloaded video:
|
179
|
+
Title: ${title}
|
180
|
+
Duration: ${duration} seconds
|
181
|
+
Resolution: ${resolution}
|
182
|
+
File size: ${fileSizeMB} MB
|
183
|
+
Saved to: ${finalPath}`,
|
184
|
+
},
|
185
|
+
],
|
186
|
+
};
|
187
|
+
} catch (err) {
|
188
|
+
return {
|
189
|
+
content: [
|
190
|
+
{
|
191
|
+
type: "text",
|
192
|
+
text: `Error downloading video: ${err}`,
|
193
|
+
},
|
194
|
+
],
|
195
|
+
isError: true,
|
196
|
+
};
|
197
|
+
}
|
198
|
+
} else {
|
199
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
200
|
+
}
|
201
|
+
});
|
202
|
+
|
203
|
+
async function runServer() {
|
204
|
+
const transport = new StdioServerTransport();
|
205
|
+
await server.connect(transport);
|
206
|
+
}
|
207
|
+
|
208
|
+
runServer().catch(console.error);
|
package/src/vtt2txt.mts
CHANGED
@@ -1,25 +1,25 @@
|
|
1
|
-
export function cleanSubtitles(vttContent: string): string {
|
2
|
-
// Split into lines
|
3
|
-
const lines = vttContent.split('\n');
|
4
|
-
const cleanedText = new Set<string>(); // Use Set to remove duplicates
|
5
|
-
|
6
|
-
for (let line of lines) {
|
7
|
-
// Skip WebVTT header, timestamps, and empty lines
|
8
|
-
if (line.trim() === 'WEBVTT' || line.trim() === '' || /^\d{2}:\d{2}/.test(line)) {
|
9
|
-
continue;
|
10
|
-
}
|
11
|
-
|
12
|
-
// Remove HTML-style tags and clean the text
|
13
|
-
line = line.replace(/<[^>]*>/g, '')
|
14
|
-
.replace(/\[.*?\]/g, '') // Remove square brackets and their contents
|
15
|
-
.trim();
|
16
|
-
|
17
|
-
// If we have actual text, add it
|
18
|
-
if (line) {
|
19
|
-
cleanedText.add(line);
|
20
|
-
}
|
21
|
-
}
|
22
|
-
|
23
|
-
// Convert Set back to array and join with newlines
|
24
|
-
return Array.from(cleanedText).join('\n');
|
1
|
+
export function cleanSubtitles(vttContent: string): string {
|
2
|
+
// Split into lines
|
3
|
+
const lines = vttContent.split('\n');
|
4
|
+
const cleanedText = new Set<string>(); // Use Set to remove duplicates
|
5
|
+
|
6
|
+
for (let line of lines) {
|
7
|
+
// Skip WebVTT header, timestamps, and empty lines
|
8
|
+
if (line.trim() === 'WEBVTT' || line.trim() === '' || /^\d{2}:\d{2}/.test(line)) {
|
9
|
+
continue;
|
10
|
+
}
|
11
|
+
|
12
|
+
// Remove HTML-style tags and clean the text
|
13
|
+
line = line.replace(/<[^>]*>/g, '')
|
14
|
+
.replace(/\[.*?\]/g, '') // Remove square brackets and their contents
|
15
|
+
.trim();
|
16
|
+
|
17
|
+
// If we have actual text, add it
|
18
|
+
if (line) {
|
19
|
+
cleanedText.add(line);
|
20
|
+
}
|
21
|
+
}
|
22
|
+
|
23
|
+
// Convert Set back to array and join with newlines
|
24
|
+
return Array.from(cleanedText).join('\n');
|
25
25
|
}
|
package/tsconfig.json
CHANGED
@@ -1,28 +1,28 @@
|
|
1
|
-
{
|
2
|
-
"compilerOptions": {
|
3
|
-
"removeComments": false,
|
4
|
-
"preserveConstEnums": true,
|
5
|
-
"sourceMap": true,
|
6
|
-
"declaration": true,
|
7
|
-
"noImplicitAny": true,
|
8
|
-
"noImplicitReturns": true,
|
9
|
-
"strictNullChecks": true,
|
10
|
-
"noUnusedLocals": true,
|
11
|
-
"noImplicitThis": true,
|
12
|
-
"noUnusedParameters": true,
|
13
|
-
"module": "NodeNext",
|
14
|
-
"moduleResolution": "NodeNext",
|
15
|
-
"pretty": true,
|
16
|
-
"target": "ES2020",
|
17
|
-
"outDir": "lib",
|
18
|
-
"lib": ["es2020"],
|
19
|
-
"allowJs": true,
|
20
|
-
"checkJs": true,
|
21
|
-
"esModuleInterop": true
|
22
|
-
},
|
23
|
-
"ts-node": {
|
24
|
-
"esm": true
|
25
|
-
},
|
26
|
-
"include": ["src/**/*.mts", "src/**/*.ts"],
|
27
|
-
"exclude": ["node_modules", "lib"]
|
28
|
-
}
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
"removeComments": false,
|
4
|
+
"preserveConstEnums": true,
|
5
|
+
"sourceMap": true,
|
6
|
+
"declaration": true,
|
7
|
+
"noImplicitAny": true,
|
8
|
+
"noImplicitReturns": true,
|
9
|
+
"strictNullChecks": true,
|
10
|
+
"noUnusedLocals": true,
|
11
|
+
"noImplicitThis": true,
|
12
|
+
"noUnusedParameters": true,
|
13
|
+
"module": "NodeNext",
|
14
|
+
"moduleResolution": "NodeNext",
|
15
|
+
"pretty": true,
|
16
|
+
"target": "ES2020",
|
17
|
+
"outDir": "lib",
|
18
|
+
"lib": ["es2020"],
|
19
|
+
"allowJs": true,
|
20
|
+
"checkJs": true,
|
21
|
+
"esModuleInterop": true
|
22
|
+
},
|
23
|
+
"ts-node": {
|
24
|
+
"esm": true
|
25
|
+
},
|
26
|
+
"include": ["src/**/*.mts", "src/**/*.ts"],
|
27
|
+
"exclude": ["node_modules", "lib"]
|
28
|
+
}
|