mcp-video-analyzer 0.2.4 → 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.
@@ -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[], language?: string): 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, language = 'eng+por') {
7
+ export async function extractTextFromFrames(frames, language = 'eng+por', onProgress) {
8
8
  const Tesseract = await loadTesseract();
9
9
  if (!Tesseract)
10
10
  return [];
11
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, language = 'eng+por') {
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.4',
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)'),
@@ -49,6 +50,7 @@ Requires video download capability for frame extraction.`,
49
50
  openWorldHint: true,
50
51
  },
51
52
  execute: async (args, { reportProgress }) => {
53
+ const progress = createProgressReporter(reportProgress);
52
54
  const { url, from, to } = args;
53
55
  const count = args.count ?? 10;
54
56
  const ocrLanguage = args.ocrLanguage ?? 'eng+por';
@@ -69,7 +71,7 @@ Requires video download capability for frame extraction.`,
69
71
  }
70
72
  const warnings = [];
71
73
  const tempDir = await createTempDir();
72
- await reportProgress({ progress: 0, total: 100 });
74
+ await progress(0, `Starting moment analysis (${from} → ${to})...`);
73
75
  // Fetch transcript and filter to time range
74
76
  const fullTranscript = await adapter.getTranscript(url).catch((e) => {
75
77
  warnings.push(`Failed to fetch transcript: ${e instanceof Error ? e.message : String(e)}`);
@@ -79,7 +81,7 @@ Requires video download capability for frame extraction.`,
79
81
  const entrySeconds = parseTimestampLoose(entry.time);
80
82
  return entrySeconds !== null && entrySeconds >= fromSeconds && entrySeconds <= toSeconds;
81
83
  });
82
- await reportProgress({ progress: 20, total: 100 });
84
+ await progress(15, 'Transcript filtered to time range');
83
85
  // Download video and extract burst frames
84
86
  if (!adapter.capabilities.videoDownload) {
85
87
  throw new UserError('Moment analysis requires video download capability. Use a direct video URL (.mp4, .webm, .mov).');
@@ -88,9 +90,9 @@ Requires video download capability for frame extraction.`,
88
90
  if (!videoPath) {
89
91
  throw new UserError('Failed to download video for moment analysis.');
90
92
  }
91
- await reportProgress({ progress: 40, total: 100 });
93
+ await progress(35, 'Video downloaded, extracting burst frames...');
92
94
  const rawFrames = await extractFrameBurst(videoPath, tempDir, from, to, count);
93
- await reportProgress({ progress: 60, total: 100 });
95
+ await progress(55, `Extracted ${rawFrames.length} frames, optimizing...`);
94
96
  // Optimize frames
95
97
  const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
96
98
  warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -106,16 +108,20 @@ Requires video download capability for frame extraction.`,
106
108
  if (frames.length < beforeDedup) {
107
109
  warnings.push(`Removed ${beforeDedup - frames.length} near-duplicate frames (${beforeDedup} → ${frames.length})`);
108
110
  }
109
- await reportProgress({ progress: 75, total: 100 });
111
+ await progress(70, 'Filtering and deduplicating frames...');
110
112
  // OCR
111
- const ocrResults = await extractTextFromFrames(frames, ocrLanguage).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) => {
112
118
  warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
113
119
  return [];
114
120
  });
115
- await reportProgress({ progress: 90, total: 100 });
121
+ await progress(92, 'Building annotated timeline...');
116
122
  // Build mini-timeline for this range
117
123
  const timeline = buildAnnotatedTimeline(transcriptSegment, frames, ocrResults);
118
- await reportProgress({ progress: 100, total: 100 });
124
+ await progress(100, 'Moment analysis complete');
119
125
  // Build response
120
126
  const textData = {
121
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 = [
@@ -103,6 +104,7 @@ Use options.forceRefresh to bypass the cache.`,
103
104
  openWorldHint: true,
104
105
  },
105
106
  execute: async (args, { reportProgress }) => {
107
+ const progress = createProgressReporter(reportProgress);
106
108
  const { url, options } = args;
107
109
  const detail = options?.detail ?? 'standard';
108
110
  const forceRefresh = options?.forceRefresh ?? false;
@@ -147,7 +149,7 @@ Use options.forceRefresh to bypass the cache.`,
147
149
  const warnings = [];
148
150
  let tempDir = null;
149
151
  try {
150
- await reportProgress({ progress: 0, total: 100 });
152
+ await progress(0, 'Starting video analysis...');
151
153
  // Fetch metadata, transcript, comments in parallel
152
154
  const [metadata, transcript, comments, chapters, aiSummary] = await Promise.all([
153
155
  adapter.getMetadata(url).catch((e) => {
@@ -171,7 +173,7 @@ Use options.forceRefresh to bypass the cache.`,
171
173
  adapter.getChapters(url).catch(() => []),
172
174
  adapter.getAiSummary(url).catch(() => null),
173
175
  ]);
