mcp-video-analyzer 0.2.3 → 0.2.5

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # mcp-video-analyzer
2
2
 
3
+ <a href="https://glama.ai/mcp/servers/guimatheus92/mcp-video-analyzer">
4
+ <img width="380" height="200" src="https://glama.ai/mcp/servers/guimatheus92/mcp-video-analyzer/badge" alt="mcp-video-analyzer MCP server" />
5
+ </a>
6
+
7
+ Featured in [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers#-multimedia-process).
8
+
3
9
  MCP server for video analysis — extracts transcripts, key frames, and metadata from video URLs. Supports Loom, direct video files (.mp4, .webm), and more.
4
10
 
5
11
  No existing video MCP combines **transcripts + visual frames + metadata** in one tool. This one does.
@@ -10,4 +10,4 @@ export interface IOcrResult {
10
10
  *
11
11
  * Only includes results with meaningful text (confidence > 50%, text length > 3).
12
12
  */
13
- export declare function extractTextFromFrames(frames: IFrameResult[]): Promise<IOcrResult[]>;
13
+ export declare function extractTextFromFrames(frames: IFrameResult[], language?: string, onProgress?: (completed: number, total: number) => void): Promise<IOcrResult[]>;
@@ -4,14 +4,15 @@
4
4
  *
5
5
  * Only includes results with meaningful text (confidence > 50%, text length > 3).
6
6
  */
7
- export async function extractTextFromFrames(frames) {
7
+ export async function extractTextFromFrames(frames, language = 'eng+por', onProgress) {
8
8
  const Tesseract = await loadTesseract();
9
9
  if (!Tesseract)
10
10
  return [];
11
- const worker = await Tesseract.createWorker('eng');
11
+ const worker = await Tesseract.createWorker(language);
12
12
  try {
13
13
  const results = [];
14
- for (const frame of frames) {
14
+ for (let i = 0; i < frames.length; i++) {
15
+ const frame = frames[i];
15
16
  try {
16
17
  const { data: { text, confidence }, } = await worker.recognize(frame.filePath);
17
18
  const cleaned = text.trim();
@@ -26,6 +27,7 @@ export async function extractTextFromFrames(frames) {
26
27
  catch {
27
28
  // Skip frames that fail OCR
28
29
  }
30
+ onProgress?.(i + 1, frames.length);
29
31
  }
30
32
  return results;
31
33
  }
package/dist/server.js CHANGED
@@ -12,7 +12,7 @@ import { registerGetTranscript } from './tools/get-transcript.js';
12
12
  export function createServer() {
13
13
  const server = new FastMCP({
14
14
  name: 'mcp-video-analyzer',
15
- version: '0.2.3',
15
+ version: '0.2.5',
16
16
  instructions: `Video analysis MCP server. Extracts transcripts, key frames, metadata, comments, OCR text, and annotated timelines from video URLs.
17
17
 
18
18
  AUTOMATIC BEHAVIOR — Do NOT wait for the user to ask:
@@ -6,6 +6,7 @@ import { deduplicateFrames } from '../processors/frame-dedup.js';
6
6
  import { extractFrameBurst, parseTimestamp } from '../processors/frame-extractor.js';
7
7
  import { extractTextFromFrames } from '../processors/frame-ocr.js';
8
8
  import { optimizeFrames } from '../processors/image-optimizer.js';
9
+ import { createProgressReporter } from '../utils/progress.js';
9
10
  import { createTempDir } from '../utils/temp-files.js';
10
11
  const AnalyzeMomentSchema = z.object({
11
12
  url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
@@ -18,6 +19,10 @@ const AnalyzeMomentSchema = z.object({
18
19
  .default(10)
19
20
  .optional()
20
21
  .describe('Number of frames to extract in the range (default: 10)'),
22
+ ocrLanguage: z
23
+ .string()
24
+ .optional()
25
+ .describe('Tesseract OCR language codes (default: "eng+por"). Use "+" to combine: "eng+spa", "eng+fra+deu".'),
21
26
  });
22
27
  export function registerAnalyzeMoment(server) {
23
28
  server.addTool({
@@ -45,8 +50,10 @@ Requires video download capability for frame extraction.`,
45
50
  openWorldHint: true,
46
51
  },
47
52
  execute: async (args, { reportProgress }) => {
53
+ const progress = createProgressReporter(reportProgress);
48
54
  const { url, from, to } = args;
49
55
  const count = args.count ?? 10;
56
+ const ocrLanguage = args.ocrLanguage ?? 'eng+por';
50
57
  // Validate timestamps
51
58
  const fromSeconds = parseTimestamp(from);
52
59
  const toSeconds = parseTimestamp(to);
@@ -64,7 +71,7 @@ Requires video download capability for frame extraction.`,
64
71
  }
65
72
  const warnings = [];
66
73
  const tempDir = await createTempDir();
67
- await reportProgress({ progress: 0, total: 100 });
74
+ await progress(0, `Starting moment analysis (${from} → ${to})...`);
68
75
  // Fetch transcript and filter to time range
69
76
  const fullTranscript = await adapter.getTranscript(url).catch((e) => {
70
77
  warnings.push(`Failed to fetch transcript: ${e instanceof Error ? e.message : String(e)}`);
@@ -74,7 +81,7 @@ Requires video download capability for frame extraction.`,
74
81
  const entrySeconds = parseTimestampLoose(entry.time);
75
82
  return entrySeconds !== null && entrySeconds >= fromSeconds && entrySeconds <= toSeconds;
76
83
  });
77
- await reportProgress({ progress: 20, total: 100 });
84
+ await progress(15, 'Transcript filtered to time range');
78
85
  // Download video and extract burst frames
79
86
  if (!adapter.capabilities.videoDownload) {
80
87
  throw new UserError('Moment analysis requires video download capability. Use a direct video URL (.mp4, .webm, .mov).');
@@ -83,9 +90,9 @@ Requires video download capability for frame extraction.`,
83
90
  if (!videoPath) {
84
91
  throw new UserError('Failed to download video for moment analysis.');
85
92
  }
86
- await reportProgress({ progress: 40, total: 100 });
93
+ await progress(35, 'Video downloaded, extracting burst frames...');
87
94
  const rawFrames = await extractFrameBurst(videoPath, tempDir, from, to, count);
88
- await reportProgress({ progress: 60, total: 100 });
95
+ await progress(55, `Extracted ${rawFrames.length} frames, optimizing...`);
89
96
  // Optimize frames
90
97
  const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
91
98
  warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -101,16 +108,20 @@ Requires video download capability for frame extraction.`,
101
108
  if (frames.length < beforeDedup) {
102
109
  warnings.push(`Removed ${beforeDedup - frames.length} near-duplicate frames (${beforeDedup} → ${frames.length})`);
103
110
  }
104
- await reportProgress({ progress: 75, total: 100 });
111
+ await progress(70, 'Filtering and deduplicating frames...');
105
112
  // OCR
106
- const ocrResults = await extractTextFromFrames(frames).catch((e) => {
113
+ await progress(75, `Running OCR on ${frames.length} frames...`);
114
+ const ocrResults = await extractTextFromFrames(frames, ocrLanguage, (completed, total) => {
115
+ const pct = 75 + Math.round((completed / total) * 15);
116
+ progress(pct, `OCR: processing frame ${completed}/${total}...`);
117
+ }).catch((e) => {
107
118
  warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
108
119
  return [];
109
120
  });
110
- await reportProgress({ progress: 90, total: 100 });
121
+ await progress(92, 'Building annotated timeline...');
111
122
  // Build mini-timeline for this range
112
123
  const timeline = buildAnnotatedTimeline(transcriptSegment, frames, ocrResults);
113
- await reportProgress({ progress: 100, total: 100 });
124
+ await progress(100, 'Moment analysis complete');
114
125
  // Build response
115
126
  const textData = {
116
127
  range: { from, to, fromSeconds, toSeconds },
@@ -11,6 +11,7 @@ import { extractTextFromFrames } from '../processors/frame-ocr.js';
11
11
  import { optimizeFrames } from '../processors/image-optimizer.js';
12
12
  import { AnalysisCache, cacheKey } from '../utils/cache.js';
13
13
  import { filterAnalysisResult } from '../utils/field-filter.js';
14
+ import { createProgressReporter } from '../utils/progress.js';
14
15
  import { cleanupTempDir, createTempDir } from '../utils/temp-files.js';
15
16
  const cache = new AnalysisCache();
16
17
  const ANALYSIS_FIELDS = [
@@ -62,6 +63,10 @@ const AnalyzeOptionsSchema = z
62
63
  .default(false)
63
64
  .optional()
64
65
  .describe('Bypass cache and re-analyze the video'),
66
+ ocrLanguage: z
67
+ .string()
68
+ .optional()
69
+ .describe('Tesseract OCR language codes (default: "eng+por"). Use "+" to combine: "eng+spa", "eng+fra+deu". See Tesseract docs for codes.'),
65
70
  })
66
71
  .optional();
67
72
  const AnalyzeVideoSchema = z.object({
@@ -99,11 +104,13 @@ Use options.forceRefresh to bypass the cache.`,
99
104
  openWorldHint: true,
100
105
  },
101
106
  execute: async (args, { reportProgress }) => {
107
+ const progress = createProgressReporter(reportProgress);
102
108
  const { url, options } = args;
103
109
  const detail = options?.detail ?? 'standard';
104
110
  const forceRefresh = options?.forceRefresh ?? false;
105
111
  const fields = options?.fields;
106
112
  const threshold = options?.threshold ?? 0.1;
113
+ const ocrLanguage = options?.ocrLanguage ?? 'eng+por';
107
114
  // Resolve detail config
108
115
  const config = getDetailConfig(detail);
109
116
  const maxFrames = options?.maxFrames ?? config.maxFrames;
@@ -142,7 +149,7 @@ Use options.forceRefresh to bypass the cache.`,
142
149
  const warnings = [];
143
150
  let tempDir = null;
144
151
  try {
145
- await reportProgress({ progress: 0, total: 100 });
152
+ await progress(0, 'Starting video analysis...');
146
153
  // Fetch metadata, transcript, comments in parallel
147
154
  const [metadata, transcript, comments, chapters, aiSummary] = await Promise.all([
148
155
  adapter.getMetadata(url).catch((e) => {
@@ -166,7 +173,7 @@ Use options.forceRefresh to bypass the cache.`,
166
173
  adapter.getChapters(url).catch(() => []),
167
174
  adapter.getAiSummary(url).catch(() => null),
168
175
  ]);
169
- await reportProgress({ progress: 40, total: 100 });
176
+ await progress(35, 'Metadata and transcript fetched');
170
177
  // Apply transcript limit for brief mode
171
178
  const limitedTranscript = config.transcriptMaxEntries !== null
172
179
  ? transcript.slice(0, config.transcriptMaxEntries)
@@ -191,7 +198,7 @@ Use options.forceRefresh to bypass the cache.`,
191
198
  if (adapter.capabilities.videoDownload) {
192
199
  videoPath = await adapter.downloadVideo(url, tempDir);
193
200
  if (videoPath) {
194
- await reportProgress({ progress: 60, total: 100 });
201
+ await progress(50, 'Video downloaded, extracting frames...');
195
202
  // Probe duration if metadata didn't provide it
196
203
  if (metadata.duration === 0) {
197
204
  const duration = await probeVideoDuration(videoPath).catch(() => 0);
@@ -211,7 +218,7 @@ Use options.forceRefresh to bypass the cache.`,
211
218
  warnings.push(`Frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
212
219
  return [];
213
220
  });
214
- await reportProgress({ progress: 80, total: 100 });
221
+ await progress(70, `Extracted ${rawFrames.length} frames, optimizing...`);
215
222
  if (rawFrames.length > 0) {
216
223
  const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
217
224
  warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -227,7 +234,7 @@ Use options.forceRefresh to bypass the cache.`,
227
234
  }
228
235
  // Strategy 2: Browser-based extraction (fallback)
229
236
  if (!framesExtracted && metadata.duration > 0) {
230
- await reportProgress({ progress: 60, total: 100 });
237
+ await progress(50, 'Extracting frames via browser fallback...');
231
238
  const timestamps = generateTimestamps(metadata.duration, maxFrames);
232
239
  const browserFrames = await extractBrowserFrames(url, tempDir, {
233
240
  timestamps,
@@ -235,7 +242,7 @@ Use options.forceRefresh to bypass the cache.`,
235
242
  warnings.push(`Browser frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
236
243
  return [];
237
244
  });
238
- await reportProgress({ progress: 80, total: 100 });
245
+ await progress(70, `Browser extracted ${browserFrames.length} frames`);
239
246
  if (browserFrames.length > 0) {
240
247
  result.frames = browserFrames;
241
248
  framesExtracted = true;
@@ -264,18 +271,23 @@ Use options.forceRefresh to bypass the cache.`,
264
271
  if (result.frames.length < beforeDedup) {
265
272
  warnings.push(`Removed ${beforeDedup - result.frames.length} near-duplicate frames (${beforeDedup} → ${result.frames.length})`);
266
273
  }
267
- await reportProgress({ progress: 85, total: 100 });
274
+ await progress(80, 'Filtering and deduplicating frames...');
268
275
  // OCR: extract text visible on screen
269
276
  if (config.includeOcr) {
270
- result.ocrResults = await extractTextFromFrames(result.frames).catch((e) => {
277
+ await progress(85, `Running OCR on ${result.frames.length} frames...`);
278
+ result.ocrResults = await extractTextFromFrames(result.frames, ocrLanguage, (completed, total) => {
279
+ const pct = 85 + Math.round((completed / total) * 8);
280
+ progress(pct, `OCR: processing frame ${completed}/${total}...`);
281
+ }).catch((e) => {
271
282
  warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
272
283
  return [];
273
284
  });
274
285
  }
275
- await reportProgress({ progress: 95, total: 100 });
286
+ await progress(93, 'OCR complete');
276
287
  }
277
288
  // Build annotated timeline
278
289
  if (config.includeTimeline) {
290
+ await progress(95, 'Building annotated timeline...');
279
291
  result.timeline = buildAnnotatedTimeline(result.transcript, result.frames, result.ocrResults);
280
292
  }
281
293
  }
@@ -300,7 +312,7 @@ Use options.forceRefresh to bypass the cache.`,
300
312
  // Audio extraction or transcription failed — not critical
301
313
  }
302
314
  }
303
- await reportProgress({ progress: 100, total: 100 });
315
+ await progress(100, 'Analysis complete');
304
316
  // Cache the full result
305
317
  cache.set(key, result);
306
318
  // Apply field filter
@@ -4,8 +4,8 @@ import { getAdapter } from '../adapters/adapter.interface.js';
4
4
  import { extractBrowserFrames } from '../processors/browser-frame-extractor.js';
5
5
  import { extractFrameAt, parseTimestamp } from '../processors/frame-extractor.js';
6
6
  import { optimizeFrame } from '../processors/image-optimizer.js';
7
- import { createTempDir } from '../utils/temp-files.js';
8
- import { getTempFilePath } from '../utils/temp-files.js';
7
+ import { createProgressReporter } from '../utils/progress.js';
8
+ import { createTempDir, getTempFilePath } from '../utils/temp-files.js';
9
9
  const GetFrameAtSchema = z.object({
10
10
  url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
11
11
  timestamp: z
@@ -42,19 +42,20 @@ Returns: A single image of the video frame at the specified timestamp.`,
42
42
  openWorldHint: true,
43
43
  },
44
44
  execute: async (args, { reportProgress }) => {
45
+ const progress = createProgressReporter(reportProgress);
45
46
  const { url, timestamp } = args;
46
47
  const adapter = getAdapter(url);
47
- await reportProgress({ progress: 0, total: 100 });
48
+ await progress(0, 'Starting frame extraction...');
48
49
  const tempDir = await createTempDir();
49
50
  // Strategy 1: Download video + ffmpeg extraction
50
51
  if (adapter.capabilities.videoDownload) {
51
52
  const videoPath = await adapter.downloadVideo(url, tempDir);
52
53
  if (videoPath) {
53
- await reportProgress({ progress: 50, total: 100 });
54
+ await progress(50, `Extracting frame at ${timestamp}...`);
54
55
  const frame = await extractFrameAt(videoPath, tempDir, timestamp);
55
56
  const optimizedPath = getTempFilePath(tempDir, `opt_frame_at.jpg`);
56
57
  await optimizeFrame(frame.filePath, optimizedPath);
57
- await reportProgress({ progress: 100, total: 100 });
58
+ await progress(100, 'Frame extracted');
58
59
  return {
59
60
  content: [
60
61
  { type: 'text', text: `Frame extracted at ${timestamp}` },
@@ -64,13 +65,13 @@ Returns: A single image of the video frame at the specified timestamp.`,
64
65
  }
65
66
  }
66
67
  // Strategy 2: Browser-based extraction (fallback)
67
- await reportProgress({ progress: 30, total: 100 });
68
+ await progress(30, 'Extracting frame via browser fallback...');
68
69
  const seconds = parseTimestamp(timestamp);
69
70
  const browserFrames = await extractBrowserFrames(url, tempDir, {
70
71
  timestamps: [seconds],
71
72
  });
72
73
  if (browserFrames.length > 0) {
73
- await reportProgress({ progress: 100, total: 100 });
74
+ await progress(100, 'Frame extracted');
74
75
  return {
75
76
  content: [
76
77
  {
@@ -4,6 +4,7 @@ import { getAdapter } from '../adapters/adapter.interface.js';
4
4
  import { extractBrowserFrames } from '../processors/browser-frame-extractor.js';
5
5
  import { extractFrameBurst, parseTimestamp } from '../processors/frame-extractor.js';
6
6
  import { optimizeFrames } from '../processors/image-optimizer.js';
7
+ import { createProgressReporter } from '../utils/progress.js';
7
8
  import { createTempDir } from '../utils/temp-files.js';
8
9
  const GetFrameBurstSchema = z.object({
9
10
  url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
@@ -53,20 +54,21 @@ Returns: N images evenly distributed between the from and to timestamps.`,
53
54
  openWorldHint: true,
54
55
  },
55
56
  execute: async (args, { reportProgress }) => {
57
+ const progress = createProgressReporter(reportProgress);
56
58
  const { url, from, to, count } = args;
57
59
  const frameCount = count ?? 5;
58
60
  const adapter = getAdapter(url);
59
- await reportProgress({ progress: 0, total: 100 });
61
+ await progress(0, `Starting burst extraction (${from} → ${to})...`);
60
62
  const tempDir = await createTempDir();
61
63
  // Strategy 1: Download video + ffmpeg burst extraction
62
64
  if (adapter.capabilities.videoDownload) {
63
65
  const videoPath = await adapter.downloadVideo(url, tempDir);
64
66
  if (videoPath) {
65
- await reportProgress({ progress: 40, total: 100 });
67
+ await progress(40, 'Video downloaded, extracting burst frames...');
66
68
  const frames = await extractFrameBurst(videoPath, tempDir, from, to, frameCount);
67
- await reportProgress({ progress: 70, total: 100 });
69
+ await progress(70, `Extracted ${frames.length} frames, optimizing...`);
68
70
  const optimizedPaths = await optimizeFrames(frames.map((f) => f.filePath), tempDir);
69
- await reportProgress({ progress: 100, total: 100 });
71
+ await progress(100, 'Burst extraction complete');
70
72
  const content = [
71
73
  {
72
74
  type: 'text',
@@ -80,14 +82,14 @@ Returns: N images evenly distributed between the from and to timestamps.`,
80
82
  }
81
83
  }
82
84
  // Strategy 2: Browser-based extraction (fallback)
83
- await reportProgress({ progress: 30, total: 100 });
85
+ await progress(30, 'Extracting frames via browser fallback...');
84
86
  const fromSeconds = parseTimestamp(from);
85
87
  const toSeconds = parseTimestamp(to);
86
88
  const interval = (toSeconds - fromSeconds) / Math.max(frameCount - 1, 1);
87
89
  const timestamps = Array.from({ length: frameCount }, (_, i) => Math.round(fromSeconds + i * interval));
88
90
  const browserFrames = await extractBrowserFrames(url, tempDir, { timestamps });
89
91
  if (browserFrames.length > 0) {
90
- await reportProgress({ progress: 100, total: 100 });
92
+ await progress(100, 'Burst extraction complete');
91
93
  const content = [
92
94
  {
93
95
  type: 'text',
@@ -5,6 +5,7 @@ import { extractBrowserFrames, generateTimestamps } from '../processors/browser-
5
5
  import { deduplicateFrames, filterBlackFrames } from '../processors/frame-dedup.js';
6
6
  import { extractDenseFrames, extractSceneFrames, formatTimestamp, probeVideoDuration, } from '../processors/frame-extractor.js';
7
7
  import { optimizeFrames } from '../processors/image-optimizer.js';
8
+ import { createProgressReporter } from '../utils/progress.js';
8
9
  import { createTempDir } from '../utils/temp-files.js';
9
10
  const GetFramesSchema = z.object({
10
11
  url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
@@ -53,6 +54,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
53
54
  openWorldHint: true,
54
55
  },
55
56
  execute: async (args, { reportProgress }) => {
57
+ const progress = createProgressReporter(reportProgress);
56
58
  const { url, options } = args;
57
59
  const maxFrames = options?.maxFrames ?? 20;
58
60
  const threshold = options?.threshold ?? 0.1;
@@ -68,7 +70,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
68
70
  }
69
71
  const warnings = [];
70
72
  const tempDir = await createTempDir();
71
- await reportProgress({ progress: 0, total: 100 });
73
+ await progress(0, 'Starting frame extraction...');
72
74
  // Get metadata for duration (needed for browser fallback)
73
75
  const metadata = await adapter.getMetadata(url).catch(() => ({
74
76
  platform: adapter.name,
@@ -82,7 +84,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
82
84
  if (adapter.capabilities.videoDownload) {
83
85
  const videoPath = await adapter.downloadVideo(url, tempDir);
84
86
  if (videoPath) {
85
- await reportProgress({ progress: 50, total: 100 });
87
+ await progress(40, 'Video downloaded, extracting frames...');
86
88
  if (metadata.duration === 0) {
87
89
  const duration = await probeVideoDuration(videoPath).catch(() => 0);
88
90
  metadata.duration = duration;
@@ -108,7 +110,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
108
110
  }
109
111
  // Strategy 2: Browser fallback
110
112
  if (frames.length === 0 && metadata.duration > 0) {
111
- await reportProgress({ progress: 50, total: 100 });
113
+ await progress(40, 'Extracting frames via browser fallback...');
112
114
  const timestamps = generateTimestamps(metadata.duration, maxFrames);
113
115
  frames = await extractBrowserFrames(url, tempDir, { timestamps }).catch((e) => {
114
116
  warnings.push(`Browser extraction failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -116,6 +118,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
116
118
  });
117
119
  }
118
120
  // Filter black/blank frames
121
+ await progress(80, 'Filtering and deduplicating frames...');
119
122
  if (frames.length > 0) {
120
123
  const blackResult = await filterBlackFrames(frames).catch(() => ({
121
124
  frames,
@@ -134,7 +137,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
134
137
  warnings.push(`Removed ${before - frames.length} duplicate frames`);
135
138
  }
136
139
  }
137
- await reportProgress({ progress: 100, total: 100 });
140
+ await progress(100, 'Frames extracted');
138
141
  if (frames.length === 0) {
139
142
  throw new UserError('Could not extract any frames. Install yt-dlp or Chrome/Chromium for frame extraction.');
140
143
  }
@@ -1,6 +1,7 @@
1
1
  import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getAdapter } from '../adapters/adapter.interface.js';
4
+ import { createProgressReporter } from '../utils/progress.js';
4
5
  const GetMetadataSchema = z.object({
5
6
  url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
6
7
  });
@@ -21,7 +22,8 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
21
22
  idempotentHint: true,
22
23
  openWorldHint: true,
23
24
  },
24
- execute: async (args) => {
25
+ execute: async (args, { reportProgress }) => {
26
+ const progress = createProgressReporter(reportProgress);
25
27
  const { url } = args;
26
28
  let adapter;
27
29
  try {
@@ -33,6 +35,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
33
35
  throw new UserError(`Failed to detect video platform for URL: ${url}`);
34
36
  }
35
37
  const warnings = [];
38
+ await progress(0, 'Fetching video metadata...');
36
39
  const [metadata, comments, chapters, aiSummary] = await Promise.all([
37
40
  adapter.getMetadata(url).catch((e) => {
38
41
  warnings.push(`Failed to fetch metadata: ${e instanceof Error ? e.message : String(e)}`);
@@ -51,6 +54,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
51
54
  adapter.getChapters(url).catch(() => []),
52
55
  adapter.getAiSummary(url).catch(() => null),
53
56
  ]);
57
+ await progress(100, 'Metadata fetched');
54
58
  return {
55
59
  content: [
56
60
  {
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getAdapter } from '../adapters/adapter.interface.js';
4
4
  import { extractAudioTrack, transcribeAudio } from '../processors/audio-transcriber.js';
5
+ import { createProgressReporter } from '../utils/progress.js';
5
6
  import { cleanupTempDir, createTempDir } from '../utils/temp-files.js';
6
7
  const GetTranscriptSchema = z.object({
7
8
  url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
@@ -26,7 +27,8 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
26
27
  idempotentHint: true,
27
28
  openWorldHint: true,
28
29
  },
29
- execute: async (args) => {
30
+ execute: async (args, { reportProgress }) => {
31
+ const progress = createProgressReporter(reportProgress);
30
32
  const { url } = args;
31
33
  let adapter;
32
34
  try {
@@ -38,18 +40,22 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
38
40
  throw new UserError(`Failed to detect video platform for URL: ${url}`);
39
41
  }
40
42
  const warnings = [];
43
+ await progress(0, 'Fetching transcript...');
41
44
  // Try native transcript first
42
45
  let transcript = await adapter.getTranscript(url).catch((e) => {
43
46
  warnings.push(`Failed to fetch native transcript: ${e instanceof Error ? e.message : String(e)}`);
44
47
  return [];
45
48
  });
49
+ await progress(40, 'Native transcript fetched');
46
50
  // Whisper fallback if no native transcript
47
51
  if (transcript.length === 0 && adapter.capabilities.videoDownload) {
48
52
  let tempDir = null;
49
53
  try {
54
+ await progress(45, 'No native transcript, downloading video for Whisper...');
50
55
  tempDir = await createTempDir();
51
56
  const videoPath = await adapter.downloadVideo(url, tempDir);
52
57
  if (videoPath) {
58
+ await progress(65, 'Transcribing audio with Whisper...');
53
59
  const audioPath = await extractAudioTrack(videoPath, tempDir);
54
60
  transcript = await transcribeAudio(audioPath);
55
61
  if (transcript.length > 0) {
@@ -68,6 +74,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
68
74
  if (transcript.length === 0) {
69
75
  warnings.push('No transcript available for this video.');
70
76
  }
77
+ await progress(100, 'Transcript complete');
71
78
  return {
72
79
  content: [
73
80
  {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Progress reporting utility with descriptive messages.
3
+ *
4
+ * The MCP spec supports an optional `message` field in progress notifications.
5
+ * FastMCP's TypeScript type omits it, but the runtime passes it through via spread.
6
+ */
7
+ type ReportProgressFn = (progress: {
8
+ progress: number;
9
+ total?: number;
10
+ }) => Promise<void>;
11
+ export declare function createProgressReporter(reportProgress: ReportProgressFn, total?: number): (progress: number, message?: string) => Promise<void>;
12
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Progress reporting utility with descriptive messages.
3
+ *
4
+ * The MCP spec supports an optional `message` field in progress notifications.
5
+ * FastMCP's TypeScript type omits it, but the runtime passes it through via spread.
6
+ */
7
+ export function createProgressReporter(reportProgress, total = 100) {
8
+ return async (progress, message) => {
9
+ const payload = { progress, total };
10
+ if (message) {
11
+ payload.message = message;
12
+ }
13
+ await reportProgress(payload);
14
+ };
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-video-analyzer",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "MCP server for video analysis — extracts transcripts, key frames, OCR text, and metadata from video URLs. Supports Loom and direct video files.",
5
5
  "author": "guimatheus92",
6
6
  "license": "MIT",