@sylphx/video-reader-mcp 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SylphxAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # Video Reader MCP
2
+
3
+ > Evidence-first video reading for AI agents — ffprobe, subtitles, scenes, transcripts, and timelines without frame-by-frame LLM vision.
4
+
5
+ **Status:** v0.1.0 shipped — `read_video` MCP tool available.
6
+
7
+ Orchestrated by [smart-reader-mcp](https://github.com/SylphxAI/smart-reader-mcp) — portfolio ADR lives there, not in pdf-reader-mcp.
8
+
9
+ | Repository | Role |
10
+ | --- | --- |
11
+ | [pdf-reader-mcp](https://github.com/SylphxAI/pdf-reader-mcp) | PDF (production) |
12
+ | [image-reader-mcp](https://github.com/SylphxAI/image-reader-mcp) | Image |
13
+ | **video-reader-mcp** (this repo) | Video |
14
+ | [smart-reader-mcp](https://github.com/SylphxAI/smart-reader-mcp) | Unified read + delegate |
15
+
16
+ ## Read vs interpret
17
+
18
+ **Read** (this repo): extract facts, metadata, transcripts, regions, and timelines with provenance — **no generative LLM required**.
19
+
20
+ **Interpret** (out of scope): summarize, classify, or answer open questions — belongs in the agent or an optional remote provider adapter.
21
+
22
+ ## MCP surface
23
+
24
+ Primary tool: `read_video`
25
+
26
+ Returns a **timeline document** per source:
27
+
28
+ - ffprobe format + stream metadata
29
+ - chapter markers
30
+ - embedded subtitle cues (`time_ms`, text, provenance)
31
+ - optional scene boundaries (ffmpeg `scene` filter)
32
+ - warnings (missing ffmpeg/ffprobe, VFR, missing audio, skipped ASR)
33
+ - optional local ASR hook (skipped in v0.1 unless adapter is wired)
34
+
35
+ No per-frame vision LLM calls. No cloud APIs by default.
36
+
37
+ ## Prerequisites
38
+
39
+ - Node.js ≥ 22.13
40
+ - **ffprobe** (required) and **ffmpeg** (recommended for subtitles + scenes) on `PATH`
41
+
42
+ ## Quick start
43
+
44
+ ```bash
45
+ npx @sylphx/video-reader-mcp
46
+ ```
47
+
48
+ From source:
49
+
50
+ ```bash
51
+ bun install
52
+ bun run build
53
+ bun run start
54
+ ```
55
+
56
+ ### Example `read_video` input
57
+
58
+ ```json
59
+ {
60
+ "sources": [{ "path": "./sample.mp4" }],
61
+ "include_subtitles": true,
62
+ "include_scenes": true,
63
+ "scene_threshold": 0.4
64
+ }
65
+ ```
66
+
67
+ ### HTTP transport (optional)
68
+
69
+ ```bash
70
+ MCP_TRANSPORT=http MCP_HTTP_PORT=8080 node dist/index.js
71
+ ```
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ bun install
77
+ bun run typecheck
78
+ bun test
79
+ bun run build
80
+ ```
81
+
82
+ Unit tests mock parsers and do not require ffmpeg in CI. Integration with real media is optional locally.
83
+
84
+ ## License
85
+
86
+ MIT © [SylphxAI](https://github.com/SylphxAI)
@@ -0,0 +1,24 @@
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 ADDED
@@ -0,0 +1,59 @@
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 ADDED
@@ -0,0 +1,181 @@
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
+ };
@@ -0,0 +1,33 @@
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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
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
+ };
@@ -0,0 +1,14 @@
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
+ }
@@ -0,0 +1,56 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const DEFAULT_TIMEOUT_MS = 120_000;
5
+ const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
6
+ export const execBinary = async (binary, args, options = {}) => {
7
+ const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
8
+ const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER;
9
+ try {
10
+ const { stdout, stderr } = await execFileAsync(binary, [...args], {
11
+ timeout,
12
+ maxBuffer,
13
+ encoding: 'utf8',
14
+ });
15
+ return {
16
+ stdout: typeof stdout === 'string' ? stdout : String(stdout),
17
+ stderr: typeof stderr === 'string' ? stderr : String(stderr),
18
+ };
19
+ }
20
+ catch (error) {
21
+ if (typeof error === 'object' &&
22
+ error !== null &&
23
+ 'killed' in error &&
24
+ error.killed === true &&
25
+ 'signal' in error &&
26
+ error.signal === 'SIGTERM') {
27
+ throw new Error(`${binary} timed out after ${timeout}ms`);
28
+ }
29
+ throw error;
30
+ }
31
+ };
32
+ const binaryCache = new Map();
33
+ export const isBinaryAvailable = async (binary) => {
34
+ const cached = binaryCache.get(binary);
35
+ if (cached !== undefined)
36
+ return cached;
37
+ try {
38
+ await execFileAsync(binary, ['-version'], { timeout: 5_000, encoding: 'utf8' });
39
+ binaryCache.set(binary, true);
40
+ return true;
41
+ }
42
+ catch {
43
+ try {
44
+ await execFileAsync(binary, ['--version'], { timeout: 5_000, encoding: 'utf8' });
45
+ binaryCache.set(binary, true);
46
+ return true;
47
+ }
48
+ catch {
49
+ binaryCache.set(binary, false);
50
+ return false;
51
+ }
52
+ }
53
+ };
54
+ export const clearBinaryCache = () => {
55
+ binaryCache.clear();
56
+ };
@@ -0,0 +1,66 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execBinary, isBinaryAvailable } from './exec.js';
5
+ import { parseSubtitleContent } from './subtitles.js';
6
+ const subtitleFormatForStream = (stream) => {
7
+ const codec = stream.codec_name?.toLowerCase() ?? '';
8
+ if (codec.includes('webvtt') || codec === 'vtt')
9
+ return 'vtt';
10
+ return 'srt';
11
+ };
12
+ export const extractSubtitles = async (videoPath, subtitleStreams) => {
13
+ const warnings = [];
14
+ const subtitles = [];
15
+ if (subtitleStreams.length === 0) {
16
+ return { subtitles, warnings: ['No embedded subtitle streams found.'] };
17
+ }
18
+ const available = await isBinaryAvailable('ffmpeg');
19
+ if (!available) {
20
+ return {
21
+ subtitles,
22
+ warnings: ['ffmpeg is not installed; subtitle extraction skipped.'],
23
+ };
24
+ }
25
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-reader-mcp-'));
26
+ try {
27
+ for (const stream of subtitleStreams) {
28
+ const format = subtitleFormatForStream(stream);
29
+ const extension = format === 'srt' ? 'srt' : 'vtt';
30
+ const outputPath = path.join(tempDir, `sub-${stream.index}.${extension}`);
31
+ try {
32
+ await execBinary('ffmpeg', [
33
+ '-hide_banner',
34
+ '-y',
35
+ '-i',
36
+ videoPath,
37
+ '-map',
38
+ `0:${stream.index}`,
39
+ '-c:s',
40
+ format === 'srt' ? 'srt' : 'webvtt',
41
+ outputPath,
42
+ ], { timeoutMs: 120_000 });
43
+ const content = await readFile(outputPath, 'utf8');
44
+ const cues = parseSubtitleContent(content, format).map((cue) => ({
45
+ ...cue,
46
+ index: subtitles.length + cue.index,
47
+ stream_index: stream.index,
48
+ ...(stream.tags?.language ? { language: stream.tags.language } : {}),
49
+ provenance: {
50
+ method: 'ffmpeg_extract',
51
+ format,
52
+ },
53
+ }));
54
+ subtitles.push(...cues);
55
+ }
56
+ catch (error) {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ warnings.push(`Subtitle stream ${stream.index} extraction failed: ${message}`);
59
+ }
60
+ }
61
+ }
62
+ finally {
63
+ await rm(tempDir, { recursive: true, force: true });
64
+ }
65
+ return { subtitles, warnings };
66
+ };
@@ -0,0 +1,79 @@
1
+ import { execBinary, isBinaryAvailable } from './exec.js';
2
+ export const parseFfprobeJson = (raw) => {
3
+ const parsed = JSON.parse(raw);
4
+ return {
5
+ streams: Array.isArray(parsed.streams) ? parsed.streams : [],
6
+ format: parsed.format ?? {},
7
+ chapters: Array.isArray(parsed.chapters) ? parsed.chapters : [],
8
+ };
9
+ };
10
+ export const secondsToMs = (value) => {
11
+ if (value === undefined)
12
+ return 0;
13
+ const seconds = typeof value === 'number' ? value : Number.parseFloat(value);
14
+ if (!Number.isFinite(seconds))
15
+ return 0;
16
+ return Math.round(seconds * 1000);
17
+ };
18
+ export const mapStreams = (streams) => streams.map((stream) => ({
19
+ index: stream.index,
20
+ codec_type: stream.codec_type,
21
+ ...(stream.codec_name ? { codec_name: stream.codec_name } : {}),
22
+ ...(stream.tags?.language ? { language: stream.tags.language } : {}),
23
+ ...(stream.channels !== undefined ? { channels: stream.channels } : {}),
24
+ ...(stream.sample_rate ? { sample_rate: Number.parseInt(stream.sample_rate, 10) } : {}),
25
+ ...(stream.width !== undefined ? { width: stream.width } : {}),
26
+ ...(stream.height !== undefined ? { height: stream.height } : {}),
27
+ ...(stream.avg_frame_rate ? { avg_frame_rate: stream.avg_frame_rate } : {}),
28
+ ...(stream.r_frame_rate ? { r_frame_rate: stream.r_frame_rate } : {}),
29
+ ...(stream.bit_rate ? { bit_rate: Number.parseInt(stream.bit_rate, 10) } : {}),
30
+ ...(stream.disposition ? { disposition: stream.disposition } : {}),
31
+ ...(stream.tags ? { tags: stream.tags } : {}),
32
+ }));
33
+ export const mapChapters = (chapters) => (chapters ?? []).map((chapter) => ({
34
+ id: chapter.id,
35
+ start_ms: secondsToMs(chapter.start),
36
+ end_ms: secondsToMs(chapter.end),
37
+ ...(chapter.tags?.title ? { title: chapter.tags.title } : {}),
38
+ }));
39
+ export const collectProbeWarnings = (probe, includeStreams) => {
40
+ const warnings = [];
41
+ const videoStreams = probe.streams.filter((s) => s.codec_type === 'video');
42
+ const audioStreams = probe.streams.filter((s) => s.codec_type === 'audio');
43
+ if (includeStreams && videoStreams.length === 0) {
44
+ warnings.push('No video stream detected.');
45
+ }
46
+ if (includeStreams && audioStreams.length === 0) {
47
+ warnings.push('No audio stream detected.');
48
+ }
49
+ for (const stream of videoStreams) {
50
+ if (stream.avg_frame_rate &&
51
+ stream.r_frame_rate &&
52
+ stream.avg_frame_rate !== stream.r_frame_rate) {
53
+ warnings.push(`Stream ${stream.index}: variable frame rate suspected (avg_frame_rate=${stream.avg_frame_rate}, r_frame_rate=${stream.r_frame_rate}).`);
54
+ }
55
+ }
56
+ const duration = probe.format.duration;
57
+ if (!duration || Number.parseFloat(duration) <= 0) {
58
+ warnings.push('Duration unavailable or zero; timeline bounds may be incomplete.');
59
+ }
60
+ return warnings;
61
+ };
62
+ export const runFfprobe = async (videoPath) => {
63
+ const available = await isBinaryAvailable('ffprobe');
64
+ if (!available) {
65
+ throw new Error('ffprobe is not installed or not on PATH');
66
+ }
67
+ const { stdout } = await execBinary('ffprobe', [
68
+ '-v',
69
+ 'quiet',
70
+ '-print_format',
71
+ 'json',
72
+ '-show_format',
73
+ '-show_streams',
74
+ '-show_chapters',
75
+ videoPath,
76
+ ], { timeoutMs: 60_000 });
77
+ return parseFfprobeJson(stdout);
78
+ };
79
+ export const findSubtitleStreams = (streams) => streams.filter((stream) => stream.codec_type === 'subtitle');
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ErrorCode, VideoError } from './errors.js';
4
+ export const PROJECT_ROOT = process.cwd();
5
+ const canonicalize = (p) => {
6
+ try {
7
+ return fs.realpathSync(p);
8
+ }
9
+ catch (err) {
10
+ if (typeof err === 'object' &&
11
+ err !== null &&
12
+ 'code' in err &&
13
+ (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
14
+ const parent = path.dirname(p);
15
+ if (parent === p)
16
+ return p;
17
+ return path.join(canonicalize(parent), path.basename(p));
18
+ }
19
+ throw err;
20
+ }
21
+ };
22
+ export const resolvePath = (userPath) => {
23
+ if (typeof userPath !== 'string') {
24
+ throw new VideoError(ErrorCode.InvalidParams, 'Path must be a string.');
25
+ }
26
+ const normalizedUserPath = path.normalize(userPath);
27
+ const resolved = path.isAbsolute(normalizedUserPath)
28
+ ? normalizedUserPath
29
+ : path.resolve(PROJECT_ROOT, normalizedUserPath);
30
+ return canonicalize(resolved);
31
+ };
@@ -0,0 +1,49 @@
1
+ import { execBinary, isBinaryAvailable } from './exec.js';
2
+ const SCENE_PTS_TIME_RE = /pts_time:([0-9.]+)/g;
3
+ export const parseSceneFilterOutput = (stderr, threshold) => {
4
+ const scenes = [];
5
+ let match;
6
+ while ((match = SCENE_PTS_TIME_RE.exec(stderr)) !== null) {
7
+ const seconds = Number.parseFloat(match[1]);
8
+ if (!Number.isFinite(seconds))
9
+ continue;
10
+ scenes.push({
11
+ index: scenes.length,
12
+ time_ms: Math.round(seconds * 1000),
13
+ provenance: {
14
+ method: 'ffmpeg_scene_filter',
15
+ threshold,
16
+ },
17
+ });
18
+ }
19
+ return scenes;
20
+ };
21
+ export const detectScenes = async (videoPath, threshold) => {
22
+ const available = await isBinaryAvailable('ffmpeg');
23
+ if (!available) {
24
+ return {
25
+ scenes: [],
26
+ warning: 'ffmpeg is not installed; scene detection skipped.',
27
+ };
28
+ }
29
+ try {
30
+ const { stderr } = await execBinary('ffmpeg', [
31
+ '-hide_banner',
32
+ '-i',
33
+ videoPath,
34
+ '-vf',
35
+ `select='gt(scene,${threshold})',showinfo`,
36
+ '-f',
37
+ 'null',
38
+ '-',
39
+ ], { timeoutMs: 300_000 });
40
+ return { scenes: parseSceneFilterOutput(stderr, threshold) };
41
+ }
42
+ catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ return {
45
+ scenes: [],
46
+ warning: `Scene detection failed: ${message}`,
47
+ };
48
+ }
49
+ };
@@ -0,0 +1,85 @@
1
+ const parseTimestampToMs = (timestamp) => {
2
+ const normalized = timestamp.trim().replace(',', '.');
3
+ const parts = normalized.split(':');
4
+ if (parts.length !== 3)
5
+ return 0;
6
+ const [hours, minutes, secondsPart] = parts;
7
+ const [seconds, millis = '0'] = secondsPart.split('.');
8
+ const h = Number.parseInt(hours, 10);
9
+ const m = Number.parseInt(minutes, 10);
10
+ const s = Number.parseInt(seconds, 10);
11
+ const ms = Number.parseInt(millis.padEnd(3, '0').slice(0, 3), 10);
12
+ return ((h * 60 + m) * 60 + s) * 1000 + ms;
13
+ };
14
+ const stripTags = (text) => text
15
+ .replace(/<[^>]+>/g, '')
16
+ .replace(/\{[^}]+\}/g, '')
17
+ .trim();
18
+ export const parseSrt = (content) => {
19
+ const blocks = content
20
+ .replace(/\r\n/g, '\n')
21
+ .split(/\n{2,}/)
22
+ .map((block) => block.trim())
23
+ .filter(Boolean);
24
+ const cues = [];
25
+ for (const block of blocks) {
26
+ const lines = block.split('\n');
27
+ if (lines.length < 2)
28
+ continue;
29
+ const timingLine = lines.find((line) => line.includes('-->'));
30
+ if (!timingLine)
31
+ continue;
32
+ const [startRaw, endRaw] = timingLine.split('-->').map((part) => part.trim());
33
+ const textLines = lines.filter((line) => line !== lines[0] && !line.includes('-->'));
34
+ const text = stripTags(textLines.join('\n'));
35
+ if (!text)
36
+ continue;
37
+ cues.push({
38
+ index: cues.length,
39
+ start_ms: parseTimestampToMs(startRaw),
40
+ end_ms: parseTimestampToMs(endRaw),
41
+ text,
42
+ provenance: {
43
+ method: 'embedded_parse',
44
+ format: 'srt',
45
+ },
46
+ });
47
+ }
48
+ return cues;
49
+ };
50
+ export const parseVtt = (content) => {
51
+ const normalized = content.replace(/\r\n/g, '\n');
52
+ const blocks = normalized
53
+ .split(/\n{2,}/)
54
+ .map((block) => block.trim())
55
+ .filter((block) => block && !block.startsWith('WEBVTT') && !block.startsWith('NOTE'));
56
+ const cues = [];
57
+ for (const block of blocks) {
58
+ const lines = block.split('\n');
59
+ const timingLine = lines.find((line) => line.includes('-->'));
60
+ if (!timingLine)
61
+ continue;
62
+ const timing = timingLine.split('-->')[0]?.trim() ?? '';
63
+ const endTiming = timingLine.split('-->')[1]?.trim().split(' ')[0] ?? '';
64
+ const textLines = lines.filter((line) => !line.includes('-->') && !/^\d+$/.test(line));
65
+ const text = stripTags(textLines.join('\n'));
66
+ if (!text)
67
+ continue;
68
+ cues.push({
69
+ index: cues.length,
70
+ start_ms: parseTimestampToMs(timing),
71
+ end_ms: parseTimestampToMs(endTiming),
72
+ text,
73
+ provenance: {
74
+ method: 'embedded_parse',
75
+ format: 'vtt',
76
+ },
77
+ });
78
+ }
79
+ return cues;
80
+ };
81
+ export const parseSubtitleContent = (content, format) => {
82
+ if (format === 'srt')
83
+ return parseSrt(content);
84
+ return parseVtt(content);
85
+ };
@@ -0,0 +1,81 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { tryAsrTranscript } from '../utils/asr.js';
3
+ import { extractSubtitles } from '../utils/ffmpeg.js';
4
+ import { collectProbeWarnings, findSubtitleStreams, mapChapters, mapStreams, runFfprobe, secondsToMs, } from '../utils/ffprobe.js';
5
+ import { resolvePath } from '../utils/pathUtils.js';
6
+ import { detectScenes } from '../utils/scenes.js';
7
+ const DEFAULT_SCENE_THRESHOLD = 0.4;
8
+ export const buildTimelineDocument = async (sourcePath, args, version) => {
9
+ const includeStreams = args.include_streams ?? true;
10
+ const includeChapters = args.include_chapters ?? true;
11
+ const includeSubtitles = args.include_subtitles ?? true;
12
+ const includeScenes = args.include_scenes ?? true;
13
+ const includeTranscript = args.include_transcript ?? false;
14
+ const sceneThreshold = args.scene_threshold ?? DEFAULT_SCENE_THRESHOLD;
15
+ const warnings = [];
16
+ const probe = await runFfprobe(sourcePath);
17
+ warnings.push(...collectProbeWarnings(probe, includeStreams));
18
+ const format = {
19
+ ...(probe.format.format_name ? { format_name: probe.format.format_name } : {}),
20
+ duration_ms: secondsToMs(probe.format.duration),
21
+ ...(probe.format.bit_rate ? { bit_rate: Number.parseInt(probe.format.bit_rate, 10) } : {}),
22
+ ...(probe.format.size ? { size_bytes: Number.parseInt(probe.format.size, 10) } : {}),
23
+ ...(probe.format.tags ? { tags: probe.format.tags } : {}),
24
+ };
25
+ let subtitles = [];
26
+ if (includeSubtitles) {
27
+ const subtitleStreams = findSubtitleStreams(probe.streams);
28
+ const extracted = await extractSubtitles(sourcePath, subtitleStreams);
29
+ subtitles = extracted.subtitles;
30
+ warnings.push(...extracted.warnings);
31
+ }
32
+ let scenes = [];
33
+ if (includeScenes) {
34
+ const detected = await detectScenes(sourcePath, sceneThreshold);
35
+ scenes = detected.scenes;
36
+ if (detected.warning)
37
+ warnings.push(detected.warning);
38
+ }
39
+ let transcript = [];
40
+ if (includeTranscript) {
41
+ const asr = await tryAsrTranscript(sourcePath, true);
42
+ transcript = asr.transcript;
43
+ if (asr.warning)
44
+ warnings.push(asr.warning);
45
+ }
46
+ return {
47
+ provenance: {
48
+ source: sourcePath,
49
+ tool: 'read_video',
50
+ version,
51
+ extracted_at: new Date().toISOString(),
52
+ },
53
+ format,
54
+ streams: includeStreams ? mapStreams(probe.streams) : [],
55
+ chapters: includeChapters ? mapChapters(probe.chapters) : [],
56
+ scenes,
57
+ subtitles,
58
+ transcript,
59
+ warnings,
60
+ };
61
+ };
62
+ export const processVideoSource = async (userPath, args, version) => {
63
+ try {
64
+ const sourcePath = resolvePath(userPath);
65
+ await access(sourcePath);
66
+ const data = await buildTimelineDocument(sourcePath, args, version);
67
+ return {
68
+ source: userPath,
69
+ success: true,
70
+ data,
71
+ };
72
+ }
73
+ catch (error) {
74
+ const message = error instanceof Error ? error.message : String(error);
75
+ return {
76
+ source: userPath,
77
+ success: false,
78
+ error: message,
79
+ };
80
+ }
81
+ };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@sylphx/video-reader-mcp",
3
+ "version": "0.1.0",
4
+ "mcpName": "io.github.SylphxAI/video-reader-mcp",
5
+ "description": "Evidence-first video reading for AI agents — ffprobe, subtitles, scenes, transcripts, and timelines without frame-by-frame LLM vision.",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "license": "MIT",
11
+ "bin": {
12
+ "video-reader-mcp": "./dist/index.js"
13
+ },
14
+ "files": [
15
+ "dist/",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "exports": {
20
+ ".": "./dist/index.js"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/SylphxAI/video-reader-mcp.git"
25
+ },
26
+ "homepage": "https://github.com/SylphxAI/video-reader-mcp#readme",
27
+ "keywords": [
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "video",
31
+ "reader",
32
+ "evidence",
33
+ "ffprobe",
34
+ "ffmpeg",
35
+ "subtitles",
36
+ "sylphx"
37
+ ],
38
+ "engines": {
39
+ "node": ">=22.13.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc && chmod +x dist/index.js",
43
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
44
+ "test": "bun test",
45
+ "check": "biome check .",
46
+ "check:fix": "biome check --write .",
47
+ "sync:server-json": "bun scripts/sync-server-json.ts",
48
+ "version-packages": "changeset version && bun run sync:server-json",
49
+ "start": "node dist/index.js",
50
+ "clean": "rm -rf dist coverage",
51
+ "prepublishOnly": "bun run clean && bun run build",
52
+ "release": "bun run typecheck && bun run check && bun run build && bun test && changeset publish"
53
+ },
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.29.0",
56
+ "zod": "^4.4.3"
57
+ },
58
+ "devDependencies": {
59
+ "@biomejs/biome": "^2.4.16",
60
+ "@changesets/changelog-github": "^0.7.0",
61
+ "@changesets/cli": "^2.31.0",
62
+ "@types/bun": "^1.2.19",
63
+ "@types/node": "^25.9.1",
64
+ "typescript": "^7.0.2"
65
+ },
66
+ "packageManager": "bun@1.3.12"
67
+ }