174
- await reportProgress({ progress: 40, total: 100 });
176
+ await progress(35, 'Metadata and transcript fetched');
175
177
  // Apply transcript limit for brief mode
176
178
  const limitedTranscript = config.transcriptMaxEntries !== null
177
179
  ? transcript.slice(0, config.transcriptMaxEntries)
@@ -196,7 +198,7 @@ Use options.forceRefresh to bypass the cache.`,
196
198
  if (adapter.capabilities.videoDownload) {
197
199
  videoPath = await adapter.downloadVideo(url, tempDir);
198
200
  if (videoPath) {
199
- await reportProgress({ progress: 60, total: 100 });
201
+ await progress(50, 'Video downloaded, extracting frames...');
200
202
  // Probe duration if metadata didn't provide it
201
203
  if (metadata.duration === 0) {
202
204
  const duration = await probeVideoDuration(videoPath).catch(() => 0);
@@ -216,7 +218,7 @@ Use options.forceRefresh to bypass the cache.`,
216
218
  warnings.push(`Frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
217
219
  return [];
218
220
  });
219
- await reportProgress({ progress: 80, total: 100 });
221
+ await progress(70, `Extracted ${rawFrames.length} frames, optimizing...`);
220
222
  if (rawFrames.length > 0) {
221
223
  const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
222
224
  warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -232,7 +234,7 @@ Use options.forceRefresh to bypass the cache.`,
232
234
  }
233
235
  // Strategy 2: Browser-based extraction (fallback)
234
236
  if (!framesExtracted && metadata.duration > 0) {
235
- await reportProgress({ progress: 60, total: 100 });
237
+ await progress(50, 'Extracting frames via browser fallback...');
236
238
  const timestamps = generateTimestamps(metadata.duration, maxFrames);
237
239
  const browserFrames = await extractBrowserFrames(url, tempDir, {
238
240
  timestamps,
@@ -240,7 +242,7 @@ Use options.forceRefresh to bypass the cache.`,
240
242
  warnings.push(`Browser frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
241
243
  return [];
242
244
  });
243
- await reportProgress({ progress: 80, total: 100 });
245
+ await progress(70, `Browser extracted ${browserFrames.length} frames`);
244
246
  if (browserFrames.length > 0) {
245
247
  result.frames = browserFrames;
246
248
  framesExtracted = true;
@@ -269,18 +271,23 @@ Use options.forceRefresh to bypass the cache.`,
269
271
  if (result.frames.length < beforeDedup) {
270
272
  warnings.push(`Removed ${beforeDedup - result.frames.length} near-duplicate frames (${beforeDedup} → ${result.frames.length})`);
271
273
  }
272
- await reportProgress({ progress: 85, total: 100 });
274
+ await progress(80, 'Filtering and deduplicating frames...');
273
275
  // OCR: extract text visible on screen
274
276
  if (config.includeOcr) {
275
- result.ocrResults = await extractTextFromFrames(result.frames, ocrLanguage).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) => {
276
282
  warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
277
283
  return [];
278
284
  });
279
285
  }
280
- await reportProgress({ progress: 95, total: 100 });
286
+ await progress(93, 'OCR complete');
281
287
  }
282
288
  // Build annotated timeline
283
289
  if (config.includeTimeline) {
290
+ await progress(95, 'Building annotated timeline...');
284
291
  result.timeline = buildAnnotatedTimeline(result.transcript, result.frames, result.ocrResults);
285
292
  }
286
293
  }
@@ -305,7 +312,7 @@ Use options.forceRefresh to bypass the cache.`,
305
312
  // Audio extraction or transcription failed — not critical
306
313
  }
307
314
  }
308
- await reportProgress({ progress: 100, total: 100 });
315
+ await progress(100, 'Analysis complete');
309
316
  // Cache the full result
310
317
  cache.set(key, result);
311
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.4",
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",