feat: add MCP server for file conversion via Claude Code

Standalone stdio MCP server exposing VERT's conversion capabilities.
140+ image formats (ImageMagick WASM), 28+ audio (system ffmpeg),
11 document formats (system pandoc).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rmakestrash-jpg 2026-02-24 10:44:09 -05:00
parent 897ae56702
commit 97cfba2acb
12 changed files with 3392 additions and 0 deletions

2
mcp/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

1309
mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
mcp/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "@vert-sh/mcp-server",
"version": "0.1.0",
"type": "module",
"bin": "./dist/index.js",
"files": [
"dist/"
],
"license": "AGPL-3.0-only",
"repository": {
"type": "git",
"url": "https://github.com/VERT-sh/VERT",
"directory": "mcp"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"prepublishOnly": "tsc"
},
"dependencies": {
"@imagemagick/magick-wasm": "0.0.38",
"@modelcontextprotocol/sdk": "1.27.0",
"byte-data": "19.0.1",
"riff-file": "1.0.3",
"which": "^3.0.0",
"yazl": "3.3.1",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22",
"@types/which": "^3",
"@types/yazl": "^2",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -0,0 +1,355 @@
import { execFile } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
import which from "which";
import { FormatInfo } from "./types.js";
import type { ConvertOptions, ConvertResult, NodeConverter } from "./types.js";
/** Conversion timeout in milliseconds. */
const TIMEOUT_MS = 120_000;
/** Maximum number of concurrent ffmpeg processes. */
const MAX_CONCURRENT = 3;
// ── Audio format definitions (from VERT ffmpeg.svelte.ts lines 43-74) ──
const AUDIO_FORMATS: FormatInfo[] = [
new FormatInfo("mp3"),
new FormatInfo("wav"),
new FormatInfo("flac"),
new FormatInfo("ogg"),
new FormatInfo("mogg", true, false), // from only
new FormatInfo("oga"),
new FormatInfo("opus"),
new FormatInfo("aac"),
new FormatInfo("alac"), // output extension becomes .m4a
new FormatInfo("m4a"),
new FormatInfo("caf", true, false), // from only
new FormatInfo("wma"),
new FormatInfo("amr"),
new FormatInfo("ac3"),
new FormatInfo("aiff"),
new FormatInfo("aifc"),
new FormatInfo("aif"),
new FormatInfo("mp1", true, false), // from only
new FormatInfo("mp2"),
new FormatInfo("mpc", true, false), // from only
new FormatInfo("dsd", true, false), // from only
new FormatInfo("dsf", true, false), // from only
new FormatInfo("dff", true, false), // from only
new FormatInfo("mqa", true, false), // from only
new FormatInfo("au"),
new FormatInfo("m4b"),
new FormatInfo("voc"),
new FormatInfo("weba"),
];
// ── Video format definitions (input only for audio extraction) ──
const VIDEO_FORMATS: FormatInfo[] = [
new FormatInfo("mkv", true, false, false),
new FormatInfo("mp4", true, false, false),
new FormatInfo("avi", true, false, false),
new FormatInfo("mov", true, false, false),
new FormatInfo("webm", true, false, false),
new FormatInfo("ts", true, false, false),
new FormatInfo("mts", true, false, false),
new FormatInfo("m2ts", true, false, false),
new FormatInfo("wmv", true, false, false),
new FormatInfo("mpg", true, false, false),
new FormatInfo("mpeg", true, false, false),
new FormatInfo("flv", true, false, false),
new FormatInfo("f4v", true, false, false),
new FormatInfo("vob", true, false, false),
new FormatInfo("m4v", true, false, false),
new FormatInfo("3gp", true, false, false),
new FormatInfo("3g2", true, false, false),
new FormatInfo("mxf", true, false, false),
new FormatInfo("ogv", true, false, false),
new FormatInfo("rm", true, false, false),
new FormatInfo("rmvb", true, false, false),
new FormatInfo("divx", true, false, false),
];
/** Set of video extensions (undotted, lowercase) for detecting video input. */
const VIDEO_EXTENSIONS = new Set(
VIDEO_FORMATS.map((f) => f.name.slice(1).toLowerCase()),
);
// ── Codec mappings (from VERT ffmpeg.svelte.ts lines 636-699 + additions) ──
/**
* Get the FFmpeg codec string for a given output format extension.
* Adapted from VERT's getCodecs() with additions for missing codecs.
*/
function getCodec(ext: string): string {
switch (ext) {
case "mp3":
return "libmp3lame";
case "wav":
return "pcm_s16le";
case "flac":
return "flac";
case "ogg":
case "oga":
return "libvorbis";
case "opus":
return "libopus";
case "aac":
return "aac";
case "alac":
return "alac";
case "m4a":
return "aac";
case "m4b":
return "aac";
case "wma":
return "wmav2";
case "amr":
return "libopencore_amrnb";
case "ac3":
return "ac3";
case "aiff":
case "aifc":
case "aif":
return "pcm_s16be";
case "mp2":
return "mp2";
case "au":
return "pcm_mulaw";
case "voc":
return "pcm_u8";
case "weba":
return "libopus";
case "caf":
return "pcm_s16le";
default:
return "copy";
}
}
/**
* Check if the given extension is a video format we recognize as input.
*/
function isVideoInput(ext: string): boolean {
return VIDEO_EXTENSIONS.has(ext.toLowerCase());
}
// ── Concurrency limiter ──
let activeProcesses = 0;
const waitQueue: Array<() => void> = [];
function acquireSlot(): Promise<void> {
if (activeProcesses < MAX_CONCURRENT) {
activeProcesses++;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
waitQueue.push(() => {
activeProcesses++;
resolve();
});
});
}
function releaseSlot(): void {
activeProcesses--;
const next = waitQueue.shift();
if (next) {
next();
}
}
// ── Helpers ──
/**
* Run ffmpeg with the given arguments via execFile (no shell).
* Returns a promise that resolves on success or rejects on failure.
*/
function runFFmpeg(ffmpegPath: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFile(
ffmpegPath,
args,
{
timeout: TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {
reject(new Error(
`ffmpeg failed: ${error.message}\n${stderr}`,
));
} else {
resolve({ stdout, stderr });
}
},
);
});
}
/**
* Build the FFmpeg conversion command arguments.
* Adapted from VERT's buildConversionCommand() (ffmpeg.svelte.ts lines 315-527).
*/
function buildConversionArgs(options: ConvertOptions, inputExt: string): string[] {
const {
inputPath,
outputPath,
outputFormat,
audioBitrate,
sampleRate,
keepMetadata,
} = options;
// Determine the actual output format and codec
let actualOutputFormat = outputFormat.toLowerCase();
let actualOutputPath = outputPath;
// ALAC: output_format="alac" -> actual file ext .m4a, codec alac
if (actualOutputFormat === "alac") {
// The output path should already have .m4a extension (handled by caller),
// but the codec is "alac"
actualOutputFormat = "alac";
}
const codec = getCodec(actualOutputFormat);
const isVideoSource = isVideoInput(inputExt);
const args: string[] = [];
// Security: restrict protocols to prevent SSRF
args.push("-protocol_whitelist", "file,pipe");
// Overwrite output without asking (we handle uniqueness ourselves)
args.push("-y");
// Input file
// -i takes its argument directly; no -- between flag and value.
args.push("-i", inputPath);
// If extracting audio from video, map only the first audio stream
if (isVideoSource) {
args.push("-map", "0:a:0");
}
// Audio codec
if (codec !== "copy") {
args.push("-c:a", codec);
}
// Metadata handling
if (keepMetadata === false) {
args.push("-map_metadata", "-1");
args.push("-map_chapters", "-1");
if (!isVideoSource) {
args.push("-map", "a");
}
}
// Audio bitrate
if (audioBitrate) {
args.push("-b:a", audioBitrate);
}
// Sample rate handling
if (sampleRate) {
// Opus 44100 -> 48000 auto-adjustment
if ((actualOutputFormat === "opus" || actualOutputFormat === "weba") && sampleRate === 44100) {
args.push("-ar", "48000");
} else {
args.push("-ar", String(sampleRate));
}
} else {
// If no sample rate specified and output is opus, default to 48000
// (Opus doesn't support 44100Hz)
if (actualOutputFormat === "opus" || actualOutputFormat === "weba") {
args.push("-ar", "48000");
}
}
// End of options separator, then output file path
// -- prevents output paths starting with "-" from being interpreted as flags
args.push("--", actualOutputPath);
return args;
}
// ── FFmpeg Node Converter ──
/**
* FFmpeg-based audio converter using the system ffmpeg binary.
* Gracefully unavailable if ffmpeg is not installed.
*/
export class FFmpegNodeConverter implements NodeConverter {
readonly name = "ffmpeg";
readonly supportedFormats: FormatInfo[] = [...AUDIO_FORMATS, ...VIDEO_FORMATS];
private ffmpegPath: string | null = null;
private available: boolean | null = null;
/**
* Check if ffmpeg is available on the system.
* Caches the result after the first check.
*/
async isAvailable(): Promise<boolean> {
if (this.available !== null) {
return this.available;
}
try {
this.ffmpegPath = await which("ffmpeg");
this.available = true;
} catch {
this.ffmpegPath = null;
this.available = false;
}
return this.available;
}
/**
* Convert an audio file using system ffmpeg.
* Supports audio-to-audio conversion and video-to-audio extraction.
*/
async convert(options: ConvertOptions): Promise<ConvertResult> {
if (!(await this.isAvailable()) || !this.ffmpegPath) {
throw new Error("ffmpeg is not installed on this system");
}
const inputExt = path.extname(options.inputPath).slice(1).toLowerCase();
// Determine actual output path (ALAC uses .m4a extension)
let actualOutputPath = options.outputPath;
if (options.outputFormat.toLowerCase() === "alac") {
const dir = path.dirname(options.outputPath);
const base = path.basename(options.outputPath, path.extname(options.outputPath));
actualOutputPath = path.join(dir, `${base}.m4a`);
}
const effectiveOptions: ConvertOptions = {
...options,
outputPath: actualOutputPath,
};
const args = buildConversionArgs(effectiveOptions, inputExt);
await acquireSlot();
try {
await runFFmpeg(this.ffmpegPath, args);
} finally {
releaseSlot();
}
// Verify output exists and get size
const stats = await stat(actualOutputPath);
return {
outputPath: actualOutputPath,
format: options.outputFormat.toLowerCase(),
sizeBytes: stats.size,
};
}
}

