@sylphx/video-reader-mcp 0.1.0 → 0.1.1
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 +222 -41
- package/bin/native/video-reader-mcp-server +0 -0
- package/bin/video-reader-mcp +48 -0
- package/dist/doctor-cli.js +167 -0
- package/package.json +23 -10
- package/src/doctor-cli.ts +18 -0
- package/src/doctor.ts +132 -0
- package/src/engine/rust-asr.ts +98 -0
- package/src/engine/rust-frames.ts +95 -0
- package/src/engine/rust-timeline.ts +164 -0
- package/src/engine/rust-video-evidence.ts +132 -0
- package/src/handlers/readVideo.ts +41 -0
- package/src/handlers/videoEvidence.ts +161 -0
- package/src/mcp.ts +302 -0
- package/src/schemas/readVideo.ts +67 -0
- package/src/schemas/videoEvidence.ts +53 -0
- package/src/sdk.ts +44 -0
- package/src/types/timeline.ts +106 -0
- package/src/utils/asr.ts +59 -0
- package/src/utils/errors.ts +16 -0
- package/src/utils/exec.ts +76 -0
- package/src/utils/ffmpeg.ts +81 -0
- package/src/utils/ffprobe.ts +137 -0
- package/src/utils/frameOcr.ts +99 -0
- package/src/utils/frames.ts +93 -0
- package/src/utils/pathUtils.ts +36 -0
- package/src/utils/scenes.ts +63 -0
- package/src/utils/subtitles.ts +101 -0
- package/src/video/readCoordinator.ts +165 -0
- package/dist/handlers/readVideo.js +0 -24
- package/dist/index.js +0 -59
- package/dist/mcp.js +0 -181
- package/dist/schemas/readVideo.js +0 -33
- package/dist/types/timeline.js +0 -1
- package/dist/utils/asr.js +0 -26
- package/dist/utils/errors.js +0 -14
- package/dist/utils/exec.js +0 -56
- package/dist/utils/ffmpeg.js +0 -66
- package/dist/utils/ffprobe.js +0 -79
- package/dist/utils/pathUtils.js +0 -31
- package/dist/utils/scenes.js +0 -49
- package/dist/utils/subtitles.js +0 -85
- package/dist/video/readCoordinator.js +0 -81
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SceneInfo } from '../types/timeline.js';
|
|
2
|
+
import { execBinary, isBinaryAvailable } from './exec.js';
|
|
3
|
+
|
|
4
|
+
const SCENE_PTS_TIME_RE = /pts_time:([0-9.]+)/g;
|
|
5
|
+
|
|
6
|
+
export const parseSceneFilterOutput = (stderr: string, threshold: number): SceneInfo[] => {
|
|
7
|
+
const scenes: SceneInfo[] = [];
|
|
8
|
+
let match: RegExpExecArray | null;
|
|
9
|
+
|
|
10
|
+
while ((match = SCENE_PTS_TIME_RE.exec(stderr)) !== null) {
|
|
11
|
+
const seconds = Number.parseFloat(match[1]);
|
|
12
|
+
if (!Number.isFinite(seconds)) continue;
|
|
13
|
+
|
|
14
|
+
scenes.push({
|
|
15
|
+
index: scenes.length,
|
|
16
|
+
time_ms: Math.round(seconds * 1000),
|
|
17
|
+
provenance: {
|
|
18
|
+
method: 'ffmpeg_scene_filter',
|
|
19
|
+
threshold,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return scenes;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const detectScenes = async (
|
|
28
|
+
videoPath: string,
|
|
29
|
+
threshold: number
|
|
30
|
+
): Promise<{ scenes: SceneInfo[]; warning?: string }> => {
|
|
31
|
+
const available = await isBinaryAvailable('ffmpeg');
|
|
32
|
+
if (!available) {
|
|
33
|
+
return {
|
|
34
|
+
scenes: [],
|
|
35
|
+
warning: 'ffmpeg is not installed; scene detection skipped.',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const { stderr } = await execBinary(
|
|
41
|
+
'ffmpeg',
|
|
42
|
+
[
|
|
43
|
+
'-hide_banner',
|
|
44
|
+
'-i',
|
|
45
|
+
videoPath,
|
|
46
|
+
'-vf',
|
|
47
|
+
`select='gt(scene,${threshold})',showinfo`,
|
|
48
|
+
'-f',
|
|
49
|
+
'null',
|
|
50
|
+
'-',
|
|
51
|
+
],
|
|
52
|
+
{ timeoutMs: 300_000 }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return { scenes: parseSceneFilterOutput(stderr, threshold) };
|
|
56
|
+
} catch (error: unknown) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
return {
|
|
59
|
+
scenes: [],
|
|
60
|
+
warning: `Scene detection failed: ${message}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { SubtitleCue } from '../types/timeline.js';
|
|
2
|
+
|
|
3
|
+
const parseTimestampToMs = (timestamp: string): number => {
|
|
4
|
+
const normalized = timestamp.trim().replace(',', '.');
|
|
5
|
+
const parts = normalized.split(':');
|
|
6
|
+
if (parts.length !== 3) return 0;
|
|
7
|
+
|
|
8
|
+
const [hours, minutes, secondsPart] = parts;
|
|
9
|
+
const [seconds, millis = '0'] = secondsPart.split('.');
|
|
10
|
+
const h = Number.parseInt(hours, 10);
|
|
11
|
+
const m = Number.parseInt(minutes, 10);
|
|
12
|
+
const s = Number.parseInt(seconds, 10);
|
|
13
|
+
const ms = Number.parseInt(millis.padEnd(3, '0').slice(0, 3), 10);
|
|
14
|
+
|
|
15
|
+
return ((h * 60 + m) * 60 + s) * 1000 + ms;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const stripTags = (text: string): string =>
|
|
19
|
+
text
|
|
20
|
+
.replace(/<[^>]+>/g, '')
|
|
21
|
+
.replace(/\{[^}]+\}/g, '')
|
|
22
|
+
.trim();
|
|
23
|
+
|
|
24
|
+
export const parseSrt = (content: string): SubtitleCue[] => {
|
|
25
|
+
const blocks = content
|
|
26
|
+
.replace(/\r\n/g, '\n')
|
|
27
|
+
.split(/\n{2,}/)
|
|
28
|
+
.map((block) => block.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
|
|
31
|
+
const cues: SubtitleCue[] = [];
|
|
32
|
+
|
|
33
|
+
for (const block of blocks) {
|
|
34
|
+
const lines = block.split('\n');
|
|
35
|
+
if (lines.length < 2) continue;
|
|
36
|
+
|
|
37
|
+
const timingLine = lines.find((line) => line.includes('-->'));
|
|
38
|
+
if (!timingLine) continue;
|
|
39
|
+
|
|
40
|
+
const [startRaw, endRaw] = timingLine.split('-->').map((part) => part.trim());
|
|
41
|
+
const textLines = lines.filter((line) => line !== lines[0] && !line.includes('-->'));
|
|
42
|
+
const text = stripTags(textLines.join('\n'));
|
|
43
|
+
if (!text) continue;
|
|
44
|
+
|
|
45
|
+
cues.push({
|
|
46
|
+
index: cues.length,
|
|
47
|
+
start_ms: parseTimestampToMs(startRaw),
|
|
48
|
+
end_ms: parseTimestampToMs(endRaw),
|
|
49
|
+
text,
|
|
50
|
+
provenance: {
|
|
51
|
+
method: 'embedded_parse',
|
|
52
|
+
format: 'srt',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return cues;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const parseVtt = (content: string): SubtitleCue[] => {
|
|
61
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
62
|
+
const blocks = normalized
|
|
63
|
+
.split(/\n{2,}/)
|
|
64
|
+
.map((block) => block.trim())
|
|
65
|
+
.filter((block) => block && !block.startsWith('WEBVTT') && !block.startsWith('NOTE'));
|
|
66
|
+
|
|
67
|
+
const cues: SubtitleCue[] = [];
|
|
68
|
+
|
|
69
|
+
for (const block of blocks) {
|
|
70
|
+
const lines = block.split('\n');
|
|
71
|
+
const timingLine = lines.find((line) => line.includes('-->'));
|
|
72
|
+
if (!timingLine) continue;
|
|
73
|
+
|
|
74
|
+
const timing = timingLine.split('-->')[0]?.trim() ?? '';
|
|
75
|
+
const endTiming = timingLine.split('-->')[1]?.trim().split(' ')[0] ?? '';
|
|
76
|
+
const textLines = lines.filter((line) => !line.includes('-->') && !/^\d+$/.test(line));
|
|
77
|
+
const text = stripTags(textLines.join('\n'));
|
|
78
|
+
if (!text) continue;
|
|
79
|
+
|
|
80
|
+
cues.push({
|
|
81
|
+
index: cues.length,
|
|
82
|
+
start_ms: parseTimestampToMs(timing),
|
|
83
|
+
end_ms: parseTimestampToMs(endTiming),
|
|
84
|
+
text,
|
|
85
|
+
provenance: {
|
|
86
|
+
method: 'embedded_parse',
|
|
87
|
+
format: 'vtt',
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return cues;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const parseSubtitleContent = (
|
|
96
|
+
content: string,
|
|
97
|
+
format: 'srt' | 'vtt' | 'webvtt'
|
|
98
|
+
): SubtitleCue[] => {
|
|
99
|
+
if (format === 'srt') return parseSrt(content);
|
|
100
|
+
return parseVtt(content);
|
|
101
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
assembleProbeTimelineViaRustEngine,
|
|
4
|
+
buildCacheKeyViaRustEngine,
|
|
5
|
+
hashSourceViaRustEngine,
|
|
6
|
+
shouldUseRustTimelineEngine,
|
|
7
|
+
} from '../engine/rust-timeline.js';
|
|
8
|
+
import type { ReadVideoArgs } from '../schemas/readVideo.js';
|
|
9
|
+
import type { TimelineDocument, VideoSourceResult } from '../types/timeline.js';
|
|
10
|
+
import { tryAsrTranscript } from '../utils/asr.js';
|
|
11
|
+
import { extractSubtitles } from '../utils/ffmpeg.js';
|
|
12
|
+
import {
|
|
13
|
+
collectProbeWarnings,
|
|
14
|
+
findSubtitleStreams,
|
|
15
|
+
mapChapters,
|
|
16
|
+
mapStreams,
|
|
17
|
+
runFfprobe,
|
|
18
|
+
secondsToMs,
|
|
19
|
+
} from '../utils/ffprobe.js';
|
|
20
|
+
import { extractKeyframes } from '../utils/frames.js';
|
|
21
|
+
import { resolvePath } from '../utils/pathUtils.js';
|
|
22
|
+
import { detectScenes } from '../utils/scenes.js';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SCENE_THRESHOLD = 0.4;
|
|
25
|
+
const DEFAULT_KEYFRAME_LIMIT = 8;
|
|
26
|
+
|
|
27
|
+
export const buildTimelineDocument = async (
|
|
28
|
+
sourcePath: string,
|
|
29
|
+
args: ReadVideoArgs,
|
|
30
|
+
version: string
|
|
31
|
+
): Promise<TimelineDocument> => {
|
|
32
|
+
const includeStreams = args.include_streams ?? true;
|
|
33
|
+
const includeChapters = args.include_chapters ?? true;
|
|
34
|
+
const includeSubtitles = args.include_subtitles ?? true;
|
|
35
|
+
const includeScenes = args.include_scenes ?? true;
|
|
36
|
+
const includeTranscript = args.include_transcript ?? false;
|
|
37
|
+
const includeKeyframes = args.include_keyframes ?? false;
|
|
38
|
+
const includeKeyframeImages = args.include_keyframe_images ?? false;
|
|
39
|
+
const keyframeLimit = args.keyframe_limit ?? DEFAULT_KEYFRAME_LIMIT;
|
|
40
|
+
const keyframeMaxDimension = args.keyframe_max_dimension;
|
|
41
|
+
const sceneThreshold = args.scene_threshold ?? DEFAULT_SCENE_THRESHOLD;
|
|
42
|
+
|
|
43
|
+
const warnings: string[] = [];
|
|
44
|
+
const probe = await runFfprobe(sourcePath);
|
|
45
|
+
|
|
46
|
+
let format: TimelineDocument['format'];
|
|
47
|
+
let streams: TimelineDocument['streams'];
|
|
48
|
+
let chapters: TimelineDocument['chapters'];
|
|
49
|
+
let assemblyRoute = 'typescript-timeline-v1';
|
|
50
|
+
let sourceHash: string | undefined;
|
|
51
|
+
let cacheKey: string | undefined;
|
|
52
|
+
|
|
53
|
+
if (shouldUseRustTimelineEngine()) {
|
|
54
|
+
const assembled = assembleProbeTimelineViaRustEngine(probe, {
|
|
55
|
+
includeStreams,
|
|
56
|
+
includeChapters,
|
|
57
|
+
});
|
|
58
|
+
format = assembled.format;
|
|
59
|
+
streams = assembled.streams;
|
|
60
|
+
chapters = assembled.chapters;
|
|
61
|
+
warnings.push(...assembled.warnings);
|
|
62
|
+
assemblyRoute = assembled.route;
|
|
63
|
+
sourceHash = hashSourceViaRustEngine(sourcePath);
|
|
64
|
+
cacheKey = buildCacheKeyViaRustEngine(sourceHash, {
|
|
65
|
+
includeStreams,
|
|
66
|
+
includeChapters,
|
|
67
|
+
includeSubtitles,
|
|
68
|
+
includeScenes,
|
|
69
|
+
includeTranscript,
|
|
70
|
+
includeKeyframes,
|
|
71
|
+
includeKeyframeImages,
|
|
72
|
+
keyframeLimit,
|
|
73
|
+
keyframeMaxDimension,
|
|
74
|
+
sceneThreshold,
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
warnings.push(...collectProbeWarnings(probe, includeStreams));
|
|
78
|
+
format = {
|
|
79
|
+
...(probe.format.format_name ? { format_name: probe.format.format_name } : {}),
|
|
80
|
+
duration_ms: secondsToMs(probe.format.duration),
|
|
81
|
+
...(probe.format.bit_rate ? { bit_rate: Number.parseInt(probe.format.bit_rate, 10) } : {}),
|
|
82
|
+
...(probe.format.size ? { size_bytes: Number.parseInt(probe.format.size, 10) } : {}),
|
|
83
|
+
...(probe.format.tags ? { tags: probe.format.tags } : {}),
|
|
84
|
+
};
|
|
85
|
+
streams = includeStreams ? mapStreams(probe.streams) : [];
|
|
86
|
+
chapters = includeChapters ? mapChapters(probe.chapters) : [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let subtitles: TimelineDocument['subtitles'] = [];
|
|
90
|
+
if (includeSubtitles) {
|
|
91
|
+
const subtitleStreams = findSubtitleStreams(probe.streams);
|
|
92
|
+
const extracted = await extractSubtitles(sourcePath, subtitleStreams);
|
|
93
|
+
subtitles = extracted.subtitles;
|
|
94
|
+
warnings.push(...extracted.warnings);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let scenes: TimelineDocument['scenes'] = [];
|
|
98
|
+
if (includeScenes) {
|
|
99
|
+
const detected = await detectScenes(sourcePath, sceneThreshold);
|
|
100
|
+
scenes = detected.scenes;
|
|
101
|
+
if (detected.warning) warnings.push(detected.warning);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let transcript: TimelineDocument['transcript'] = [];
|
|
105
|
+
if (includeTranscript) {
|
|
106
|
+
const asr = await tryAsrTranscript(sourcePath, true);
|
|
107
|
+
transcript = asr.transcript;
|
|
108
|
+
if (asr.warning) warnings.push(asr.warning);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let keyframes: TimelineDocument['keyframes'] = [];
|
|
112
|
+
if (includeKeyframes) {
|
|
113
|
+
const extracted = await extractKeyframes(sourcePath, keyframeLimit, {
|
|
114
|
+
includeImages: includeKeyframeImages,
|
|
115
|
+
...(keyframeMaxDimension !== undefined ? { maxDimension: keyframeMaxDimension } : {}),
|
|
116
|
+
});
|
|
117
|
+
keyframes = extracted.keyframes;
|
|
118
|
+
if (extracted.warning) warnings.push(extracted.warning);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
provenance: {
|
|
123
|
+
source: sourcePath,
|
|
124
|
+
tool: 'read_video',
|
|
125
|
+
version,
|
|
126
|
+
extracted_at: new Date().toISOString(),
|
|
127
|
+
...(sourceHash ? { source_hash: sourceHash } : {}),
|
|
128
|
+
...(cacheKey ? { cache_key: cacheKey } : {}),
|
|
129
|
+
...(shouldUseRustTimelineEngine() ? { assembly_route: assemblyRoute } : {}),
|
|
130
|
+
},
|
|
131
|
+
format,
|
|
132
|
+
streams,
|
|
133
|
+
chapters,
|
|
134
|
+
scenes,
|
|
135
|
+
subtitles,
|
|
136
|
+
transcript,
|
|
137
|
+
keyframes,
|
|
138
|
+
warnings,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const processVideoSource = async (
|
|
143
|
+
userPath: string,
|
|
144
|
+
args: ReadVideoArgs,
|
|
145
|
+
version: string
|
|
146
|
+
): Promise<VideoSourceResult> => {
|
|
147
|
+
try {
|
|
148
|
+
const sourcePath = resolvePath(userPath);
|
|
149
|
+
await access(sourcePath);
|
|
150
|
+
|
|
151
|
+
const data = await buildTimelineDocument(sourcePath, args, version);
|
|
152
|
+
return {
|
|
153
|
+
source: userPath,
|
|
154
|
+
success: true,
|
|
155
|
+
data,
|
|
156
|
+
};
|
|
157
|
+
} catch (error: unknown) {
|
|
158
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
159
|
+
return {
|
|
160
|
+
source: userPath,
|
|
161
|
+
success: false,
|
|
162
|
+
error: message,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
};
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { text, tool, toolError } from '../mcp.js';
|
|
2
|
-
import { readVideoArgsSchema } from '../schemas/readVideo.js';
|
|
3
|
-
import { processVideoSource } from '../video/readCoordinator.js';
|
|
4
|
-
const MAX_CONCURRENT_SOURCES = 2;
|
|
5
|
-
export const createReadVideoHandler = (version) => tool()
|
|
6
|
-
.description('Primary video reader. Returns a timeline document with ffprobe metadata, embedded subtitles, optional scene boundaries, and warnings — no per-frame vision LLM.')
|
|
7
|
-
.input(readVideoArgsSchema)
|
|
8
|
-
.handler(async ({ input }) => {
|
|
9
|
-
const results = [];
|
|
10
|
-
for (let i = 0; i < input.sources.length; i += MAX_CONCURRENT_SOURCES) {
|
|
11
|
-
const batch = input.sources.slice(i, i + MAX_CONCURRENT_SOURCES);
|
|
12
|
-
const batchResults = await Promise.all(batch.map((source) => processVideoSource(source.path, input, version)));
|
|
13
|
-
results.push(...batchResults);
|
|
14
|
-
}
|
|
15
|
-
const allFailed = results.every((result) => !result.success);
|
|
16
|
-
if (allFailed) {
|
|
17
|
-
const errorMessages = results.map((result) => result.error).join('; ');
|
|
18
|
-
return toolError(`All video sources failed to process: ${errorMessages}`);
|
|
19
|
-
}
|
|
20
|
-
return text(JSON.stringify({
|
|
21
|
-
results,
|
|
22
|
-
}, null, 2));
|
|
23
|
-
});
|
|
24
|
-
export const readVideo = createReadVideoHandler('0.1.0');
|
package/dist/index.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
|
-
import { createReadVideoHandler } from './handlers/readVideo.js';
|
|
4
|
-
import { createServer, http, stdio } from './mcp.js';
|
|
5
|
-
const require = createRequire(import.meta.url);
|
|
6
|
-
const packageJson = require('../package.json');
|
|
7
|
-
const transportType = process.env['MCP_TRANSPORT'] ?? 'stdio';
|
|
8
|
-
const httpPort = Number.parseInt(process.env['MCP_HTTP_PORT'] ?? '8080', 10);
|
|
9
|
-
const httpHost = process.env['MCP_HTTP_HOST'] ?? '127.0.0.1';
|
|
10
|
-
const apiKey = process.env['MCP_API_KEY'];
|
|
11
|
-
const corsOrigin = process.env['MCP_CORS_ORIGIN'];
|
|
12
|
-
const isLoopbackHost = (host) => host === 'localhost' || host === '::1' || host === '127.0.0.1' || host.startsWith('127.');
|
|
13
|
-
function createTransport() {
|
|
14
|
-
if (transportType === 'http') {
|
|
15
|
-
return http({
|
|
16
|
-
port: httpPort,
|
|
17
|
-
hostname: httpHost,
|
|
18
|
-
...(corsOrigin ? { cors: corsOrigin } : {}),
|
|
19
|
-
...(apiKey ? { apiKey } : {}),
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
return stdio();
|
|
23
|
-
}
|
|
24
|
-
const server = createServer({
|
|
25
|
-
name: 'video-reader-mcp',
|
|
26
|
-
version: packageJson.version,
|
|
27
|
-
instructions: 'Evidence-first video reader. Use read_video to extract ffprobe metadata, embedded subtitles, scene boundaries, and timeline warnings without frame-by-frame vision LLM calls.',
|
|
28
|
-
tools: {
|
|
29
|
-
read_video: createReadVideoHandler(packageJson.version),
|
|
30
|
-
},
|
|
31
|
-
transport: createTransport(),
|
|
32
|
-
});
|
|
33
|
-
async function main() {
|
|
34
|
-
await server.start();
|
|
35
|
-
if (transportType === 'http') {
|
|
36
|
-
console.log(`[Video Reader MCP] Server running on http://${httpHost}:${httpPort}/mcp`);
|
|
37
|
-
console.log(`[Video Reader MCP] Health check: http://${httpHost}:${httpPort}/mcp/health`);
|
|
38
|
-
if (apiKey) {
|
|
39
|
-
console.log('[Video Reader MCP] API key authentication enabled (X-API-Key header)');
|
|
40
|
-
}
|
|
41
|
-
else if (!isLoopbackHost(httpHost)) {
|
|
42
|
-
console.warn(`[Video Reader MCP] WARNING: bound to non-loopback host ${httpHost} with no API key. ` +
|
|
43
|
-
'Any client that can reach this port can read every video this process can access. ' +
|
|
44
|
-
'Set MCP_API_KEY to require an X-API-Key header, or bind MCP_HTTP_HOST=127.0.0.1.');
|
|
45
|
-
}
|
|
46
|
-
if (corsOrigin) {
|
|
47
|
-
console.log(`[Video Reader MCP] CORS allowed origin: ${corsOrigin}`);
|
|
48
|
-
}
|
|
49
|
-
console.log('[Video Reader MCP] Project root:', process.cwd());
|
|
50
|
-
}
|
|
51
|
-
else if (process.env['DEBUG_MCP']) {
|
|
52
|
-
console.error('[Video Reader MCP] Server running on stdio');
|
|
53
|
-
console.error('[Video Reader MCP] Project root:', process.cwd());
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
main().catch((error) => {
|
|
57
|
-
console.error('[Video Reader MCP] Server error:', error);
|
|
58
|
-
process.exit(1);
|
|
59
|
-
});
|
package/dist/mcp.js
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { createServer as createHttpServer } from 'node:http';
|
|
3
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
-
import { z } from 'zod';
|
|
7
|
-
export const text = (value) => ({ type: 'text', text: value });
|
|
8
|
-
export const image = (data, mimeType) => ({
|
|
9
|
-
type: 'image',
|
|
10
|
-
data,
|
|
11
|
-
mimeType,
|
|
12
|
-
});
|
|
13
|
-
export const toolError = (message) => ({
|
|
14
|
-
content: [text(message)],
|
|
15
|
-
isError: true,
|
|
16
|
-
});
|
|
17
|
-
class ToolBuilder {
|
|
18
|
-
#description;
|
|
19
|
-
#inputSchema;
|
|
20
|
-
constructor(descriptionValue, inputSchema) {
|
|
21
|
-
this.#description = descriptionValue;
|
|
22
|
-
this.#inputSchema = inputSchema;
|
|
23
|
-
}
|
|
24
|
-
description(value) {
|
|
25
|
-
return new ToolBuilder(value, this.#inputSchema);
|
|
26
|
-
}
|
|
27
|
-
input(schema) {
|
|
28
|
-
return new ToolBuilder(this.#description, schema);
|
|
29
|
-
}
|
|
30
|
-
handler(handler) {
|
|
31
|
-
return {
|
|
32
|
-
description: this.#description ?? '',
|
|
33
|
-
inputSchema: this.#inputSchema ?? z.object({}),
|
|
34
|
-
handler,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export const tool = () => new ToolBuilder();
|
|
39
|
-
export const stdio = () => ({ kind: 'stdio' });
|
|
40
|
-
export const http = (config) => ({ kind: 'http', ...config });
|
|
41
|
-
const isCallToolResult = (result) => typeof result === 'object' && result !== null && 'content' in result;
|
|
42
|
-
const isContentArray = (result) => Array.isArray(result);
|
|
43
|
-
const normalizeToolResult = (result) => {
|
|
44
|
-
if (isContentArray(result))
|
|
45
|
-
return { content: [...result] };
|
|
46
|
-
if (isCallToolResult(result))
|
|
47
|
-
return result;
|
|
48
|
-
return { content: [result] };
|
|
49
|
-
};
|
|
50
|
-
const withCors = (res, origin) => {
|
|
51
|
-
if (!origin)
|
|
52
|
-
return;
|
|
53
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
54
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
55
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id, X-API-Key');
|
|
56
|
-
};
|
|
57
|
-
const isApiKeyValid = (configuredKey, presented) => {
|
|
58
|
-
const provided = Array.isArray(presented) ? presented[0] : presented;
|
|
59
|
-
if (typeof provided !== 'string' || provided.length === 0)
|
|
60
|
-
return false;
|
|
61
|
-
const expected = createHash('sha256').update(configuredKey).digest();
|
|
62
|
-
const actual = createHash('sha256').update(provided).digest();
|
|
63
|
-
return timingSafeEqual(expected, actual);
|
|
64
|
-
};
|
|
65
|
-
const unauthorized = (res) => {
|
|
66
|
-
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'X-API-Key' });
|
|
67
|
-
res.end(JSON.stringify({
|
|
68
|
-
jsonrpc: '2.0',
|
|
69
|
-
id: null,
|
|
70
|
-
error: { code: -32001, message: 'Unauthorized: missing or invalid X-API-Key header' },
|
|
71
|
-
}));
|
|
72
|
-
};
|
|
73
|
-
const ensureStreamableAcceptHeader = (req) => {
|
|
74
|
-
const accept = req.headers.accept;
|
|
75
|
-
const acceptValues = Array.isArray(accept) ? accept.join(', ') : (accept ?? '');
|
|
76
|
-
if (acceptValues.includes('application/json') && acceptValues.includes('text/event-stream')) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
req.headers.accept = 'application/json, text/event-stream';
|
|
80
|
-
const rawAcceptIndex = req.rawHeaders.findIndex((value, index) => index % 2 === 0 && value.toLowerCase() === 'accept');
|
|
81
|
-
if (rawAcceptIndex >= 0) {
|
|
82
|
-
req.rawHeaders[rawAcceptIndex + 1] = 'application/json, text/event-stream';
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
req.rawHeaders.push('Accept', 'application/json, text/event-stream');
|
|
86
|
-
};
|
|
87
|
-
const handleNonMcpRoute = (req, res, pathname, transportConfig) => {
|
|
88
|
-
if (pathname === '/mcp/health' && req.method === 'GET') {
|
|
89
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
90
|
-
res.end(JSON.stringify({ status: 'ok' }));
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
if (pathname !== '/mcp') {
|
|
94
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
95
|
-
res.end(JSON.stringify({ error: 'Not found' }));
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
if (req.method === 'OPTIONS') {
|
|
99
|
-
res.writeHead(204);
|
|
100
|
-
res.end();
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
103
|
-
if (transportConfig.apiKey !== undefined &&
|
|
104
|
-
!isApiKeyValid(transportConfig.apiKey, req.headers['x-api-key'])) {
|
|
105
|
-
unauthorized(res);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
return false;
|
|
109
|
-
};
|
|
110
|
-
const handleHttpRequest = async (req, res, mcpServer, transportConfig) => {
|
|
111
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? transportConfig.hostname}`);
|
|
112
|
-
withCors(res, transportConfig.cors);
|
|
113
|
-
if (handleNonMcpRoute(req, res, url.pathname, transportConfig))
|
|
114
|
-
return;
|
|
115
|
-
ensureStreamableAcceptHeader(req);
|
|
116
|
-
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
117
|
-
try {
|
|
118
|
-
await mcpServer.connect(transport);
|
|
119
|
-
await transport.handleRequest(req, res);
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
await mcpServer.close();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
const startHttpServer = async (serverInfo, transportConfig) => {
|
|
126
|
-
const mcpServer = buildMcpServer(serverInfo);
|
|
127
|
-
const httpServer = createHttpServer((req, res) => {
|
|
128
|
-
void handleHttpRequest(req, res, mcpServer, transportConfig);
|
|
129
|
-
});
|
|
130
|
-
await new Promise((resolve, reject) => {
|
|
131
|
-
httpServer.once('error', reject);
|
|
132
|
-
httpServer.listen(transportConfig.port, transportConfig.hostname, () => {
|
|
133
|
-
httpServer.off('error', reject);
|
|
134
|
-
resolve();
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
return { mcpServer, httpServer };
|
|
138
|
-
};
|
|
139
|
-
const buildMcpServer = ({ name, version, instructions, tools, }) => {
|
|
140
|
-
const mcpServer = new McpServer({ name, version }, {
|
|
141
|
-
...(instructions ? { instructions } : {}),
|
|
142
|
-
});
|
|
143
|
-
for (const [toolName, definition] of Object.entries(tools)) {
|
|
144
|
-
mcpServer.registerTool(toolName, {
|
|
145
|
-
description: definition.description,
|
|
146
|
-
inputSchema: definition.inputSchema,
|
|
147
|
-
}, async (input, ctx) => normalizeToolResult(await definition.handler({ input, ctx })));
|
|
148
|
-
}
|
|
149
|
-
return mcpServer;
|
|
150
|
-
};
|
|
151
|
-
export const createServer = (options) => {
|
|
152
|
-
let mcpServer;
|
|
153
|
-
let httpServer;
|
|
154
|
-
return {
|
|
155
|
-
async start() {
|
|
156
|
-
if (options.transport.kind === 'stdio') {
|
|
157
|
-
mcpServer = buildMcpServer(options);
|
|
158
|
-
await mcpServer.connect(new StdioServerTransport());
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
const started = await startHttpServer(options, options.transport);
|
|
162
|
-
mcpServer = started.mcpServer;
|
|
163
|
-
httpServer = started.httpServer;
|
|
164
|
-
},
|
|
165
|
-
async close() {
|
|
166
|
-
await mcpServer?.close();
|
|
167
|
-
const serverToClose = httpServer;
|
|
168
|
-
if (!serverToClose)
|
|
169
|
-
return;
|
|
170
|
-
await new Promise((resolve, reject) => {
|
|
171
|
-
serverToClose.close((error) => {
|
|
172
|
-
if (error) {
|
|
173
|
-
reject(error);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
resolve();
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
};
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
export const videoSourceSchema = z.object({
|
|
3
|
-
path: z.string().min(1).describe('Path to the local video file (absolute or relative to cwd).'),
|
|
4
|
-
});
|
|
5
|
-
export const readVideoArgsSchema = z.object({
|
|
6
|
-
sources: z.array(videoSourceSchema).min(1).describe('One or more local video sources to read.'),
|
|
7
|
-
include_streams: z
|
|
8
|
-
.boolean()
|
|
9
|
-
.optional()
|
|
10
|
-
.describe('Include stream metadata from ffprobe. Defaults to true.'),
|
|
11
|
-
include_chapters: z
|
|
12
|
-
.boolean()
|
|
13
|
-
.optional()
|
|
14
|
-
.describe('Include chapter markers when present. Defaults to true.'),
|
|
15
|
-
include_subtitles: z
|
|
16
|
-
.boolean()
|
|
17
|
-
.optional()
|
|
18
|
-
.describe('Extract embedded subtitles when available. Defaults to true.'),
|
|
19
|
-
include_scenes: z
|
|
20
|
-
.boolean()
|
|
21
|
-
.optional()
|
|
22
|
-
.describe('Detect scene boundaries with ffmpeg scene filter. Defaults to true.'),
|
|
23
|
-
scene_threshold: z
|
|
24
|
-
.number()
|
|
25
|
-
.min(0)
|
|
26
|
-
.max(1)
|
|
27
|
-
.optional()
|
|
28
|
-
.describe('Scene detection sensitivity for ffmpeg gt(scene,threshold). Defaults to 0.4.'),
|
|
29
|
-
include_transcript: z
|
|
30
|
-
.boolean()
|
|
31
|
-
.optional()
|
|
32
|
-
.describe('Attempt optional local ASR transcript when an adapter is installed. Defaults to false.'),
|
|
33
|
-
});
|
package/dist/types/timeline.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/utils/asr.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { isBinaryAvailable } from './exec.js';
|
|
2
|
-
const ASR_CANDIDATES = ['whisper', 'whisper-cpp', 'vosk-transcriber'];
|
|
3
|
-
export const detectAsrAdapter = async () => {
|
|
4
|
-
for (const candidate of ASR_CANDIDATES) {
|
|
5
|
-
if (await isBinaryAvailable(candidate)) {
|
|
6
|
-
return candidate;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
return null;
|
|
10
|
-
};
|
|
11
|
-
export const tryAsrTranscript = async (_videoPath, enabled) => {
|
|
12
|
-
if (!enabled) {
|
|
13
|
-
return { transcript: [] };
|
|
14
|
-
}
|
|
15
|
-
const adapter = await detectAsrAdapter();
|
|
16
|
-
if (!adapter) {
|
|
17
|
-
return {
|
|
18
|
-
transcript: [],
|
|
19
|
-
warning: 'ASR requested but no local adapter found (checked whisper, whisper-cpp, vosk-transcriber); transcript skipped.',
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
return {
|
|
23
|
-
transcript: [],
|
|
24
|
-
warning: `ASR adapter "${adapter}" detected but transcription is not wired in v0.1.0; transcript skipped.`,
|
|
25
|
-
};
|
|
26
|
-
};
|
package/dist/utils/errors.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export var ErrorCode;
|
|
2
|
-
(function (ErrorCode) {
|
|
3
|
-
ErrorCode[ErrorCode["InvalidParams"] = -32602] = "InvalidParams";
|
|
4
|
-
ErrorCode[ErrorCode["InvalidRequest"] = -32600] = "InvalidRequest";
|
|
5
|
-
ErrorCode[ErrorCode["MethodNotFound"] = -32601] = "MethodNotFound";
|
|
6
|
-
})(ErrorCode || (ErrorCode = {}));
|
|
7
|
-
export class VideoError extends Error {
|
|
8
|
-
code;
|
|
9
|
-
constructor(code, message, options) {
|
|
10
|
-
super(message, options?.cause ? { cause: options.cause } : undefined);
|
|
11
|
-
this.code = code;
|
|
12
|
-
this.name = 'VideoError';
|
|
13
|
-
}
|
|
14
|
-
}
|