@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/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
- "((bv*[height<=1080])/bv*)+ba/b",
158
- "--merge-output-format",
159
- "mp4",
160
- "-o",
161
- outputTemplate,
162
- url,
163
- ]
164
- );
165
-
166
- // Get the final file size
167
- const finalPath = filename ?
168
- path.join(DOWNLOADS_DIR, `${filename}.mp4`) :
169
- path.join(DOWNLOADS_DIR, `${title}.mp4`);
170
- const stats = fs.statSync(finalPath);
171
- const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
172
-
173
- return {
174
- content: [
175
- {
176
- type: "text",
177
- text: `Successfully downloaded video:
178
- Title: ${title}
179
- Duration: ${duration} seconds
180
- Resolution: ${resolution}
181
- File size: ${fileSizeMB} MB
182
- Saved to: ${finalPath}`,
183
- },
184
- ],
185
- };
186
- } catch (err) {
187
- return {
188
- content: [
189
- {
190
- type: "text",
191
- text: `Error downloading video: ${err}`,
192
- },
193
- ],
194
- isError: true,
195
- };
196
- }
197
- } else {
198
- throw new Error(`Unknown tool: ${request.params.name}`);
199
- }
200
- });
201
-
202
- async function runServer() {
203
- const transport = new StdioServerTransport();
204
- await server.connect(transport);
205
- }
206
-
207
- runServer().catch(console.error);
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
+ }