View File

@ -0,0 +1,620 @@
import { createRequire } from "node:module";
import { readFileSync, writeFileSync, statSync } from "node:fs";
import { basename, extname } from "node:path";
import {
initializeImageMagick,
ImageMagick,
MagickFormat,
MagickReadSettings,
} from "@imagemagick/magick-wasm";
import yazl from "yazl";
import { parseAni } from "../util/parse-ani.js";
import { FormatInfo, type ConvertOptions, type ConvertResult, type NodeConverter } from "./types.js";
/** Maximum number of frames to extract from ICO/ANI files. */
const MAX_FRAMES = 256;
/** Conversion timeout in milliseconds. */
const CONVERSION_TIMEOUT_MS = 120_000;
// ---------------------------------------------------------------------------
// Format lists
// ---------------------------------------------------------------------------
// Blocked formats that lack WASM delegates or are unsafe:
// svg, nef, cr2, arw, dng, rw2, raf, orf, pef, mos, raw, dcr, crw, cr3, 3fr,
// erf, mrw, mef, nrw, srw, sr2, srf, eps, icns, xcf
// ps, ps1, svgz, epdf, epi, eps2, eps3, epsf, epsi, ept, ept2, ept3
/** Manually tested image formats from VERT (magick.svelte.ts lines 19-78), blocked formats removed. */
const MANUAL_FORMATS: FormatInfo[] = [
new FormatInfo("png", true, true),
new FormatInfo("jpeg", true, true),
new FormatInfo("jpg", true, true),
new FormatInfo("webp", true, true),
new FormatInfo("gif", true, true),
// svg blocked (no WASM delegate)
new FormatInfo("jxl", true, true),
new FormatInfo("avif", true, true),
new FormatInfo("heic", true, false),
new FormatInfo("heif", true, false),
new FormatInfo("ico", true, true),
new FormatInfo("bmp", true, true),
new FormatInfo("cur", true, true),
new FormatInfo("ani", true, false),
// icns blocked (needs vert-wasm)
// nef blocked (needs libraw)
// cr2 blocked (needs libraw)
new FormatInfo("hdr", true, true),
new FormatInfo("jpe", true, true),
new FormatInfo("mat", true, true),
new FormatInfo("pbm", true, true),
new FormatInfo("pfm", true, true),
new FormatInfo("pgm", true, true),
new FormatInfo("pnm", true, true),
new FormatInfo("ppm", true, true),
new FormatInfo("tiff", true, true),
new FormatInfo("jfif", true, true),
// eps blocked (needs Ghostscript)
new FormatInfo("psd", true, true),
new FormatInfo("svg", false, true),
new FormatInfo("svgz", false, true),
// arw blocked (needs libraw)
new FormatInfo("tif", true, true),
// dng blocked (needs libraw)
// xcf blocked
// rw2 blocked (needs libraw)
// raf blocked (needs libraw)
// orf blocked (needs libraw)
// pef blocked (needs libraw)
// mos blocked (needs libraw)
// raw blocked (needs libraw)
// dcr blocked (needs libraw)
// crw blocked (needs libraw)
// cr3 blocked (needs libraw)
// 3fr blocked (needs libraw)
// erf blocked (needs libraw)
// mrw blocked (needs libraw)
// mef blocked (needs libraw)
// nrw blocked (needs libraw)
// srw blocked (needs libraw)
// sr2 blocked (needs libraw)
// srf blocked (needs libraw)
];
/** Automated formats from VERT (magick-automated.ts), blocked formats removed. */
const AUTOMATED_FORMATS: FormatInfo[] = [
new FormatInfo("a", false, true),
new FormatInfo("aai", true, true),
new FormatInfo("ai", false, true),
new FormatInfo("art", false, true),
new FormatInfo("avs", true, true),
new FormatInfo("b", false, true),
new FormatInfo("bgr", false, true),
new FormatInfo("bgra", false, true),
new FormatInfo("bgro", false, true),
new FormatInfo("bmp2", true, true),
new FormatInfo("bmp3", true, true),
new FormatInfo("brf", false, true),
new FormatInfo("cal", false, true),
new FormatInfo("cals", false, true),
new FormatInfo("cin", true, true),
new FormatInfo("cip", false, true),
new FormatInfo("cmyk", false, true),
new FormatInfo("cmyka", false, true),
new FormatInfo("dcx", true, true),
new FormatInfo("dds", true, true),
new FormatInfo("dpx", true, true),
new FormatInfo("dxt1", true, true),
new FormatInfo("dxt5", true, true),
// epdf blocked
// epi blocked
// eps2 blocked
// eps3 blocked
// epsf blocked
// epsi blocked
// ept blocked
// ept2 blocked
// ept3 blocked
new FormatInfo("exr", true, true),
new FormatInfo("farbfeld", true, true),
new FormatInfo("fax", true, true),
new FormatInfo("ff", true, true),
new FormatInfo("fit", true, true),
new FormatInfo("fits", true, true),
new FormatInfo("fl32", true, true),
new FormatInfo("fts", true, true),
new FormatInfo("ftxt", false, true),
new FormatInfo("g", false, true),
new FormatInfo("g3", true, true),
new FormatInfo("g4", false, true),
new FormatInfo("gif87", true, true),
new FormatInfo("gray", false, true),
new FormatInfo("graya", false, true),
new FormatInfo("group4", false, true),
new FormatInfo("hrz", true, true),
new FormatInfo("icb", true, true),
new FormatInfo("icon", true, true),
new FormatInfo("info", false, true),
new FormatInfo("ipl", true, true),
new FormatInfo("isobrl", false, true),
new FormatInfo("isobrl6", false, true),
new FormatInfo("j2c", true, true),
new FormatInfo("j2k", true, true),
new FormatInfo("jng", true, true),
new FormatInfo("jp2", true, true),
new FormatInfo("jpc", true, true),
new FormatInfo("jpm", true, true),
new FormatInfo("jps", true, true),
new FormatInfo("map", false, true),
new FormatInfo("miff", true, true),
new FormatInfo("mng", true, true),
new FormatInfo("mono", false, true),
new FormatInfo("mtv", true, true),
new FormatInfo("o", false, true),
new FormatInfo("otb", true, true),
new FormatInfo("pal", false, true),
new FormatInfo("palm", true, true),
new FormatInfo("pam", true, true),
new FormatInfo("pcd", true, true),
new FormatInfo("pcds", true, true),
new FormatInfo("pcl", false, true),
new FormatInfo("pct", true, true),
new FormatInfo("pcx", true, true),
new FormatInfo("pdb", true, true),
new FormatInfo("pgx", true, true),
new FormatInfo("phm", true, true),
new FormatInfo("picon", true, true),
new FormatInfo("pict", true, true),
new FormatInfo("pjpeg", true, true),
new FormatInfo("png00", true, true),
new FormatInfo("png24", true, true),
new FormatInfo("png32", true, true),
new FormatInfo("png48", true, true),
new FormatInfo("png64", true, true),
new FormatInfo("png8", true, true),
// ps blocked
// ps1 blocked
new FormatInfo("ps2", false, true),
new FormatInfo("ps3", false, true),
new FormatInfo("psb", true, true),
new FormatInfo("ptif", true, true),
new FormatInfo("qoi", true, true),
new FormatInfo("r", false, true),
new FormatInfo("ras", true, true),
new FormatInfo("rgb", false, true),
new FormatInfo("rgba", false, true),
new FormatInfo("rgbo", false, true),
new FormatInfo("rgf", true, true),
new FormatInfo("sgi", true, true),
new FormatInfo("six", true, true),
new FormatInfo("sixel", true, true),
new FormatInfo("sparse-color", false, true),
new FormatInfo("strimg", false, true),
new FormatInfo("sun", true, true),
// svgz blocked
new FormatInfo("tga", true, true),
new FormatInfo("tiff64", true, true),
new FormatInfo("ubrl", false, true),
new FormatInfo("ubrl6", false, true),
new FormatInfo("uil", false, true),
new FormatInfo("uyvy", false, true),
new FormatInfo("vda", true, true),
new FormatInfo("vicar", true, true),
new FormatInfo("viff", true, true),
new FormatInfo("vips", true, true),
new FormatInfo("vst", true, true),
new FormatInfo("wbmp", true, true),
new FormatInfo("wpg", true, true),
new FormatInfo("xbm", true, true),
new FormatInfo("xpm", true, true),
new FormatInfo("xv", true, true),
new FormatInfo("ycbcr", false, true),
new FormatInfo("ycbcra", false, true),
new FormatInfo("yuv", false, true),
];
// ---------------------------------------------------------------------------
// Format normalization helpers
// ---------------------------------------------------------------------------
/** Normalize input extension: .jfif->.jpeg, .fit->.fits (bidirectional). */
function normalizeInputExt(ext: string): string {
const lower = ext.toLowerCase();
if (lower === ".jfif") return ".jpeg";
if (lower === ".fit") return ".fits";
return lower;
}
/** Normalize output extension: .jfif->.jpeg, .fit->.fits (bidirectional). */
function normalizeOutputExt(ext: string): string {
const lower = ext.toLowerCase();
if (lower === ".jfif") return ".jpeg";
if (lower === ".fit") return ".fits";
return lower;
}
/** Convert an extension (without dot) to a MagickFormat enum value. */
function extToMagickFormat(ext: string): MagickFormat {
return ext.toUpperCase() as unknown as MagickFormat;
}
// ---------------------------------------------------------------------------
// Serialization queue
// ---------------------------------------------------------------------------
/** Simple serialization queue — ensures one conversion at a time for the single WASM instance. */
class SerialQueue {
private queue: Array<{ run: () => Promise<void>; }> = [];
private running = false;
enqueue<T>(fn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({
run: async () => {
try {
resolve(await fn());
} catch (err) {
reject(err);
}
},
});
this.drain();
});
}
private async drain(): Promise<void> {
if (this.running) return;
this.running = true;
while (this.queue.length > 0) {
const item = this.queue.shift()!;
await item.run();
}
this.running = false;
}
}
// ---------------------------------------------------------------------------
// ZIP helper using yazl
// ---------------------------------------------------------------------------
/** Create a ZIP buffer from an array of named buffers. */
function createZip(entries: Array<{ name: string; data: Buffer }>): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const zipFile = new yazl.ZipFile();
for (const entry of entries) {
zipFile.addBuffer(entry.data, entry.name);
}
zipFile.end();
const chunks: Buffer[] = [];
zipFile.outputStream.on("data", (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
zipFile.outputStream.on("end", () => {
resolve(Buffer.concat(chunks));
});
zipFile.outputStream.on("error", (err: Error) => {
reject(err);
});
});
}
// ---------------------------------------------------------------------------
// Timeout helper
// ---------------------------------------------------------------------------
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Conversion timed out after ${ms / 1000} seconds: ${label}`));
}, ms);
promise.then(
(val) => { clearTimeout(timer); resolve(val); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
// ---------------------------------------------------------------------------
// MagickNodeConverter
// ---------------------------------------------------------------------------
/**
* ImageMagick WASM converter for Node.js.
* Uses @imagemagick/magick-wasm with lazy initialization, serialized queue,
* and callback-based auto-disposal.
*/
export class MagickNodeConverter implements NodeConverter {
readonly name = "imagemagick";
readonly supportedFormats: FormatInfo[] = [...MANUAL_FORMATS, ...AUTOMATED_FORMATS];
private initialized = false;
private initPromise: Promise<void> | null = null;
private readonly queue = new SerialQueue();
/** Lazy-initialize the WASM module on first call. */
private async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (this.initPromise) {
await this.initPromise;
return;
}
this.initPromise = (async () => {
const require = createRequire(import.meta.url);
const wasmPath = require.resolve("@imagemagick/magick-wasm/magick.wasm");
const wasmBytes = readFileSync(wasmPath);
await initializeImageMagick(wasmBytes);
this.initialized = true;
})();
await this.initPromise;
}
/** ImageMagick WASM is always available (bundled). */
async isAvailable(): Promise<boolean> {
return true;
}
/** Convert an image file. */
async convert(options: ConvertOptions): Promise<ConvertResult> {
await this.ensureInitialized();
return withTimeout(
this.queue.enqueue(() => this.doConvert(options)),
CONVERSION_TIMEOUT_MS,
`${basename(options.inputPath)} -> ${options.outputFormat}`,
);
}
/** Internal conversion dispatched inside the serialized queue. */
private async doConvert(options: ConvertOptions): Promise<ConvertResult> {
const { inputPath, outputPath, quality, keepMetadata = true } = options;
const inputBytes = new Uint8Array(readFileSync(inputPath));
const rawInputExt = extname(inputPath).toLowerCase();
const from = normalizeInputExt(rawInputExt);
let outputExt = options.outputFormat.startsWith(".")
? options.outputFormat.toLowerCase()
: `.${options.outputFormat.toLowerCase()}`;
outputExt = normalizeOutputExt(outputExt);
// -----------------------------------------------------------------
// ICO input: extract individual frames → ZIP
// -----------------------------------------------------------------
if (from === ".ico") {
return this.convertIco(inputBytes, outputExt, outputPath, keepMetadata, quality);
}
// -----------------------------------------------------------------
// ANI input: parse frames → convert each → ZIP
// -----------------------------------------------------------------
if (from === ".ani") {
return this.convertAni(inputBytes, outputExt, outputPath, keepMetadata, quality);
}
// -----------------------------------------------------------------
// Animated GIF/WebP input → GIF/WebP output: use readCollection
// -----------------------------------------------------------------
if (
(from === ".gif" || from === ".webp") &&
(outputExt === ".gif" || outputExt === ".webp")
) {
return this.convertAnimated(inputBytes, from, outputExt, outputPath, keepMetadata, quality);
}
// -----------------------------------------------------------------
// Standard single-image conversion
// -----------------------------------------------------------------
const outputFmt = extToMagickFormat(outputExt.slice(1));
const inputFmt = extToMagickFormat(from.slice(1));
const result = ImageMagick.read(inputBytes, new MagickReadSettings({ format: inputFmt }), (img) => {
// ICO output: clamp to 256x256 max
if (outputExt === ".ico") {
this.clampForIco(img);
}
if (quality !== undefined) {
img.quality = quality;
}
if (!keepMetadata) {
img.strip();
}
let outputBuffer: Buffer = Buffer.alloc(0);
img.write(outputFmt, (data) => {
outputBuffer = Buffer.from(data);
});
return outputBuffer;
});
writeFileSync(outputPath, result);
const stat = statSync(outputPath);
return {
outputPath,
format: outputExt.slice(1),
sizeBytes: stat.size,
};
}
/** Convert ICO: read individual frames, convert each, ZIP output. */
private async convertIco(
inputBytes: Uint8Array,
outputExt: string,
outputPath: string,
keepMetadata: boolean,
quality?: number,
): Promise<ConvertResult> {
const outputFmt = extToMagickFormat(outputExt.slice(1));
const entries: Array<{ name: string; data: Buffer }> = [];
for (let i = 0; i < MAX_FRAMES; i++) {
try {
const frameBuffer = ImageMagick.read(
inputBytes,
new MagickReadSettings({
format: MagickFormat.Ico,
frameIndex: i,
}),
(img) => {
if (outputExt === ".ico") {
this.clampForIco(img);
}
if (quality !== undefined) {
img.quality = quality;
}
if (!keepMetadata) {
img.strip();
}
let buf: Buffer = Buffer.alloc(0);
img.write(outputFmt, (data) => {
buf = Buffer.from(data);
});
return buf;
},
);
entries.push({
name: `image${i}${outputExt}`,
data: frameBuffer,
});
} catch {
// No more frames
break;
}
}
if (entries.length === 0) {
throw new Error("Failed to read ICO — no images found");
}
const zipPath = outputPath.replace(/\.[^.]+$/, ".zip");
const zipBuffer = await createZip(entries);
writeFileSync(zipPath, zipBuffer);
const stat = statSync(zipPath);
return {
outputPath: zipPath,
format: "zip",
sizeBytes: stat.size,
};
}
/** Convert ANI: parse with parseAni(), convert each frame, ZIP output. */
private async convertAni(
inputBytes: Uint8Array,
outputExt: string,
outputPath: string,
keepMetadata: boolean,
quality?: number,
): Promise<ConvertResult> {
let parsedAni;
try {
parsedAni = parseAni(inputBytes);
} catch (err) {
throw new Error(`Failed to parse ANI file: ${(err as Error).message}`);
}
const outputFmt = extToMagickFormat(outputExt.slice(1));
const frames = parsedAni.images.slice(0, MAX_FRAMES);
if (frames.length === 0) {
throw new Error("Failed to parse ANI — no frames found");
}
const entries: Array<{ name: string; data: Buffer }> = [];
for (let i = 0; i < frames.length; i++) {
const frameData = frames[i];
const frameBuffer = ImageMagick.read(
frameData,
new MagickReadSettings({ format: MagickFormat.Ico }),
(img) => {
if (outputExt === ".ico") {
this.clampForIco(img);
}
if (quality !== undefined) {
img.quality = quality;
}
if (!keepMetadata) {
img.strip();
}
let buf: Buffer = Buffer.alloc(0);
img.write(outputFmt, (data) => {
buf = Buffer.from(data);
});
return buf;
},
);
entries.push({
name: `image${i}${outputExt}`,
data: frameBuffer,
});
}
const zipPath = outputPath.replace(/\.[^.]+$/, ".zip");
const zipBuffer = await createZip(entries);
writeFileSync(zipPath, zipBuffer);
const stat = statSync(zipPath);
return {
outputPath: zipPath,
format: "zip",
sizeBytes: stat.size,
};
}
/** Convert animated GIF/WebP → GIF/WebP using readCollection. */
private async convertAnimated(
inputBytes: Uint8Array,
_from: string,
outputExt: string,
outputPath: string,
keepMetadata: boolean,
quality?: number,
): Promise<ConvertResult> {
const outputFmt = outputExt === ".gif" ? MagickFormat.Gif : MagickFormat.WebP;
const result = ImageMagick.readCollection(inputBytes, (imgs) => {
for (const img of imgs) {
if (quality !== undefined) {
img.quality = quality;
}
if (!keepMetadata) {
img.strip();
}
}
let outputBuffer: Buffer = Buffer.alloc(0);
imgs.write(outputFmt, (data) => {
outputBuffer = Buffer.from(data);
});
return outputBuffer;
});
writeFileSync(outputPath, result);
const stat = statSync(outputPath);
return {
outputPath,
format: outputExt.slice(1),
sizeBytes: stat.size,
};
}
/** Clamp image dimensions to 256x256 for ICO output. */
private clampForIco(img: { width: number; height: number; resize: (w: number, h: number) => void }): void {
const max = 256;
const w = img.width;
const h = img.height;
if (w > max || h > max) {
const scale = max / Math.max(w, h);
const newW = Math.max(1, Math.round(w * scale));
const newH = Math.max(1, Math.round(h * scale));
img.resize(newW, newH);
}
}
}

View File

@ -0,0 +1,194 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { stat, rm, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import which from "which";
import { FormatInfo, type ConvertOptions, type ConvertResult, type NodeConverter } from "./types.js";
const execFileAsync = promisify(execFile);
/** Conversion timeout in milliseconds. */
const TIMEOUT_MS = 120_000;
/** Max concurrent pandoc processes. */
const MAX_CONCURRENT = 3;
/**
* Map file extension (without dot) to pandoc reader name.
*/
function formatToReader(ext: string): string {
switch (ext) {
case "md":
case "markdown":
return "markdown";
case "docx":
return "docx";
case "csv":
return "csv";
case "tsv":
return "tsv";
case "docbook":
return "docbook";
case "epub":
return "epub";
case "html":
return "html";
case "json":
return "json";
case "odt":
return "odt";
case "rtf":
return "rtf";
case "rst":
return "rst";
default:
throw new Error(`Unsupported pandoc input format: ${ext}`);
}
}
/**
* Map file extension (without dot) to pandoc writer name.
* Separate from reader because some writer names differ.
*/
function formatToWriter(ext: string): string {
switch (ext) {
case "md":
case "markdown":
return "markdown";
case "docx":
return "docx";
case "csv":
return "csv";
case "tsv":
return "tsv";
case "docbook":
return "docbook5";
case "epub":
return "epub3";
case "html":
return "html5";
case "json":
return "json";
case "odt":
return "odt";
case "rst":
return "rst";
default:
throw new Error(`Unsupported pandoc output format: ${ext}`);
}
}
/**
* Normalize format aliases to canonical extension names.
*/
function normalizeFormat(ext: string): string {
if (ext === "markdown") return "md";
return ext;
}
/**
* Pandoc-based document converter.
*
* Uses system `pandoc` via `child_process.execFile`.
* Gracefully unavailable if pandoc is not installed.
*/
export class PandocNodeConverter implements NodeConverter {
readonly name = "pandoc";
readonly supportedFormats: FormatInfo[] = [
new FormatInfo("docx", true, true),
// .doc is NOT supported — pandoc docx reader can't handle binary .doc
new FormatInfo("md", true, true),
new FormatInfo("html", true, true),
new FormatInfo("rtf", true, false), // read only — RTF output is blocked
new FormatInfo("csv", true, true),
new FormatInfo("tsv", true, true),
new FormatInfo("json", true, true), // pandoc-converted JSON only
new FormatInfo("rst", true, true),
new FormatInfo("epub", true, true),
new FormatInfo("odt", true, true),
new FormatInfo("docbook", true, true),
];
private pandocPath: string | null = null;
private available: boolean | null = null;
private activeCount = 0;
/** Detect if pandoc is installed on the system. */
async isAvailable(): Promise<boolean> {
if (this.available !== null) return this.available;
try {
this.pandocPath = await which("pandoc");
this.available = true;
} catch {
this.available = false;
console.error("[pandoc] pandoc not found on system PATH");
}
return this.available;
}
/** Convert a document file using pandoc. */
async convert(options: ConvertOptions): Promise<ConvertResult> {
if (!(await this.isAvailable()) || !this.pandocPath) {
throw new Error("pandoc is not available on this system");
}
if (this.activeCount >= MAX_CONCURRENT) {
throw new Error(
`Too many concurrent pandoc conversions (max ${MAX_CONCURRENT}). Try again later.`,
);
}
const inputExt = path.extname(options.inputPath).slice(1).toLowerCase();
const outputExt = normalizeFormat(options.outputFormat.toLowerCase());
// Block RTF output
if (outputExt === "rtf") {
throw new Error("Converting to RTF is not supported.");
}
const reader = formatToReader(normalizeFormat(inputExt));
const writer = formatToWriter(outputExt);
// Create temp dir for --extract-media (cleaned in finally)
const mediaTmpDir = await mkdtemp(path.join(tmpdir(), "pandoc-media-"));
this.activeCount++;
try {
const args: string[] = [
"--sandbox",
"-f", reader,
"-t", writer,
"--extract-media", mediaTmpDir,
"-o", options.outputPath,
"--", options.inputPath,
];
await execFileAsync(this.pandocPath, args, {
timeout: TIMEOUT_MS,
maxBuffer: 50 * 1024 * 1024, // 50 MB stdout/stderr buffer
windowsHide: true,
});
const outputStats = await stat(options.outputPath);
return {
outputPath: options.outputPath,
format: outputExt,
sizeBytes: outputStats.size,
};
} finally {
this.activeCount--;
// Clean up extracted media temp dir
try {
await rm(mediaTmpDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup — don't throw from finally
console.error(`[pandoc] Failed to clean up temp dir: ${mediaTmpDir}`);
}
}
}
}

View File

@ -0,0 +1,176 @@
import type { FormatInfo, NodeConverter } from "./types.js";
import { MIME_TYPES } from "./types.js";
import { MagickNodeConverter } from "./magick.js";
import { FFmpegNodeConverter } from "./ffmpeg.js";
import { PandocNodeConverter } from "./pandoc.js";
/** All converter instances, in priority order. */
const converters: NodeConverter[] = [
new MagickNodeConverter(),
new FFmpegNodeConverter(),
new PandocNodeConverter(),
];
/** Category definitions. */
export type Category = "image" | "audio" | "doc";
/**
* Build the format allowlist (undotted format names) from all converters.
*/
export function buildFormatAllowlist(): Set<string> {
const allowlist = new Set<string>();
for (const converter of converters) {
for (const fmt of converter.supportedFormats) {
// Store undotted names
const name = fmt.name.startsWith(".") ? fmt.name.slice(1) : fmt.name;
allowlist.add(name);
}
}
return allowlist;
}
/**
* Find the appropriate converter for a given input and output format.
* Converter priority: magick -> ffmpeg -> pandoc (array order).
*/
export function getConverter(
inputFormat: string,
outputFormat: string,
): NodeConverter | null {
const inputDotted = inputFormat.startsWith(".") ? inputFormat : `.${inputFormat}`;
const outputDotted = outputFormat.startsWith(".") ? outputFormat : `.${outputFormat}`;
for (const converter of converters) {
const supportsInput = converter.supportedFormats.some(
(f) => f.name === inputDotted && f.fromSupported,
);
const supportsOutput = converter.supportedFormats.some(
(f) => f.name === outputDotted && f.toSupported,
);
if (supportsInput && supportsOutput) {
return converter;
}
}
// Fallback: find any converter that handles the output format
// (e.g., video input with audio output — ffmpeg handles video input)
for (const converter of converters) {
const supportsInput = converter.supportedFormats.some(
(f) => f.name === inputDotted && f.fromSupported,
);
if (supportsInput) {
// Check if any other converter can handle the output
for (const outConverter of converters) {
const supportsOutput = outConverter.supportedFormats.some(
(f) => f.name === outputDotted && f.toSupported,
);
if (supportsOutput && converter === outConverter) {
return converter;
}
}
}
}
return null;
}
/**
* Get format info by extension (undotted).
*/
export function getFormatInfo(format: string): {
name: string;
fromSupported: boolean;
toSupported: boolean;
category: Category | null;
mimeType: string | null;
converter: string | null;
} | null {
const dotted = format.startsWith(".") ? format : `.${format}`;
const undotted = format.startsWith(".") ? format.slice(1) : format;
for (const converter of converters) {
const fmt = converter.supportedFormats.find((f) => f.name === dotted);
if (fmt) {
return {
name: undotted,
fromSupported: fmt.fromSupported,
toSupported: fmt.toSupported,
category: getCategory(converter.name),
mimeType: MIME_TYPES[undotted] ?? null,
converter: converter.name,
};
}
}
return null;
}
/**
* List formats, optionally filtered by category.
*/
export function listFormats(category?: Category): {
name: string;
fromSupported: boolean;
toSupported: boolean;
category: Category;
mimeType: string | null;
}[] {
const results: {
name: string;
fromSupported: boolean;
toSupported: boolean;
category: Category;
mimeType: string | null;
}[] = [];
const seen = new Set<string>();
for (const converter of converters) {
const cat = getCategory(converter.name);
if (category && cat !== category) continue;
for (const fmt of converter.supportedFormats) {
// Skip video formats (input-only, not shown in list_formats)
if (!fmt.isNative && !fmt.toSupported) continue;
const undotted = fmt.name.startsWith(".") ? fmt.name.slice(1) : fmt.name;
if (seen.has(undotted)) continue;
seen.add(undotted);
results.push({
name: undotted,
fromSupported: fmt.fromSupported,
toSupported: fmt.toSupported,
category: cat,
mimeType: MIME_TYPES[undotted] ?? null,
});
}
}
return results.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Check which converters are available on this system.
*/
export async function checkAvailability(): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
for (const converter of converters) {
results[converter.name] = await converter.isAvailable();
}
return results;
}
/** Map converter name to category. */
function getCategory(converterName: string): Category {
switch (converterName) {
case "imagemagick":
return "image";
case "ffmpeg":
return "audio";
case "pandoc":
return "doc";
default:
return "image";
}
}

139
mcp/src/converters/types.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* Format metadata for a single file format.
* Adapted from VERT's FormatInfo class (converter.svelte.ts).
*/
export class FormatInfo {
public name: string;
constructor(
name: string,
public fromSupported = true,
public toSupported = true,
public isNative = true,
) {
this.name = name;
if (!this.name.startsWith(".")) {
this.name = `.${this.name}`;
}
if (!this.fromSupported && !this.toSupported) {
throw new Error("Format must support at least one direction");
}
}
}
/** Options passed to a converter's convert method. */
export interface ConvertOptions {
inputPath: string;
outputPath: string;
outputFormat: string;
quality?: number;
keepMetadata?: boolean;
audioBitrate?: string;
sampleRate?: number;
}
/** Result returned from a successful conversion. */
export interface ConvertResult {
outputPath: string;
format: string;
sizeBytes: number;
}
/** Base interface for all converters. */
export interface NodeConverter {
/** Converter name (e.g. "imagemagick", "ffmpeg", "pandoc"). */
readonly name: string;
/** Supported formats. */
readonly supportedFormats: FormatInfo[];
/** Whether this converter is available on the system. */
isAvailable(): Promise<boolean>;
/** Convert a file. */
convert(options: ConvertOptions): Promise<ConvertResult>;
}
/** Common MIME types by extension (undotted). */
export const MIME_TYPES: Record<string, string> = {
// Common image formats
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
jpe: "image/jpeg",
jfif: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
svgz: "image/svg+xml",
bmp: "image/bmp",
ico: "image/x-icon",
cur: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
avif: "image/avif",
heic: "image/heic",
heif: "image/heif",
jxl: "image/jxl",
psd: "image/vnd.adobe.photoshop",
hdr: "image/vnd.radiance",
exr: "image/x-exr",
dds: "image/vnd.ms-dds",
pbm: "image/x-portable-bitmap",
pgm: "image/x-portable-graymap",
ppm: "image/x-portable-pixmap",
pnm: "image/x-portable-anymap",
pam: "image/x-portable-arbitrarymap",
pcx: "image/x-pcx",
tga: "image/x-tga",
qoi: "image/x-qoi",
ani: "application/x-navi-animation",
icns: "image/x-icns",
// Audio formats
mp3: "audio/mpeg",
wav: "audio/wav",
flac: "audio/flac",
ogg: "audio/ogg",
oga: "audio/ogg",
opus: "audio/opus",
aac: "audio/aac",
m4a: "audio/mp4",
m4b: "audio/mp4",
wma: "audio/x-ms-wma",
amr: "audio/amr",
ac3: "audio/ac3",
aiff: "audio/aiff",
aif: "audio/aiff",
aifc: "audio/aiff",
au: "audio/basic",
mp2: "audio/mpeg",
caf: "audio/x-caf",
voc: "audio/x-voc",
weba: "audio/webm",
// Document formats
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
md: "text/markdown",
html: "text/html",
rtf: "application/rtf",
csv: "text/csv",
tsv: "text/tab-separated-values",
json: "application/json",
rst: "text/x-rst",
epub: "application/epub+zip",
odt: "application/vnd.oasis.opendocument.text",
docbook: "application/docbook+xml",
// Video formats (input only for audio extraction)
mp4: "video/mp4",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
mov: "video/quicktime",
webm: "video/webm",
wmv: "video/x-ms-wmv",
flv: "video/x-flv",
mpg: "video/mpeg",
mpeg: "video/mpeg",
m4v: "video/mp4",
"3gp": "video/3gpp",
ogv: "video/ogg",
};

223
mcp/src/index.ts Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import path from "node:path";
import {
validateInputPath,
validateOutputPath,
validateFormat,
validateAudioBitrate,
validateSampleRate,
} from "./security.js";
import {
buildFormatAllowlist,
getConverter,
getFormatInfo,
listFormats,
checkAvailability,
} from "./converters/registry.js";
// Build format allowlist once at startup
const FORMAT_ALLOWLIST = buildFormatAllowlist();
const server = new McpServer({
name: "vert",
version: "0.1.0",
});
// ── convert_file ──────────────────────────────────────────────────────────
server.tool(
"convert_file",
"Convert a file between formats. Supports 140+ image formats (via ImageMagick WASM), 28+ audio formats (via system ffmpeg), and 11 document formats (via system pandoc). Video input is supported for audio extraction (e.g., MP4 to MP3).",
{
input_path: z.string().describe("Absolute path to the input file"),
output_format: z.string().describe("Target format extension (e.g. \"webp\", \"mp3\", \"docx\")"),
output_path: z.string().optional().describe("Output file path. Defaults to input directory with new extension"),
quality: z.number().min(1).max(100).optional().describe("Compression quality for lossy image formats (1-100)"),
keep_metadata: z.boolean().optional().describe("Preserve file metadata. Defaults to true"),
audio_bitrate: z.string().optional().describe("Audio bitrate (e.g. \"128k\", \"320k\")"),
sample_rate: z.number().optional().describe("Audio sample rate in Hz (e.g. 44100, 48000)"),
},
async (params) => {
try {
// Validate input path
const resolvedInput = await validateInputPath(params.input_path);
// Validate output format
const outputFormat = validateFormat(params.output_format, FORMAT_ALLOWLIST);
// Validate input format
const inputExt = path.extname(resolvedInput).slice(1).toLowerCase();
if (!inputExt) {
return {
content: [{ type: "text", text: "Input file has no extension. Cannot determine format." }],
isError: true,
};
}
// Input format validation: check it's a known format (but don't block unsupported WASM
// formats for input — they may be handled by ffmpeg/pandoc)
if (!FORMAT_ALLOWLIST.has(inputExt)) {
return {
content: [{ type: "text", text: `Unknown input format: ${inputExt}` }],
isError: true,
};
}
// Determine output path
let outputPath: string;
if (params.output_path) {
outputPath = params.output_path;
} else {
const dir = path.dirname(resolvedInput);
const base = path.basename(resolvedInput, path.extname(resolvedInput));
outputPath = path.join(dir, `${base}.${outputFormat}`);
}
// Validate output path
const resolvedOutput = await validateOutputPath(outputPath, resolvedInput);
// Validate optional params
if (params.audio_bitrate !== undefined) {
validateAudioBitrate(params.audio_bitrate);
}
if (params.sample_rate !== undefined) {
validateSampleRate(params.sample_rate);
}
// Find a converter
const converter = getConverter(inputExt, outputFormat);
if (!converter) {
return {
content: [{ type: "text", text: `No converter available for ${inputExt}${outputFormat}` }],
isError: true,
};
}
// Check if the converter is available on this system
const available = await converter.isAvailable();
if (!available) {
return {
content: [{
type: "text",
text: `Converter "${converter.name}" is not available on this system. ${converter.name === "ffmpeg" ? "Install ffmpeg to convert audio files." : converter.name === "pandoc" ? "Install pandoc to convert documents." : ""}`,
}],
isError: true,
};
}
// Run conversion
const result = await converter.convert({
inputPath: resolvedInput,
outputPath: resolvedOutput,
outputFormat,
quality: params.quality,
keepMetadata: params.keep_metadata,
audioBitrate: params.audio_bitrate,
sampleRate: params.sample_rate,
});
return {
content: [{
type: "text",
text: JSON.stringify({
output_path: result.outputPath,
format: result.format,
size_bytes: result.sizeBytes,
}, null, 2),
}],
};
} catch (err) {
return {
content: [{ type: "text", text: `Conversion failed: ${(err as Error).message}` }],
isError: true,
};
}
},
);
// ── list_formats ──────────────────────────────────────────────────────────
server.tool(
"list_formats",
"List supported file formats, optionally filtered by category. Returns format name, supported directions, category, and MIME type.",
{
category: z.enum(["image", "audio", "doc"]).optional().describe(
"Filter by category: \"image\", \"audio\", or \"doc\""
),
},
async (params) => {
try {
const formats = listFormats(params.category);
const availability = await checkAvailability();
return {
content: [{
type: "text",
text: JSON.stringify({
converters: availability,
formats,
total: formats.length,
}, null, 2),
}],
};
} catch (err) {
return {
content: [{ type: "text", text: `Failed to list formats: ${(err as Error).message}` }],
isError: true,
};
}
},
);
// ── get_format_info ───────────────────────────────────────────────────────
server.tool(
"get_format_info",
"Get detailed information about a specific file format, including supported conversion directions, category, MIME type, and which converter handles it.",
{
format: z.string().describe("File extension without dot (e.g. \"png\", \"mp3\", \"docx\")"),
},
async (params) => {
try {
const normalized = params.format.startsWith(".")
? params.format.slice(1).toLowerCase()
: params.format.toLowerCase();
const info = getFormatInfo(normalized);
if (!info) {
return {
content: [{ type: "text", text: `Unknown format: ${normalized}` }],
isError: true,
};
}
return {
content: [{
type: "text",
text: JSON.stringify(info, null, 2),
}],
};
} catch (err) {
return {
content: [{ type: "text", text: `Failed to get format info: ${(err as Error).message}` }],
isError: true,
};
}
},
);
// ── Start server ──────────────────────────────────────────────────────────
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[vert-mcp] Server started on stdio");
}
main().catch((err) => {
console.error("[vert-mcp] Fatal error:", err);
process.exit(1);
});

170
mcp/src/security.ts Normal file
View File

@ -0,0 +1,170 @@
import { stat, realpath, access, constants } from "node:fs/promises";
import path from "node:path";
/** Dangerous ImageMagick format names that could execute code or fetch URLs. */
const DANGEROUS_FORMATS = new Set([
"mvg", "msl", "ephemeral", "url", "http", "https",
"ftp", "text", "label", "caption",
]);
/** Formats that lack WASM delegates and will fail silently or produce garbage. */
const UNSUPPORTED_WASM_FORMATS = new Set([
"pdf", "pdfa", "eps", "ps", "ps1", "ps2", "ps3",
// SVG INPUT is unsupported (needs librsvg), but OUTPUT works via IM's internal writer.
// Blocked at the converter level (fromSupported=false) instead of here.
// RAW camera formats (need libraw)
"cr2", "nef", "arw", "dng", "cr3", "orf", "rw2", "pef",
"srf", "sr2", "raf", "mrw", "erf", "kdc", "dcr", "rwl",
"iiq", "3fr", "crw", "mef", "nrw", "srw", "mos", "raw",
// Old binary .doc (pandoc docx reader can't handle it)
"doc",
]);
/**
* Validate and resolve an input file path.
* Returns the resolved absolute path.
*/
export async function validateInputPath(inputPath: string): Promise<string> {
assertNoPathInjection(inputPath);
const resolved = path.resolve(inputPath);
const real = await realpath(resolved);
const stats = await stat(real);
if (!stats.isFile()) {
throw new Error(`Input path is not a regular file: ${inputPath}`);
}
// 100 MB limit
const MAX_SIZE = 100 * 1024 * 1024;
if (stats.size > MAX_SIZE) {
throw new Error(
`Input file exceeds 100 MB limit: ${(stats.size / 1024 / 1024).toFixed(1)} MB`,
);
}
return real;
}
/**
* Validate and resolve an output file path.
* Returns the resolved absolute path, ensuring it won't overwrite existing files.
*/
export async function validateOutputPath(
outputPath: string,
inputPath: string,
): Promise<string> {
assertNoPathInjection(outputPath);
const resolved = path.resolve(outputPath);
// Verify output !== input after resolution
if (resolved === inputPath) {
throw new Error("Output path cannot be the same as input path");
}
// Verify parent directory exists and is writable
const parentDir = path.dirname(resolved);
try {
await access(parentDir, constants.W_OK);
} catch {
throw new Error(`Output directory is not writable: ${parentDir}`);
}
// Don't overwrite existing files — auto-suffix
return await findAvailablePath(resolved);
}
/**
* Validate a format string against the allowlist.
* Returns the normalized undotted format string.
*/
export function validateFormat(format: string, allowlist: Set<string>): string {
// Strip leading dot if present
const normalized = format.startsWith(".") ? format.slice(1) : format;
const lower = normalized.toLowerCase();
if (DANGEROUS_FORMATS.has(lower)) {
throw new Error(`Blocked dangerous format: ${lower}`);
}
if (UNSUPPORTED_WASM_FORMATS.has(lower)) {
throw new Error(
`Unsupported format: ${lower} (requires system libraries not available in WASM)`,
);
}
if (!allowlist.has(lower)) {
throw new Error(`Unknown format: ${lower}`);
}
return lower;
}
/**
* Validate audio bitrate parameter.
*/
export function validateAudioBitrate(bitrate: string): string {
if (!/^\d+k$/.test(bitrate)) {
throw new Error(
`Invalid audio bitrate: ${bitrate}. Expected format like "128k" or "320k".`,
);
}
return bitrate;
}
/**
* Validate sample rate parameter.
*/
export function validateSampleRate(rate: number): number {
if (!Number.isInteger(rate) || rate < 8000 || rate > 192000) {
throw new Error(
`Invalid sample rate: ${rate}. Must be an integer between 8000 and 192000.`,
);
}
return rate;
}
/** Check for path injection attempts. */
function assertNoPathInjection(filePath: string): void {
// Block null bytes
if (filePath.includes("\0")) {
throw new Error("Path contains null bytes");
}
// Block Windows UNC paths
if (filePath.startsWith("\\\\")) {
throw new Error("UNC paths are not allowed");
}
// Block NTFS alternate data streams (colons after drive prefix)
// Allow the drive letter colon (e.g., C:\) but block file.txt:stream
const withoutDrive = filePath.replace(/^[A-Za-z]:/, "");
if (withoutDrive.includes(":")) {
throw new Error("NTFS alternate data streams are not allowed");
}
}
/** Find an available path by appending _1, _2, etc. if the file exists. */
async function findAvailablePath(filePath: string): Promise<string> {
try {
await stat(filePath);
} catch {
// File doesn't exist, path is available
return filePath;
}
const ext = path.extname(filePath);
const base = filePath.slice(0, -ext.length || undefined);
for (let i = 1; i <= 1000; i++) {
const candidate = `${base}_${i}${ext}`;
try {
await stat(candidate);
} catch {
return candidate;
}
}
throw new Error("Could not find available output path after 1000 attempts");
}

151
mcp/src/util/parse-ani.ts Normal file
View File

@ -0,0 +1,151 @@
// THIS CODE IS FROM https://github.com/captbaritone/webamp/blob/15b0312cb794973a0e615d894df942452e920c36/packages/ani-cursor/src/parser.ts
// LICENSED UNDER MIT. (c) Jordan Eldredge and Webamp contributors
// this code is ripped from their project because i didn't want to
// re-invent the wheel, BUT the library they provide (ani-cursor)
// doesn't expose the internals.
import riffFile from "riff-file";
const { RIFFFile } = riffFile;
import { unpackArray, unpackString } from "byte-data";
type Chunk = {
format: string;
chunkId: string;
chunkData: {
start: number;
end: number;
};
subChunks: Chunk[];
};
// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
type AniMetadata = {
cbSize: number; // Data structure size (in bytes)
nFrames: number; // Number of images (also known as frames) stored in the file
nSteps: number; // Number of frames to be displayed before the animation repeats
iWidth: number; // Width of frame (in pixels)
iHeight: number; // Height of frame (in pixels)
iBitCount: number; // Number of bits per pixel
nPlanes: number; // Number of color planes
iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units)
bfAttributes: number; // ANI attribute bit flags
};
type ParsedAni = {
rate: number[] | null;
seq: number[] | null;
images: Uint8Array[];
metadata: AniMetadata;
artist: string | null;
title: string | null;
};
const DWORD = { bits: 32, be: false, signed: false, fp: false };
export function parseAni(arr: Uint8Array): ParsedAni {
const riff = new RIFFFile();
riff.setSignature(arr);
const signature = riff.signature as Chunk;
if (signature.format !== "ACON") {
throw new Error(
`Expected format. Expected "ACON", got "${signature.format}"`,
);
}
// Helper function to get a chunk by chunkId and transform it if it's non-null.
function mapChunk<T>(
chunkId: string,
mapper: (chunk: Chunk) => T,
): T | null {
const chunk = riff.findChunk(chunkId) as Chunk | null;
return chunk == null ? null : mapper(chunk);
}
function readImages(chunk: Chunk, frameCount: number): Uint8Array[] {
return chunk.subChunks.slice(0, frameCount).map((c) => {
if (c.chunkId !== "icon") {
throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
}
return arr.slice(c.chunkData.start, c.chunkData.end);
});
}
const metadata = mapChunk("anih", (c) => {
const words = unpackArray(
arr,
DWORD,
c.chunkData.start,
c.chunkData.end,
);
return {
cbSize: words[0],
nFrames: words[1],
nSteps: words[2],
iWidth: words[3],
iHeight: words[4],
iBitCount: words[5],
nPlanes: words[6],
iDispRate: words[7],
bfAttributes: words[8],
};
});
if (metadata == null) {
throw new Error("Did not find anih");
}
const rate = mapChunk("rate", (c) => {
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
});
// chunkIds are always four chars, hence the trailing space.
const seq = mapChunk("seq ", (c) => {
return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
});
const lists = riff.findChunk("LIST", true) as Chunk[] | null;
const imageChunk = lists?.find((c) => c.format === "fram");
if (imageChunk == null) {
throw new Error("Did not find fram LIST");
}
let images = readImages(imageChunk, metadata.nFrames);
let title = null;
let artist = null;
const infoChunk = lists?.find((c) => c.format === "INFO");
if (infoChunk != null) {
infoChunk.subChunks.forEach((c) => {
switch (c.chunkId) {
case "INAM":
title = unpackString(
arr,
c.chunkData.start,
c.chunkData.end,
);
break;
case "IART":
artist = unpackString(
arr,
c.chunkData.start,
c.chunkData.end,
);
break;
case "LIST":
// Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
if (c.format === "fram") {
images = readImages(c, metadata.nFrames);
}
break;
default:
// Unexpected subchunk
}
});
}
return { images, rate, seq, metadata, artist, title };
}

15
mcp/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}