File Merger MCP Server
by exoticknight
Verified
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises";
import { createReadStream, createWriteStream } from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// Command line argument parsing - directory permissions
const args = process.argv.slice(2);
let allowedDirectories: string[] = [];
if (args.length > 0) {
allowedDirectories = args.map(dir => path.resolve(dir));
// Validate directories if specified
for (const dir of allowedDirectories) {
try {
const stats = await fs.stat(dir);
if (!stats.isDirectory()) {
console.error(`Error: ${dir} is not a directory`);
process.exit(1);
}
} catch (error) {
console.error(`Error accessing directory ${dir}:`, error);
process.exit(1);
}
}
} else {
// Default to current directory if no arguments provided
allowedDirectories = [process.cwd()];
}
// Schema definitions
const MergeFilesArgsSchema = z.object({
inputPaths: z.array(z.string()).min(1).describe("Array of file paths to merge"),
outputPath: z.string().describe("Path for the merged output file")
});
const ToolInputSchema = ToolSchema.shape.inputSchema;
type ToolInput = z.infer<typeof ToolInputSchema>;
// Security utilities
async function validatePath(requestedPath: string): Promise<string> {
const absolute = path.isAbsolute(requestedPath)
? path.resolve(requestedPath)
: path.resolve(process.cwd(), requestedPath);
const normalized = path.normalize(absolute);
// If allowed directories is empty, allow all
if (allowedDirectories.length === 0) {
return normalized;
}
// Check if path is within allowed directories
const isAllowed = allowedDirectories.some(dir => normalized.startsWith(dir));
if (!isAllowed) {
throw new Error(`Access denied - path outside allowed directories: ${normalized}, only allowed directories are ${allowedDirectories.join(', ')}, maybe case sensitivity issue?`);
}
return normalized;
}
// Helper functions
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Server setup
const server = new Server(
{
name: "file-merger-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
},
);
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "merge_files",
description:
"Merge multiple files into a single output file. Reads content from each input file " +
"in the order provided and writes it sequentially to the output file. Returns information " +
"about the merge operation including file sizes and total size. All specified paths must be " +
"within allowed directories if specified.",
inputSchema: zodToJsonSchema(MergeFilesArgsSchema) as ToolInput,
},
{
name: "list_allowed_directories",
description:
"Returns the list of directories that this server is allowed to access. " +
"Use this to understand which directories are available before trying to merge files.",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case "merge_files": {
const parsed = MergeFilesArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for merge_files: ${parsed.error}`);
}
// Validate all paths
const validInputPaths = await Promise.all(
parsed.data.inputPaths.map(async (inputPath) => {
try {
return await validatePath(inputPath);
} catch (error) {
throw new Error(`Invalid input path: ${inputPath} - ${(error as Error).message}`);
}
})
);
const validOutputPath = await validatePath(parsed.data.outputPath);
// Check if all input files exist and gather their stats
const fileStats = await Promise.all(
validInputPaths.map(async (filePath) => {
try {
const stats = await fs.stat(filePath);
return {
path: filePath,
size: stats.size
};
} catch (error) {
throw new Error(`Error accessing file: ${filePath} - ${(error as Error).message}`);
}
})
);
// Create output directory if necessary
const outputDir = path.dirname(validOutputPath);
await fs.mkdir(outputDir, { recursive: true });
// Create output stream
const outputStream = createWriteStream(validOutputPath);
// Process files sequentially using streams
let totalSize = 0;
for (const file of fileStats) {
// Create read stream for current file
const readStream = createReadStream(file.path);
// Pipe to output stream
await pipeline(readStream, outputStream, { end: false });
totalSize += file.size;
}
// Close the output stream
outputStream.end();
// Generate and return summary
const fileList = fileStats.map(f =>
`- ${path.basename(f.path)} (${formatBytes(f.size)})`
).join('\n');
return {
content: [{
type: "text",
text: `Successfully merged ${fileStats.length} files into ${validOutputPath}
Total size: ${formatBytes(totalSize)}
Files merged:
${fileList}`
}]
};
}
case "list_allowed_directories": {
return {
content: [{
type: "text",
text: allowedDirectories.length > 0
? `Allowed directories:\n${allowedDirectories.join('\n')}`
: "No directory restrictions - all directories are allowed"
}],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${errorMessage}` }],
isError: true,
};
}
});
// Start server
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP File Merger Server running on stdio");
console.error(allowedDirectories.length > 0
? `Allowed directories: ${allowedDirectories.join(', ')}`
: "No directory restrictions - all directories are allowed");
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});