@studiomeyer/mcp-video 1.0.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/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
- package/.github/workflows/ci.yml +34 -0
- package/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/USAGE.md +144 -0
- package/dist/handlers/capcut.d.ts +6 -0
- package/dist/handlers/capcut.js +229 -0
- package/dist/handlers/capcut.js.map +1 -0
- package/dist/handlers/editing.d.ts +6 -0
- package/dist/handlers/editing.js +242 -0
- package/dist/handlers/editing.js.map +1 -0
- package/dist/handlers/index.d.ts +2 -0
- package/dist/handlers/index.js +33 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/post-production.d.ts +5 -0
- package/dist/handlers/post-production.js +109 -0
- package/dist/handlers/post-production.js.map +1 -0
- package/dist/handlers/smart-screenshot.d.ts +5 -0
- package/dist/handlers/smart-screenshot.js +83 -0
- package/dist/handlers/smart-screenshot.js.map +1 -0
- package/dist/handlers/tts.d.ts +5 -0
- package/dist/handlers/tts.js +83 -0
- package/dist/handlers/tts.js.map +1 -0
- package/dist/handlers/video.d.ts +5 -0
- package/dist/handlers/video.js +127 -0
- package/dist/handlers/video.js.map +1 -0
- package/dist/lib/dual-transport.d.ts +42 -0
- package/dist/lib/dual-transport.js +208 -0
- package/dist/lib/dual-transport.js.map +1 -0
- package/dist/lib/logger.d.ts +12 -0
- package/dist/lib/logger.js +42 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/types.d.ts +16 -0
- package/dist/lib/types.js +15 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/schemas/capcut.d.ts +608 -0
- package/dist/schemas/capcut.js +411 -0
- package/dist/schemas/capcut.js.map +1 -0
- package/dist/schemas/editing.d.ts +822 -0
- package/dist/schemas/editing.js +466 -0
- package/dist/schemas/editing.js.map +1 -0
- package/dist/schemas/index.d.ts +2366 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/post-production.d.ts +379 -0
- package/dist/schemas/post-production.js +268 -0
- package/dist/schemas/post-production.js.map +1 -0
- package/dist/schemas/smart-screenshot.d.ts +127 -0
- package/dist/schemas/smart-screenshot.js +122 -0
- package/dist/schemas/smart-screenshot.js.map +1 -0
- package/dist/schemas/tts.d.ts +220 -0
- package/dist/schemas/tts.js +194 -0
- package/dist/schemas/tts.js.map +1 -0
- package/dist/schemas/video.d.ts +236 -0
- package/dist/schemas/video.js +210 -0
- package/dist/schemas/video.js.map +1 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +239 -0
- package/dist/server.js.map +1 -0
- package/dist/server.test.d.ts +1 -0
- package/dist/server.test.js +87 -0
- package/dist/server.test.js.map +1 -0
- package/dist/tools/engine/audio-mixer.d.ts +40 -0
- package/dist/tools/engine/audio-mixer.js +169 -0
- package/dist/tools/engine/audio-mixer.js.map +1 -0
- package/dist/tools/engine/audio.d.ts +22 -0
- package/dist/tools/engine/audio.js +73 -0
- package/dist/tools/engine/audio.js.map +1 -0
- package/dist/tools/engine/beat-sync.d.ts +31 -0
- package/dist/tools/engine/beat-sync.js +270 -0
- package/dist/tools/engine/beat-sync.js.map +1 -0
- package/dist/tools/engine/capture.d.ts +12 -0
- package/dist/tools/engine/capture.js +290 -0
- package/dist/tools/engine/capture.js.map +1 -0
- package/dist/tools/engine/chroma-key.d.ts +27 -0
- package/dist/tools/engine/chroma-key.js +154 -0
- package/dist/tools/engine/chroma-key.js.map +1 -0
- package/dist/tools/engine/concat.d.ts +49 -0
- package/dist/tools/engine/concat.js +149 -0
- package/dist/tools/engine/concat.js.map +1 -0
- package/dist/tools/engine/cursor.d.ts +26 -0
- package/dist/tools/engine/cursor.js +185 -0
- package/dist/tools/engine/cursor.js.map +1 -0
- package/dist/tools/engine/easing.d.ts +15 -0
- package/dist/tools/engine/easing.js +100 -0
- package/dist/tools/engine/easing.js.map +1 -0
- package/dist/tools/engine/editing.d.ts +158 -0
- package/dist/tools/engine/editing.js +541 -0
- package/dist/tools/engine/editing.js.map +1 -0
- package/dist/tools/engine/encoder.d.ts +31 -0
- package/dist/tools/engine/encoder.js +154 -0
- package/dist/tools/engine/encoder.js.map +1 -0
- package/dist/tools/engine/index.d.ts +30 -0
- package/dist/tools/engine/index.js +23 -0
- package/dist/tools/engine/index.js.map +1 -0
- package/dist/tools/engine/lut-presets.d.ts +25 -0
- package/dist/tools/engine/lut-presets.js +141 -0
- package/dist/tools/engine/lut-presets.js.map +1 -0
- package/dist/tools/engine/narrated-video.d.ts +63 -0
- package/dist/tools/engine/narrated-video.js +163 -0
- package/dist/tools/engine/narrated-video.js.map +1 -0
- package/dist/tools/engine/scenes.d.ts +17 -0
- package/dist/tools/engine/scenes.js +223 -0
- package/dist/tools/engine/scenes.js.map +1 -0
- package/dist/tools/engine/smart-screenshot.d.ts +80 -0
- package/dist/tools/engine/smart-screenshot.js +744 -0
- package/dist/tools/engine/smart-screenshot.js.map +1 -0
- package/dist/tools/engine/social-format.d.ts +66 -0
- package/dist/tools/engine/social-format.js +107 -0
- package/dist/tools/engine/social-format.js.map +1 -0
- package/dist/tools/engine/template-renderer.d.ts +45 -0
- package/dist/tools/engine/template-renderer.js +233 -0
- package/dist/tools/engine/template-renderer.js.map +1 -0
- package/dist/tools/engine/templates.d.ts +87 -0
- package/dist/tools/engine/templates.js +272 -0
- package/dist/tools/engine/templates.js.map +1 -0
- package/dist/tools/engine/text-animations.d.ts +33 -0
- package/dist/tools/engine/text-animations.js +192 -0
- package/dist/tools/engine/text-animations.js.map +1 -0
- package/dist/tools/engine/text-overlay.d.ts +27 -0
- package/dist/tools/engine/text-overlay.js +84 -0
- package/dist/tools/engine/text-overlay.js.map +1 -0
- package/dist/tools/engine/tts.d.ts +54 -0
- package/dist/tools/engine/tts.js +186 -0
- package/dist/tools/engine/tts.js.map +1 -0
- package/dist/tools/engine/types.d.ts +166 -0
- package/dist/tools/engine/types.js +13 -0
- package/dist/tools/engine/types.js.map +1 -0
- package/dist/tools/engine/voice-effects.d.ts +18 -0
- package/dist/tools/engine/voice-effects.js +215 -0
- package/dist/tools/engine/voice-effects.js.map +1 -0
- package/dist/tools/index.d.ts +32 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/package.json +56 -0
- package/scripts/check-deps.js +39 -0
- package/src/handlers/capcut.ts +245 -0
- package/src/handlers/editing.ts +260 -0
- package/src/handlers/index.ts +34 -0
- package/src/handlers/post-production.ts +136 -0
- package/src/handlers/smart-screenshot.ts +86 -0
- package/src/handlers/tts.ts +103 -0
- package/src/handlers/video.ts +137 -0
- package/src/lib/dual-transport.ts +272 -0
- package/src/lib/logger.ts +59 -0
- package/src/lib/types.ts +25 -0
- package/src/schemas/capcut.ts +418 -0
- package/src/schemas/editing.ts +476 -0
- package/src/schemas/index.ts +15 -0
- package/src/schemas/post-production.ts +273 -0
- package/src/schemas/smart-screenshot.ts +122 -0
- package/src/schemas/tts.ts +197 -0
- package/src/schemas/video.ts +211 -0
- package/src/server.test.ts +99 -0
- package/src/server.ts +289 -0
- package/src/tools/engine/audio-mixer.ts +244 -0
- package/src/tools/engine/audio.ts +115 -0
- package/src/tools/engine/beat-sync.ts +356 -0
- package/src/tools/engine/capture.ts +360 -0
- package/src/tools/engine/chroma-key.ts +202 -0
- package/src/tools/engine/concat.ts +242 -0
- package/src/tools/engine/cursor.ts +222 -0
- package/src/tools/engine/easing.ts +120 -0
- package/src/tools/engine/editing.ts +809 -0
- package/src/tools/engine/encoder.ts +208 -0
- package/src/tools/engine/index.ts +33 -0
- package/src/tools/engine/lut-presets.ts +235 -0
- package/src/tools/engine/narrated-video.ts +267 -0
- package/src/tools/engine/scenes.ts +309 -0
- package/src/tools/engine/smart-screenshot.ts +923 -0
- package/src/tools/engine/social-format.ts +146 -0
- package/src/tools/engine/template-renderer.ts +294 -0
- package/src/tools/engine/templates.ts +370 -0
- package/src/tools/engine/text-animations.ts +282 -0
- package/src/tools/engine/text-overlay.ts +143 -0
- package/src/tools/engine/tts.ts +284 -0
- package/src/tools/engine/types.ts +191 -0
- package/src/tools/engine/voice-effects.ts +258 -0
- package/src/tools/index.ts +67 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video recording tool handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jsonResponse, type ToolHandler } from '../lib/types.js';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { recordWebsite } from '../tools/index.js';
|
|
8
|
+
import type { RecordingConfig, Scene, ViewportPreset } from '../tools/index.js';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
|
|
11
|
+
const OUTPUT_DIR = process.env.VIDEO_OUTPUT_DIR || './output';
|
|
12
|
+
|
|
13
|
+
export const videoHandlers: Record<string, ToolHandler> = {
|
|
14
|
+
/**
|
|
15
|
+
* Full-featured website video recording
|
|
16
|
+
*/
|
|
17
|
+
record_website_video: async (args) => {
|
|
18
|
+
try {
|
|
19
|
+
const config: RecordingConfig = {
|
|
20
|
+
url: args.url,
|
|
21
|
+
outputPath: args.outputPath ?? generateOutputPath(args.url, 'video'),
|
|
22
|
+
viewport: args.viewport as ViewportPreset ?? 'desktop',
|
|
23
|
+
fps: args.fps ?? 60,
|
|
24
|
+
scenes: args.scenes as Scene[] | undefined,
|
|
25
|
+
cursor: args.cursor ?? { enabled: true },
|
|
26
|
+
encoding: {
|
|
27
|
+
codec: args.codec ?? 'h264',
|
|
28
|
+
crf: args.quality ?? 18,
|
|
29
|
+
},
|
|
30
|
+
darkMode: args.darkMode ?? false,
|
|
31
|
+
preloadContent: args.preloadContent ?? true,
|
|
32
|
+
dismissOverlays: args.dismissOverlays ?? true,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = await recordWebsite(config);
|
|
36
|
+
return jsonResponse(result);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
logger.error(`record_website_video failed: ${message}`);
|
|
40
|
+
return jsonResponse({ success: false, error: message }, true);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Quick scroll-through video
|
|
46
|
+
*/
|
|
47
|
+
record_website_scroll: async (args) => {
|
|
48
|
+
try {
|
|
49
|
+
const duration = args.duration ?? 12;
|
|
50
|
+
const easing = args.easing ?? 'showcase';
|
|
51
|
+
|
|
52
|
+
const config: RecordingConfig = {
|
|
53
|
+
url: args.url,
|
|
54
|
+
outputPath: args.outputPath ?? generateOutputPath(args.url, 'scroll'),
|
|
55
|
+
viewport: (args.viewport as ViewportPreset) ?? 'desktop',
|
|
56
|
+
fps: 60,
|
|
57
|
+
scenes: [
|
|
58
|
+
{ type: 'pause', duration: 1.5 },
|
|
59
|
+
{ type: 'scroll', to: 'bottom', duration, easing },
|
|
60
|
+
{ type: 'pause', duration: 2 },
|
|
61
|
+
],
|
|
62
|
+
cursor: { enabled: false },
|
|
63
|
+
encoding: { codec: 'h264', crf: 18 },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const result = await recordWebsite(config);
|
|
67
|
+
return jsonResponse(result);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
70
|
+
logger.error(`record_website_scroll failed: ${message}`);
|
|
71
|
+
return jsonResponse({ success: false, error: message }, true);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Multi-device recording
|
|
77
|
+
*/
|
|
78
|
+
record_multi_device: async (args) => {
|
|
79
|
+
try {
|
|
80
|
+
const devices: ViewportPreset[] = args.devices ?? ['desktop', 'tablet', 'mobile'];
|
|
81
|
+
const duration = args.duration ?? 10;
|
|
82
|
+
const outputDir = args.outputDir ?? OUTPUT_DIR;
|
|
83
|
+
const results: Record<string, unknown> = {};
|
|
84
|
+
|
|
85
|
+
for (const device of devices) {
|
|
86
|
+
logger.info(`Recording ${device} viewport for ${args.url}...`);
|
|
87
|
+
|
|
88
|
+
const config: RecordingConfig = {
|
|
89
|
+
url: args.url,
|
|
90
|
+
outputPath: path.join(outputDir, generateOutputName(args.url, device)),
|
|
91
|
+
viewport: device,
|
|
92
|
+
fps: 60,
|
|
93
|
+
scenes: [
|
|
94
|
+
{ type: 'pause', duration: 1 },
|
|
95
|
+
{ type: 'scroll', to: 'bottom', duration, easing: 'showcase' },
|
|
96
|
+
{ type: 'pause', duration: 1.5 },
|
|
97
|
+
],
|
|
98
|
+
cursor: { enabled: device !== 'mobile' && device !== 'mobile-landscape' },
|
|
99
|
+
encoding: { codec: 'h264', crf: 18 },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const result = await recordWebsite(config);
|
|
103
|
+
results[device] = result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return jsonResponse({
|
|
107
|
+
success: true,
|
|
108
|
+
devices: devices.length,
|
|
109
|
+
results,
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
logger.error(`record_multi_device failed: ${message}`);
|
|
114
|
+
return jsonResponse({ success: false, error: message }, true);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Generate a descriptive output path from URL
|
|
121
|
+
*/
|
|
122
|
+
function generateOutputPath(url: string, prefix: string): string {
|
|
123
|
+
const name = generateOutputName(url, prefix);
|
|
124
|
+
return path.join(OUTPUT_DIR, name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function generateOutputName(url: string, suffix: string): string {
|
|
128
|
+
try {
|
|
129
|
+
const hostname = new URL(url).hostname
|
|
130
|
+
.replace(/^www\./, '')
|
|
131
|
+
.replace(/\./g, '-');
|
|
132
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
133
|
+
return `${hostname}-${suffix}-${timestamp}`;
|
|
134
|
+
} catch {
|
|
135
|
+
return `website-${suffix}-${Date.now()}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual Transport for MCP Servers — Stdio + Streamable HTTP
|
|
3
|
+
*
|
|
4
|
+
* Default: stdio (backward compatible, used by Claude Code via subprocess)
|
|
5
|
+
* --http: Streamable HTTP on configurable port (persistent HTTP microservice)
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* startDualTransport(createMcpServer, { serverName, serverVersion, defaultPort })
|
|
9
|
+
*
|
|
10
|
+
* HTTP mode flags:
|
|
11
|
+
* --http Enable HTTP transport
|
|
12
|
+
* --port=XXXX Override default port
|
|
13
|
+
* MCP_HTTP=1 Enable via env var
|
|
14
|
+
* MCP_PORT=XXXX Override port via env var
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
|
+
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { logger } from './logger.js';
|
|
22
|
+
|
|
23
|
+
// ─── Types ───────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Any MCP server that can connect to a transport */
|
|
26
|
+
type ConnectableServer = { connect(transport: unknown): Promise<void> };
|
|
27
|
+
|
|
28
|
+
/** Factory function that creates a fresh MCP server instance */
|
|
29
|
+
export type McpServerFactory = () => ConnectableServer;
|
|
30
|
+
|
|
31
|
+
export interface DualTransportOptions {
|
|
32
|
+
serverName: string;
|
|
33
|
+
serverVersion: string;
|
|
34
|
+
/** Default HTTP port (used when --http is active) */
|
|
35
|
+
defaultPort: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TransportResult {
|
|
39
|
+
type: 'stdio' | 'http';
|
|
40
|
+
port?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Detection ───────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function isHttpMode(): boolean {
|
|
46
|
+
return (
|
|
47
|
+
process.argv.includes('--http') ||
|
|
48
|
+
process.env.MCP_HTTP === '1' ||
|
|
49
|
+
process.env.MCP_HTTP === 'true'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getPort(defaultPort: number): number {
|
|
54
|
+
const portArg = process.argv.find((a) => a.startsWith('--port='));
|
|
55
|
+
if (portArg) {
|
|
56
|
+
const parsed = parseInt(portArg.split('=')[1], 10);
|
|
57
|
+
if (!isNaN(parsed)) return parsed;
|
|
58
|
+
}
|
|
59
|
+
if (process.env.MCP_PORT) {
|
|
60
|
+
const parsed = parseInt(process.env.MCP_PORT, 10);
|
|
61
|
+
if (!isNaN(parsed)) return parsed;
|
|
62
|
+
}
|
|
63
|
+
return defaultPort;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Main Entry ──────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start an MCP server with dual transport support.
|
|
70
|
+
*
|
|
71
|
+
* @param createServer - Factory that creates a fresh MCP server (McpServer or Server)
|
|
72
|
+
* @param options - Server name, version, and default HTTP port
|
|
73
|
+
*
|
|
74
|
+
* For stdio: creates one server, connects to StdioServerTransport.
|
|
75
|
+
* For HTTP: creates a new server per session, connects to StreamableHTTPServerTransport.
|
|
76
|
+
*/
|
|
77
|
+
export async function startDualTransport(
|
|
78
|
+
createServer: McpServerFactory,
|
|
79
|
+
options: DualTransportOptions,
|
|
80
|
+
): Promise<TransportResult> {
|
|
81
|
+
if (isHttpMode()) {
|
|
82
|
+
return startHttpTransport(createServer, options);
|
|
83
|
+
}
|
|
84
|
+
return startStdioTransport(createServer, options);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Stdio Transport ─────────────────────────────────
|
|
88
|
+
|
|
89
|
+
async function startStdioTransport(
|
|
90
|
+
createServer: McpServerFactory,
|
|
91
|
+
options: DualTransportOptions,
|
|
92
|
+
): Promise<TransportResult> {
|
|
93
|
+
const server = createServer();
|
|
94
|
+
const transport = new StdioServerTransport();
|
|
95
|
+
await server.connect(transport);
|
|
96
|
+
logger.info(`${options.serverName} v${options.serverVersion} started (stdio)`);
|
|
97
|
+
return { type: 'stdio' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── HTTP Transport ──────────────────────────────────
|
|
101
|
+
|
|
102
|
+
interface SessionEntry {
|
|
103
|
+
transport: StreamableHTTPServerTransport;
|
|
104
|
+
createdAt: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function startHttpTransport(
|
|
108
|
+
createServer: McpServerFactory,
|
|
109
|
+
options: DualTransportOptions,
|
|
110
|
+
): Promise<TransportResult> {
|
|
111
|
+
const port = getPort(options.defaultPort);
|
|
112
|
+
const sessions = new Map<string, SessionEntry>();
|
|
113
|
+
|
|
114
|
+
const httpServer = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
115
|
+
try {
|
|
116
|
+
await handleHttpRequest(req, res, sessions, createServer, options);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.logError('HTTP request error', err);
|
|
119
|
+
if (!res.headersSent) {
|
|
120
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const host = process.env.MCP_HOST || '127.0.0.1';
|
|
127
|
+
httpServer.listen(port, host, () => {
|
|
128
|
+
logger.info(`${options.serverName} v${options.serverVersion} started (HTTP on ${host}:${port})`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Session cleanup: remove stale sessions older than 30 minutes
|
|
132
|
+
const cleanupInterval = setInterval(() => {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const staleThreshold = 30 * 60 * 1000;
|
|
135
|
+
for (const [sid, session] of sessions) {
|
|
136
|
+
if (now - session.createdAt > staleThreshold) {
|
|
137
|
+
session.transport.close();
|
|
138
|
+
sessions.delete(sid);
|
|
139
|
+
logger.info(`Session expired: ${sid.slice(0, 8)}...`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}, 60_000);
|
|
143
|
+
|
|
144
|
+
// Graceful shutdown
|
|
145
|
+
const shutdown = () => {
|
|
146
|
+
logger.info('Shutting down HTTP transport...');
|
|
147
|
+
clearInterval(cleanupInterval);
|
|
148
|
+
for (const [, session] of sessions) {
|
|
149
|
+
session.transport.close();
|
|
150
|
+
}
|
|
151
|
+
httpServer.close();
|
|
152
|
+
process.exit(0);
|
|
153
|
+
};
|
|
154
|
+
process.on('SIGINT', shutdown);
|
|
155
|
+
process.on('SIGTERM', shutdown);
|
|
156
|
+
|
|
157
|
+
return { type: 'http', port };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Request Handler ─────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async function handleHttpRequest(
|
|
163
|
+
req: IncomingMessage,
|
|
164
|
+
res: ServerResponse,
|
|
165
|
+
sessions: Map<string, SessionEntry>,
|
|
166
|
+
createServer: McpServerFactory,
|
|
167
|
+
options: DualTransportOptions,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
// CORS headers
|
|
170
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
171
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
172
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Session-ID');
|
|
173
|
+
res.setHeader('Access-Control-Expose-Headers', 'MCP-Session-ID');
|
|
174
|
+
|
|
175
|
+
if (req.method === 'OPTIONS') {
|
|
176
|
+
res.writeHead(204);
|
|
177
|
+
res.end();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Health check endpoint
|
|
182
|
+
if (req.url === '/health') {
|
|
183
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
184
|
+
res.end(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
status: 'ok',
|
|
187
|
+
server: options.serverName,
|
|
188
|
+
version: options.serverVersion,
|
|
189
|
+
transport: 'streamable-http',
|
|
190
|
+
sessions: sessions.size,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// MCP endpoint
|
|
197
|
+
if (req.url === '/mcp') {
|
|
198
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
199
|
+
|
|
200
|
+
if (req.method === 'POST') {
|
|
201
|
+
const body = await parseBody(req);
|
|
202
|
+
|
|
203
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
204
|
+
await sessions.get(sessionId)!.transport.handleRequest(req, res, body);
|
|
205
|
+
} else if (!sessionId) {
|
|
206
|
+
const mcpServer = createServer();
|
|
207
|
+
const transport = new StreamableHTTPServerTransport({
|
|
208
|
+
sessionIdGenerator: () => randomUUID(),
|
|
209
|
+
onsessioninitialized: (sid: string) => {
|
|
210
|
+
sessions.set(sid, { transport, createdAt: Date.now() });
|
|
211
|
+
logger.info(`Session created: ${sid.slice(0, 8)}... (${sessions.size} active)`);
|
|
212
|
+
},
|
|
213
|
+
onsessionclosed: (sid: string) => {
|
|
214
|
+
sessions.delete(sid);
|
|
215
|
+
logger.info(`Session closed: ${sid.slice(0, 8)}... (${sessions.size} active)`);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await mcpServer.connect(transport);
|
|
220
|
+
await transport.handleRequest(req, res, body);
|
|
221
|
+
} else {
|
|
222
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify({ error: 'Session not found', code: 'SESSION_NOT_FOUND' }));
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (req.method === 'GET') {
|
|
229
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
230
|
+
await sessions.get(sessionId)!.transport.handleRequest(req, res);
|
|
231
|
+
} else {
|
|
232
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
233
|
+
res.end(JSON.stringify({ error: 'Invalid or missing session ID', code: 'INVALID_SESSION' }));
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (req.method === 'DELETE') {
|
|
239
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
240
|
+
const session = sessions.get(sessionId)!;
|
|
241
|
+
await session.transport.handleRequest(req, res);
|
|
242
|
+
sessions.delete(sessionId);
|
|
243
|
+
} else {
|
|
244
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
245
|
+
res.end(JSON.stringify({ error: 'Session not found', code: 'SESSION_NOT_FOUND' }));
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 404 for anything else
|
|
252
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
253
|
+
res.end(JSON.stringify({ error: 'Not found', code: 'NOT_FOUND' }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Body Parser ─────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function parseBody(req: IncomingMessage): Promise<unknown> {
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
const chunks: Buffer[] = [];
|
|
261
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
262
|
+
req.on('end', () => {
|
|
263
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
264
|
+
try {
|
|
265
|
+
resolve(body ? JSON.parse(body) : undefined);
|
|
266
|
+
} catch {
|
|
267
|
+
resolve(undefined);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
req.on('error', reject);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging for mcp-video
|
|
3
|
+
* Logs to stderr to not interfere with MCP protocol on stdout
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
7
|
+
|
|
8
|
+
interface LogEntry {
|
|
9
|
+
level: LogLevel;
|
|
10
|
+
message: string;
|
|
11
|
+
context?: Record<string, unknown>;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEBUG = process.env.DEBUG?.includes('mcp-video') || process.env.MCP_VIDEO_DEBUG === '1';
|
|
16
|
+
|
|
17
|
+
function formatEntry(entry: LogEntry): string {
|
|
18
|
+
const prefix = `[${entry.timestamp}] [${entry.level.toUpperCase()}]`;
|
|
19
|
+
const ctx = entry.context ? ` ${JSON.stringify(entry.context)}` : '';
|
|
20
|
+
return `${prefix} ${entry.message}${ctx}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
|
|
24
|
+
if (level === 'debug' && !DEBUG) return;
|
|
25
|
+
|
|
26
|
+
const entry: LogEntry = {
|
|
27
|
+
level,
|
|
28
|
+
message,
|
|
29
|
+
context,
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
console.error(formatEntry(entry));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const logger = {
|
|
37
|
+
debug: (message: string, context?: Record<string, unknown>) => log('debug', message, context),
|
|
38
|
+
info: (message: string, context?: Record<string, unknown>) => log('info', message, context),
|
|
39
|
+
warn: (message: string, context?: Record<string, unknown>) => log('warn', message, context),
|
|
40
|
+
error: (message: string, context?: Record<string, unknown>) => log('error', message, context),
|
|
41
|
+
|
|
42
|
+
logError: (message: string, error: unknown, context?: Record<string, unknown>) => {
|
|
43
|
+
const errorContext: Record<string, unknown> = { ...context };
|
|
44
|
+
|
|
45
|
+
if (error instanceof Error) {
|
|
46
|
+
errorContext.errorMessage = error.message;
|
|
47
|
+
errorContext.errorName = error.name;
|
|
48
|
+
if (DEBUG && error.stack) {
|
|
49
|
+
errorContext.stack = error.stack;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
errorContext.error = String(error);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
log('error', message, errorContext);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type Logger = typeof logger;
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for MCP tool handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ToolResponse {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
content: Array<{ type: string; text: string }>;
|
|
8
|
+
isError?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK passes raw JSON; validation is via tool schemas
|
|
12
|
+
export type ToolHandler = (args: any) => ToolResponse | Promise<ToolResponse>;
|
|
13
|
+
|
|
14
|
+
/** Helper to wrap a JSON result into a ToolResponse */
|
|
15
|
+
export function jsonResponse(result: unknown, isError?: boolean): ToolResponse {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
18
|
+
...(isError !== undefined ? { isError } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Helper to create an error ToolResponse with structured error code */
|
|
23
|
+
export function errorResponse(message: string, code?: string): ToolResponse {
|
|
24
|
+
return jsonResponse({ error: message, code: code ?? 'INTERNAL_ERROR' }, true);
|
|
25
|
+
}
|