# Directory Structure
```
├── .gitignore
├── dist
│ └── mcp-ffmpeg.js
├── Dockerfile
├── eslint.config.js
├── package-lock.json
├── package.json
├── README.md
├── smithery.yaml
├── src
│ └── mcp-ffmpeg.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# MCP FFmpeg Video Processor
[](https://smithery.ai/server/@bitscorp-mcp/mcp-ffmpeg)
A Node.js server that uses FFmpeg to manipulate video files. This server provides APIs to:
- Resize videos to different resolutions (360p, 480p, 720p, 1080p)
- Extract audio from videos in various formats (MP3, AAC, WAV, OGG)
## Prerequisites
Before running this application, you need to have the following installed:
1. **Node.js** (v14 or higher)
2. **FFmpeg** - This is required for video processing
### Installing FFmpeg
#### On macOS:
```bash
brew install ffmpeg
```
#### On Ubuntu/Debian:
```bash
sudo apt update
sudo apt install ffmpeg
```
#### On Windows:
1. Download FFmpeg from the [official website](https://ffmpeg.org/download.html)
2. Extract the files to a folder (e.g., `C:\ffmpeg`)
3. Add the `bin` folder to your PATH environment variable
## Installation
1. Clone this repository:
```bash
git clone https://github.com/bitscorp-mcp/mcp-ffmpeg.git
cd mcp-ffmpeg
```
2. Install dependencies:
```bash
npm install
```
### Installing via Smithery
To install mcp-ffmpeg for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@bitscorp-mcp/mcp-ffmpeg):
```bash
npx -y @smithery/cli install @bitscorp-mcp/mcp-ffmpeg --client claude
```
## Running the Server
Start the server with:
```bash
npm start
```
For development with auto-restart on file changes:
```bash
npm run dev
```
### Installing via Smithery
To install mcp-ffmpeg for Claude Desktop automatically via [Smithery](https://smithery.ai/server/bitscorp-mcp/mcp-ffmpeg):
```bash
npx -y @smithery/cli install @bitscorp-mcp/mcp-ffmpeg --client claude
```
To install mcp-ffmpeg for Cursor, go to Settings -> Cursor Settings -> Features -> MCP Servers -> + Add
Select Type: command and paste the below, using your API key from Adjust
```
npx -y @smithery/cli@latest run @bitscorp/mcp-ffmpeg
```
## Using with Claude Desktop
This MCP FFmpeg server can be integrated with Claude Desktop to process videos through natural language requests.
### Running with npx
You can run the server directly with npx:
```bash
npx /path/to/mcp-ffmpeg
```
Or if you've published the package to npm:
```bash
npx mcp-ffmpeg
```
### Configuring Claude Desktop
To add this server to Claude Desktop, update your Claude Desktop configuration file:
1. Locate your Claude Desktop config file:
- macOS: `~/.config/claude-desktop/config.json` or `~/Library/Application Support/Claude Desktop/config.json`
- Windows: `%APPDATA%\Claude Desktop\config.json`
- Linux: `~/.config/claude-desktop/config.json`
2. Add the FFmpeg MCP server to the `mcpServers` section:
```json
{
"mcpServers": {
"ffmpeg": {
"command": "npx",
"args": [
"--yes",
"/absolute/path/to/mcp-ffmpeg"
]
}
}
}
```
If you've published the package to npm:
```json
{
"mcpServers": {
"ffmpeg": {
"command": "npx",
"args": [
"--yes",
"mcp-ffmpeg"
]
}
}
}
```
3. Restart Claude Desktop for the changes to take effect.
### Example Prompts for Claude
Once configured, you can use prompts like:
```
Using the ffmpeg MCP server, please resize the video at /path/to/video.mp4 to 720p resolution.
```
## Notes
- Uploaded videos are stored temporarily in the `uploads` directory
- Processed videos and audio files are stored in the `output` directory
- The server has a file size limit of 500MB for uploads
## License
MIT
```
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
```javascript
import neostandard from 'neostandard'
export default neostandard({
ignores: ['node_modules', 'dist']
})
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
startCommand:
type: stdio
configSchema:
# JSON Schema defining the configuration options for the MCP.
type: object
properties: {}
commandFunction:
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
|-
(config) => ({ command: 'node', args: ['dist/mcp-ffmpeg.js'] })
exampleConfig: {}
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
### Multi-stage Dockerfile for MCP FFmpeg Server
# Stage 1: Builder
FROM node:lts-alpine AS builder
# Install ffmpeg and any build dependencies
RUN apk add --no-cache ffmpeg
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies including dev dependencies (needed for building)
RUN npm install
# Copy the rest of the application source code
COPY . .
# Build the TypeScript code
RUN npm run build
# Stage 2: Production
FROM node:lts-alpine
# Install ffmpeg in production image
RUN apk add --no-cache ffmpeg
# Set working directory
WORKDIR /app
# Copy only the necessary files from builder
COPY package*.json ./
# Install production dependencies only
RUN npm install --production --ignore-scripts
# Copy built files from the builder stage
COPY --from=builder /app/dist ./dist
# Expose port if needed (not needed for stdio-based MCP servers)
# Start the MCP server
CMD [ "node", "dist/mcp-ffmpeg.js" ]
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "mcp-ffmpeg",
"version": "1.0.5",
"description": "FFmpeg MCP server",
"main": "dist/mcp-ffmpeg.js",
"type": "module",
"bin": {
"mcp-ffmpeg": "./dist/mcp-ffmpeg.js"
},
"scripts": {
"build": "tsc",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"dev": "npm run build && npx @modelcontextprotocol/inspector node dist/mcp-ffmpeg.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/platformatic/mcp-node.git"
},
"license": "MIT",
"author": "Platformatic Inc. <[email protected]> (https://platformatic.dev)",
"contributors": [
{
"name": "Alexandr Korsak",
"email": "[email protected]"
}
],
"bugs": {
"url": "https://github.com/bitscorp-mcp/mcp-ffmpeg/issues"
},
"homepage": "https://github.com/bitscorp-mcp/mcp-ffmpeg#readme",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.0",
"node-notifier": "^10.0.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.5",
"@types/node-notifier": "^8.0.5",
"eslint": "^9.21.0",
"neostandard": "^0.12.1",
"typescript": "^5.7.3"
},
"keywords": [
"mcp",
"model-context-protocol",
"ai",
"nodejs",
"javascript-runtime"
],
"engines": {
"node": ">=22.0.0"
}
}
```
--------------------------------------------------------------------------------
/src/mcp-ffmpeg.ts:
--------------------------------------------------------------------------------
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { exec, ExecOptions as ChildProcessExecOptions } from "child_process";
import { promisify } from "util";
import * as path from "path";
import * as fs from "fs/promises";
import * as os from "node:os";
import notifier from "node-notifier";
const execAsync = promisify(exec);
// Create an MCP server
const server = new McpServer({
name: "FFmpegProcessor",
version: "1.0.0"
});
// Define available resolutions
const RESOLUTIONS = {
"360p": { width: 640, height: 360 },
"480p": { width: 854, height: 480 },
"720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 }
};
/**
* Helper function to ask for permission using node-notifier
*/
async function askPermission(action: string): Promise<boolean> {
// Skip notification if DISABLE_NOTIFICATIONS is set
if (process.env.DISABLE_NOTIFICATIONS === 'true') {
console.log(`Auto-allowing action (notifications disabled): ${action}`);
return true;
}
return new Promise((resolve) => {
notifier.notify({
title: 'FFmpeg Processor Permission Request',
message: `${action}`,
wait: true,
timeout: 60,
actions: 'Allow',
closeLabel: 'Deny'
}, (err, response, metadata) => {
if (err) {
console.error('Error showing notification:', err);
resolve(false);
return;
}
const buttonPressed = metadata?.activationValue || response;
resolve(buttonPressed !== 'Deny');
});
});
}
/**
* Helper function to ensure output directories exist
*/
async function ensureDirectoriesExist() {
const outputDir = path.join(os.tmpdir(), 'ffmpeg-output');
try {
await fs.mkdir(outputDir, { recursive: true });
return outputDir;
} catch (error) {
console.error('Error creating output directory:', error);
return os.tmpdir();
}
}
// Tool to check FFmpeg version
server.tool(
"get-ffmpeg-version",
"Get the version of FFmpeg installed on the system",
{},
async () => {
try {
const { stdout, stderr } = await execAsync('ffmpeg -version');
// Extract the version from the output
const versionMatch = stdout.match(/ffmpeg version (\S+)/);
const version = versionMatch ? versionMatch[1] : 'Unknown';
return {
content: [{
type: "text" as const,
text: `FFmpeg Version: ${version}\n\nFull version info:\n${stdout}`
}]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text" as const,
text: `Error getting FFmpeg version: ${errorMessage}\n\nMake sure FFmpeg is installed and in your PATH.`
}]
};
}
}
);
// Tool to resize video
server.tool(
"resize-video",
"Resize a video to one or more standard resolutions",
{
videoPath: z.string().describe("Path to the video file to resize"),
resolutions: z.array(z.enum(["360p", "480p", "720p", "1080p"])).describe("Resolutions to convert the video to"),
outputDir: z.string().optional().describe("Optional directory to save the output files (defaults to a temporary directory)")
},
async ({ videoPath, resolutions, outputDir }) => {
try {
// Resolve the absolute path
const absVideoPath = path.resolve(videoPath);
// Check if file exists
try {
await fs.access(absVideoPath);
} catch (error) {
return {
isError: true,
content: [{
type: "text" as const,
text: `Error: Video file not found at ${absVideoPath}`
}]
};
}
// Determine output directory
let outputDirectory = outputDir ? path.resolve(outputDir) : await ensureDirectoriesExist();
// Check if output directory exists and is writable
try {
await fs.access(outputDirectory, fs.constants.W_OK);
} catch (error) {
return {
isError: true,
content: [{
type: "text" as const,
text: `Error: Output directory ${outputDirectory} does not exist or is not writable`
}]
};
}
// Format command for permission request
const resolutionsStr = resolutions.join(', ');
const permissionMessage = `Resize video ${path.basename(absVideoPath)} to ${resolutionsStr}`;
// Ask for permission
const permitted = await askPermission(permissionMessage);
if (!permitted) {
return {
isError: true,
content: [{
type: "text" as const,
text: "Permission denied by user"
}]
};
}
// Get video filename without extension
const videoFilename = path.basename(absVideoPath, path.extname(absVideoPath));
// Define the type for our results
type ResizeResult = {
resolution: "360p" | "480p" | "720p" | "1080p";
outputPath: string;
success: boolean;
error?: string;
};
// Process each resolution
const results: ResizeResult[] = [];
for (const resolution of resolutions) {
const { width, height } = RESOLUTIONS[resolution as keyof typeof RESOLUTIONS];
const outputFilename = `${videoFilename}_${resolution}${path.extname(absVideoPath)}`;
const outputPath = path.join(outputDirectory, outputFilename);
// Build FFmpeg command
const command = `ffmpeg -i "${absVideoPath}" -vf "scale=${width}:${height}" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k "${outputPath}"`;
try {
// Execute FFmpeg command
const { stdout, stderr } = await execAsync(command);
results.push({
resolution,
outputPath,
success: true
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
results.push({
resolution,
outputPath,
success: false,
error: errorMessage
});
}
}
// Format results
const successCount = results.filter(r => r.success).length;
const failCount = results.length - successCount;
let resultText = `Processed ${results.length} resolutions (${successCount} successful, ${failCount} failed)\n\n`;
results.forEach(result => {
if (result.success) {
resultText += `✅ ${result.resolution}: ${result.outputPath}\n`;
} else {
resultText += `❌ ${result.resolution}: Failed - ${result.error}\n`;
}
});
return {
content: [{
type: "text" as const,
text: resultText
}]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text" as const,
text: `Error resizing video: ${errorMessage}`
}]
};
}
}
);
// Tool to extract audio from video
server.tool(
"extract-audio",
"Extract audio from a video file",
{
videoPath: z.string().describe("Path to the video file to extract audio from"),
format: z.enum(["mp3", "aac", "wav", "ogg"]).default("mp3").describe("Audio format to extract"),
outputDir: z.string().optional().describe("Optional directory to save the output file (defaults to a temporary directory)")
},
async ({ videoPath, format, outputDir }) => {
try {
// Resolve the absolute path
const absVideoPath = path.resolve(videoPath);
// Check if file exists
try {
await fs.access(absVideoPath);
} catch (error) {
return {
isError: true,
content: [{
type: "text" as const,
text: `Error: Video file not found at ${absVideoPath}`
}]
};
}
// Determine output directory
let outputDirectory = outputDir ? path.resolve(outputDir) : await ensureDirectoriesExist();
// Check if output directory exists and is writable
try {
await fs.access(outputDirectory, fs.constants.W_OK);
} catch (error) {
return {
isError: true,
content: [{
type: "text" as const,
text: `Error: Output directory ${outputDirectory} does not exist or is not writable`
}]
};
}
// Format command for permission request
const permissionMessage = `Extract ${format} audio from video ${path.basename(absVideoPath)}`;
// Ask for permission
const permitted = await askPermission(permissionMessage);
if (!permitted) {
return {
isError: true,
content: [{
type: "text" as const,
text: "Permission denied by user"
}]
};
}
// Get video filename without extension
const videoFilename = path.basename(absVideoPath, path.extname(absVideoPath));
const outputFilename = `${videoFilename}.${format}`;
const outputPath = path.join(outputDirectory, outputFilename);
// Determine audio codec based on format
let audioCodec;
switch (format) {
case 'mp3':
audioCodec = 'libmp3lame';
break;
case 'aac':
audioCodec = 'aac';
break;
case 'wav':
audioCodec = 'pcm_s16le';
break;
case 'ogg':
audioCodec = 'libvorbis';
break;
default:
audioCodec = 'libmp3lame';
}
// Build FFmpeg command
const command = `ffmpeg -i "${absVideoPath}" -vn -acodec ${audioCodec} "${outputPath}"`;
try {
// Execute FFmpeg command
const { stdout, stderr } = await execAsync(command);
return {
content: [{
type: "text" as const,
text: `Successfully extracted audio to: ${outputPath}`
}]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text" as const,
text: `Error extracting audio: ${errorMessage}`
}]
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text" as const,
text: `Error extracting audio: ${errorMessage}`
}]
};
}
}
);
// Tool to get video information
server.tool(
"get-video-info",
"Get detailed information about a video file",
{
videoPath: z.string().describe("Path to the video file to analyze")
},
async ({ videoPath }) => {
try {
// Resolve the absolute path
const absVideoPath = path.resolve(videoPath);
// Check if file exists
try {
await fs.access(absVideoPath);
} catch (error) {
return {
isError: true,
content: [{
type: "text" as const,
text: `Error: Video file not found at ${absVideoPath}`
}]
};
}
// Format command for permission request
const permissionMessage = `Analyze video file ${path.basename(absVideoPath)}`;
// Ask for permission
const permitted = await askPermission(permissionMessage);
if (!permitted) {
return {
isError: true,
content: [{
type: "text" as const,
text: "Permission denied by user"
}]
};
}
// Build FFprobe command to get video information in JSON format
const command = `ffprobe -v quiet -print_format json -show_format -show_streams "${absVideoPath}"`;
// Execute FFprobe command
const { stdout, stderr } = await execAsync(command);
// Parse the JSON output
const videoInfo = JSON.parse(stdout);
// Format the output in a readable way
let formattedInfo = `Video Information for: ${path.basename(absVideoPath)}\n\n`;
// Format information
if (videoInfo.format) {
formattedInfo += `Format: ${videoInfo.format.format_name}\n`;
formattedInfo += `Duration: ${videoInfo.format.duration} seconds\n`;
formattedInfo += `Size: ${(parseInt(videoInfo.format.size) / (1024 * 1024)).toFixed(2)} MB\n`;
formattedInfo += `Bitrate: ${(parseInt(videoInfo.format.bit_rate) / 1000).toFixed(2)} kbps\n\n`;
}
// Stream information
if (videoInfo.streams && videoInfo.streams.length > 0) {
formattedInfo += `Streams:\n`;
videoInfo.streams.forEach((stream: any, index: number) => {
formattedInfo += `\nStream #${index} (${stream.codec_type}):\n`;
if (stream.codec_type === 'video') {
formattedInfo += ` Codec: ${stream.codec_name}\n`;
formattedInfo += ` Resolution: ${stream.width}x${stream.height}\n`;
formattedInfo += ` Frame rate: ${stream.r_frame_rate}\n`;
if (stream.bit_rate) {
formattedInfo += ` Bitrate: ${(parseInt(stream.bit_rate) / 1000).toFixed(2)} kbps\n`;
}
} else if (stream.codec_type === 'audio') {
formattedInfo += ` Codec: ${stream.codec_name}\n`;
formattedInfo += ` Sample rate: ${stream.sample_rate} Hz\n`;
formattedInfo += ` Channels: ${stream.channels}\n`;
if (stream.bit_rate) {
formattedInfo += ` Bitrate: ${(parseInt(stream.bit_rate) / 1000).toFixed(2)} kbps\n`;
}
}
});
}
return {
content: [{
type: "text" as const,
text: formattedInfo
}]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text" as const,
text: `Error getting video information: ${errorMessage}`
}]
};
}
}
);
// Start the server
async function main() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("FFmpeg MCP Server running");
} catch (error) {
console.error("Error starting server:", error);
process.exit(1);
}
}
main();
```