838 lines
29 KiB
JavaScript
838 lines
29 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||
import fs from "fs/promises";
|
||
import { createReadStream } from "fs";
|
||
import http from "http";
|
||
import path from "path";
|
||
import { spawn } from "child_process";
|
||
import { URL } from "url";
|
||
import { z } from "zod";
|
||
import { minimatch } from "minimatch";
|
||
import {
|
||
// Function imports
|
||
formatSize,
|
||
validatePath,
|
||
getFileStats,
|
||
readFileContent,
|
||
writeFileContent,
|
||
searchFilesWithValidation,
|
||
applyFileEdits,
|
||
tailFile,
|
||
headFile,
|
||
setAllowedDirectories,
|
||
} from './lib.js';
|
||
|
||
// Always use full filesystem (typical for container deployment)
|
||
const allowedDirectories: string[] = ["/"];
|
||
setAllowedDirectories(allowedDirectories);
|
||
|
||
// Schema definitions
|
||
const ReadTextFileArgsSchema = z.object({
|
||
path: z.string(),
|
||
tail: z.number().optional().describe('If provided, returns only the last N lines of the file'),
|
||
head: z.number().optional().describe('If provided, returns only the first N lines of the file')
|
||
});
|
||
|
||
const ReadMediaFileArgsSchema = z.object({
|
||
path: z.string()
|
||
});
|
||
|
||
const ReadMultipleFilesArgsSchema = z.object({
|
||
paths: z
|
||
.array(z.string())
|
||
.min(1, "At least one file path must be provided")
|
||
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories."),
|
||
});
|
||
|
||
const WriteFileArgsSchema = z.object({
|
||
path: z.string(),
|
||
content: z.string(),
|
||
});
|
||
|
||
const EditOperation = z.object({
|
||
oldText: z.string().describe('Text to search for - must match exactly'),
|
||
newText: z.string().describe('Text to replace with')
|
||
});
|
||
|
||
const EditFileArgsSchema = z.object({
|
||
path: z.string(),
|
||
edits: z.array(EditOperation),
|
||
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
|
||
});
|
||
|
||
const CreateDirectoryArgsSchema = z.object({
|
||
path: z.string(),
|
||
});
|
||
|
||
const ListDirectoryArgsSchema = z.object({
|
||
path: z.string(),
|
||
});
|
||
|
||
const ListDirectoryWithSizesArgsSchema = z.object({
|
||
path: z.string(),
|
||
sortBy: z.enum(['name', 'size']).optional().default('name').describe('Sort entries by name or size'),
|
||
});
|
||
|
||
const DirectoryTreeArgsSchema = z.object({
|
||
path: z.string(),
|
||
excludePatterns: z.array(z.string()).optional().default([])
|
||
});
|
||
|
||
const MoveFileArgsSchema = z.object({
|
||
source: z.string(),
|
||
destination: z.string(),
|
||
});
|
||
|
||
const SearchFilesArgsSchema = z.object({
|
||
path: z.string(),
|
||
pattern: z.string(),
|
||
excludePatterns: z.array(z.string()).optional().default([])
|
||
});
|
||
|
||
const GetFileInfoArgsSchema = z.object({
|
||
path: z.string(),
|
||
});
|
||
|
||
const RunCommandArgsSchema = z.object({
|
||
command: z.string().min(1),
|
||
cwd: z.string().optional().describe("Working directory to run the command in. Must be within allowed directories."),
|
||
timeoutMs: z.number().int().positive().optional().default(600_000).describe("Kill the command if it runs longer than this (ms). Default: 10 minutes."),
|
||
env: z.record(z.string(), z.string()).optional().default({}).describe("Extra environment variables (string values)."),
|
||
shell: z.enum(["bash", "sh"]).optional().default("bash").describe("Shell to use for the command. Default: bash."),
|
||
maxOutputChars: z.number().int().positive().optional().default(200_000).describe("Maximum characters captured for each of stdout/stderr."),
|
||
});
|
||
|
||
// Server setup
|
||
const server = new McpServer(
|
||
{
|
||
name: "secure-filesystem-server",
|
||
version: "0.2.0",
|
||
}
|
||
);
|
||
|
||
// Reads a file as a stream of buffers, concatenates them, and then encodes
|
||
// the result to a Base64 string. This is a memory-efficient way to handle
|
||
// binary data from a stream before the final encoding.
|
||
async function readFileAsBase64Stream(filePath: string): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const stream = createReadStream(filePath);
|
||
const chunks: Buffer[] = [];
|
||
stream.on('data', (chunk) => {
|
||
chunks.push(chunk as Buffer);
|
||
});
|
||
stream.on('end', () => {
|
||
const finalBuffer = Buffer.concat(chunks);
|
||
resolve(finalBuffer.toString('base64'));
|
||
});
|
||
stream.on('error', (err) => reject(err));
|
||
});
|
||
}
|
||
|
||
function appendWithLimit(current: string, chunk: string, maxChars: number): { next: string; truncated: boolean } {
|
||
if (maxChars <= 0) return { next: "", truncated: true };
|
||
if (current.length >= maxChars) return { next: current, truncated: true };
|
||
const remaining = maxChars - current.length;
|
||
if (chunk.length <= remaining) return { next: current + chunk, truncated: false };
|
||
return { next: current + chunk.slice(0, remaining), truncated: true };
|
||
}
|
||
|
||
async function runShellCommand(args: z.infer<typeof RunCommandArgsSchema>): Promise<{
|
||
exitCode: number | null;
|
||
signal: string | null;
|
||
stdout: string;
|
||
stderr: string;
|
||
stdoutTruncated: boolean;
|
||
stderrTruncated: boolean;
|
||
timedOut: boolean;
|
||
effectiveCwd: string;
|
||
}> {
|
||
const defaultCwd = process.env.MCP_DEFAULT_CWD ?? "/workspace";
|
||
const requestedCwd = args.cwd ?? defaultCwd;
|
||
const effectiveCwd = await validatePath(requestedCwd);
|
||
|
||
const cwdStats = await fs.stat(effectiveCwd);
|
||
if (!cwdStats.isDirectory()) {
|
||
throw new Error(`cwd is not a directory: ${requestedCwd}`);
|
||
}
|
||
|
||
const shellPath = args.shell === "sh" ? "/bin/sh" : "/bin/bash";
|
||
const env: NodeJS.ProcessEnv = { ...process.env, ...args.env };
|
||
|
||
return await new Promise((resolve) => {
|
||
const child = spawn(shellPath, ["-lc", args.command], {
|
||
cwd: effectiveCwd,
|
||
env,
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
|
||
let stdout = "";
|
||
let stderr = "";
|
||
let stdoutTruncated = false;
|
||
let stderrTruncated = false;
|
||
let timedOut = false;
|
||
|
||
const killTimer = setTimeout(() => {
|
||
timedOut = true;
|
||
try {
|
||
child.kill("SIGKILL");
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, args.timeoutMs);
|
||
|
||
child.stdout?.setEncoding("utf-8");
|
||
child.stderr?.setEncoding("utf-8");
|
||
|
||
child.stdout?.on("data", (data: string) => {
|
||
const result = appendWithLimit(stdout, data, args.maxOutputChars);
|
||
stdout = result.next;
|
||
stdoutTruncated = stdoutTruncated || result.truncated;
|
||
});
|
||
|
||
child.stderr?.on("data", (data: string) => {
|
||
const result = appendWithLimit(stderr, data, args.maxOutputChars);
|
||
stderr = result.next;
|
||
stderrTruncated = stderrTruncated || result.truncated;
|
||
});
|
||
|
||
child.on("close", (exitCode, signal) => {
|
||
clearTimeout(killTimer);
|
||
resolve({
|
||
exitCode,
|
||
signal: signal ? String(signal) : null,
|
||
stdout,
|
||
stderr,
|
||
stdoutTruncated,
|
||
stderrTruncated,
|
||
timedOut,
|
||
effectiveCwd,
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// Tool registrations
|
||
|
||
// read_file (deprecated) and read_text_file
|
||
const readTextFileHandler = async (args: z.infer<typeof ReadTextFileArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
|
||
if (args.head && args.tail) {
|
||
throw new Error("Cannot specify both head and tail parameters simultaneously");
|
||
}
|
||
|
||
let content: string;
|
||
if (args.tail) {
|
||
content = await tailFile(validPath, args.tail);
|
||
} else if (args.head) {
|
||
content = await headFile(validPath, args.head);
|
||
} else {
|
||
content = await readFileContent(validPath);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: content }],
|
||
structuredContent: { content }
|
||
};
|
||
};
|
||
|
||
server.registerTool(
|
||
"read_file",
|
||
{
|
||
title: "Read File (Deprecated)",
|
||
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
|
||
inputSchema: ReadTextFileArgsSchema.shape,
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
readTextFileHandler
|
||
);
|
||
|
||
server.registerTool(
|
||
"read_text_file",
|
||
{
|
||
title: "Read Text File",
|
||
description:
|
||
"Read the complete contents of a file from the file system as text. " +
|
||
"Handles various text encodings and provides detailed error messages " +
|
||
"if the file cannot be read. Use this tool when you need to examine " +
|
||
"the contents of a single file. Use the 'head' parameter to read only " +
|
||
"the first N lines of a file, or the 'tail' parameter to read only " +
|
||
"the last N lines of a file. Operates on the file as text regardless of extension. " +
|
||
"Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string(),
|
||
tail: z.number().optional().describe("If provided, returns only the last N lines of the file"),
|
||
head: z.number().optional().describe("If provided, returns only the first N lines of the file")
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
readTextFileHandler
|
||
);
|
||
|
||
server.registerTool(
|
||
"read_media_file",
|
||
{
|
||
title: "Read Media File",
|
||
description:
|
||
"Read an image or audio file. Returns the base64 encoded data and MIME type. " +
|
||
"Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string()
|
||
},
|
||
outputSchema: {
|
||
content: z.array(z.object({
|
||
type: z.enum(["image", "audio", "blob"]),
|
||
data: z.string(),
|
||
mimeType: z.string()
|
||
}))
|
||
},
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
async (args: z.infer<typeof ReadMediaFileArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
const extension = path.extname(validPath).toLowerCase();
|
||
const mimeTypes: Record<string, string> = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".webp": "image/webp",
|
||
".bmp": "image/bmp",
|
||
".svg": "image/svg+xml",
|
||
".mp3": "audio/mpeg",
|
||
".wav": "audio/wav",
|
||
".ogg": "audio/ogg",
|
||
".flac": "audio/flac",
|
||
};
|
||
const mimeType = mimeTypes[extension] || "application/octet-stream";
|
||
const data = await readFileAsBase64Stream(validPath);
|
||
|
||
const type = mimeType.startsWith("image/")
|
||
? "image"
|
||
: mimeType.startsWith("audio/")
|
||
? "audio"
|
||
// Fallback for other binary types, not officially supported by the spec but has been used for some time
|
||
: "blob";
|
||
const contentItem = { type: type as 'image' | 'audio' | 'blob', data, mimeType };
|
||
return {
|
||
content: [contentItem],
|
||
structuredContent: { content: [contentItem] }
|
||
} as unknown as CallToolResult;
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"read_multiple_files",
|
||
{
|
||
title: "Read Multiple Files",
|
||
description:
|
||
"Read the contents of multiple files simultaneously. This is more " +
|
||
"efficient than reading files one by one when you need to analyze " +
|
||
"or compare multiple files. Each file's content is returned with its " +
|
||
"path as a reference. Failed reads for individual files won't stop " +
|
||
"the entire operation. Only works within allowed directories.",
|
||
inputSchema: {
|
||
paths: z.array(z.string())
|
||
.min(1)
|
||
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.")
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
async (args: z.infer<typeof ReadMultipleFilesArgsSchema>) => {
|
||
const results = await Promise.all(
|
||
args.paths.map(async (filePath: string) => {
|
||
try {
|
||
const validPath = await validatePath(filePath);
|
||
const content = await readFileContent(validPath);
|
||
return `${filePath}:\n${content}\n`;
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
return `${filePath}: Error - ${errorMessage}`;
|
||
}
|
||
}),
|
||
);
|
||
const text = results.join("\n---\n");
|
||
return {
|
||
content: [{ type: "text" as const, text }],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"write_file",
|
||
{
|
||
title: "Write File",
|
||
description:
|
||
"Create a new file or completely overwrite an existing file with new content. " +
|
||
"Use with caution as it will overwrite existing files without warning. " +
|
||
"Handles text content with proper encoding. Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string(),
|
||
content: z.string()
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true }
|
||
},
|
||
async (args: z.infer<typeof WriteFileArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
await writeFileContent(validPath, args.content);
|
||
const text = `Successfully wrote to ${args.path}`;
|
||
return {
|
||
content: [{ type: "text" as const, text }],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"edit_file",
|
||
{
|
||
title: "Edit File",
|
||
description:
|
||
"Make line-based edits to a text file. Each edit replaces exact line sequences " +
|
||
"with new content. Returns a git-style diff showing the changes made. " +
|
||
"Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string(),
|
||
edits: z.array(z.object({
|
||
oldText: z.string().describe("Text to search for - must match exactly"),
|
||
newText: z.string().describe("Text to replace with")
|
||
})),
|
||
dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format")
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }
|
||
},
|
||
async (args: z.infer<typeof EditFileArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
const result = await applyFileEdits(validPath, args.edits, args.dryRun);
|
||
return {
|
||
content: [{ type: "text" as const, text: result }],
|
||
structuredContent: { content: result }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"create_directory",
|
||
{
|
||
title: "Create Directory",
|
||
description:
|
||
"Create a new directory or ensure a directory exists. Can create multiple " +
|
||
"nested directories in one operation. If the directory already exists, " +
|
||
"this operation will succeed silently. Perfect for setting up directory " +
|
||
"structures for projects or ensuring required paths exist. Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string()
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false }
|
||
},
|
||
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
await fs.mkdir(validPath, { recursive: true });
|
||
const text = `Successfully created directory ${args.path}`;
|
||
return {
|
||
content: [{ type: "text" as const, text }],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"list_directory",
|
||
{
|
||
title: "List Directory",
|
||
description:
|
||
"Get a detailed listing of all files and directories in a specified path. " +
|
||
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
|
||
"prefixes. This tool is essential for understanding directory structure and " +
|
||
"finding specific files within a directory. Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string()
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
async (args: z.infer<typeof ListDirectoryArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
||
const formatted = entries
|
||
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
|
||
.join("\n");
|
||
return {
|
||
content: [{ type: "text" as const, text: formatted }],
|
||
structuredContent: { content: formatted }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"list_directory_with_sizes",
|
||
{
|
||
title: "List Directory with Sizes",
|
||
description:
|
||
"Get a detailed listing of all files and directories in a specified path, including sizes. " +
|
||
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
|
||
"prefixes. This tool is useful for understanding directory structure and " +
|
||
"finding specific files within a directory. Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string(),
|
||
sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size")
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
||
|
||
// Get detailed information for each entry
|
||
const detailedEntries = await Promise.all(
|
||
entries.map(async (entry) => {
|
||
const entryPath = path.join(validPath, entry.name);
|
||
try {
|
||
const stats = await fs.stat(entryPath);
|
||
return {
|
||
name: entry.name,
|
||
isDirectory: entry.isDirectory(),
|
||
size: stats.size,
|
||
mtime: stats.mtime
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
name: entry.name,
|
||
isDirectory: entry.isDirectory(),
|
||
size: 0,
|
||
mtime: new Date(0)
|
||
};
|
||
}
|
||
})
|
||
);
|
||
|
||
// Sort entries based on sortBy parameter
|
||
const sortedEntries = [...detailedEntries].sort((a, b) => {
|
||
if (args.sortBy === 'size') {
|
||
return b.size - a.size; // Descending by size
|
||
}
|
||
// Default sort by name
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
// Format the output
|
||
const formattedEntries = sortedEntries.map(entry =>
|
||
`${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${
|
||
entry.isDirectory ? "" : formatSize(entry.size).padStart(10)
|
||
}`
|
||
);
|
||
|
||
// Add summary
|
||
const totalFiles = detailedEntries.filter(e => !e.isDirectory).length;
|
||
const totalDirs = detailedEntries.filter(e => e.isDirectory).length;
|
||
const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), 0);
|
||
|
||
const summary = [
|
||
"",
|
||
`Total: ${totalFiles} files, ${totalDirs} directories`,
|
||
`Combined size: ${formatSize(totalSize)}`
|
||
];
|
||
|
||
const text = [...formattedEntries, ...summary].join("\n");
|
||
const contentBlock = { type: "text" as const, text };
|
||
return {
|
||
content: [contentBlock],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"directory_tree",
|
||
{
|
||
title: "Directory Tree",
|
||
description:
|
||
"Get a recursive tree view of files and directories as a JSON structure. " +
|
||
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
|
||
"Files have no children array, while directories always have a children array (which may be empty). " +
|
||
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string(),
|
||
excludePatterns: z.array(z.string()).optional().default([])
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
|
||
interface TreeEntry {
|
||
name: string;
|
||
type: 'file' | 'directory';
|
||
children?: TreeEntry[];
|
||
}
|
||
const rootPath = args.path;
|
||
|
||
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
|
||
const validPath = await validatePath(currentPath);
|
||
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
||
const result: TreeEntry[] = [];
|
||
|
||
for (const entry of entries) {
|
||
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
|
||
const shouldExclude = excludePatterns.some(pattern => {
|
||
if (pattern.includes('*')) {
|
||
return minimatch(relativePath, pattern, { dot: true });
|
||
}
|
||
// For files: match exact name or as part of path
|
||
// For directories: match as directory path
|
||
return minimatch(relativePath, pattern, { dot: true }) ||
|
||
minimatch(relativePath, `**/${pattern}`, { dot: true }) ||
|
||
minimatch(relativePath, `**/${pattern}/**`, { dot: true });
|
||
});
|
||
if (shouldExclude)
|
||
continue;
|
||
|
||
const entryData: TreeEntry = {
|
||
name: entry.name,
|
||
type: entry.isDirectory() ? 'directory' : 'file'
|
||
};
|
||
|
||
if (entry.isDirectory()) {
|
||
const subPath = path.join(currentPath, entry.name);
|
||
entryData.children = await buildTree(subPath, excludePatterns);
|
||
}
|
||
|
||
result.push(entryData);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
const treeData = await buildTree(rootPath, args.excludePatterns);
|
||
const text = JSON.stringify(treeData, null, 2);
|
||
const contentBlock = { type: "text" as const, text };
|
||
return {
|
||
content: [contentBlock],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"move_file",
|
||
{
|
||
title: "Move File",
|
||
description:
|
||
"Move or rename files and directories. Can move files between directories " +
|
||
"and rename them in a single operation. If the destination exists, the " +
|
||
"operation will fail. Works across different directories and can be used " +
|
||
"for simple renaming within the same directory. Both source and destination must be within allowed directories.",
|
||
inputSchema: {
|
||
source: z.string(),
|
||
destination: z.string()
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: false }
|
||
},
|
||
async (args: z.infer<typeof MoveFileArgsSchema>) => {
|
||
const validSourcePath = await validatePath(args.source);
|
||
const validDestPath = await validatePath(args.destination);
|
||
await fs.rename(validSourcePath, validDestPath);
|
||
const text = `Successfully moved ${args.source} to ${args.destination}`;
|
||
const contentBlock = { type: "text" as const, text };
|
||
return {
|
||
content: [contentBlock],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"search_files",
|
||
{
|
||
title: "Search Files",
|
||
description:
|
||
"Recursively search for files and directories matching a pattern. " +
|
||
"The patterns should be glob-style patterns that match paths relative to the working directory. " +
|
||
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
|
||
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
|
||
"Only searches within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string(),
|
||
pattern: z.string(),
|
||
excludePatterns: z.array(z.string()).optional().default([])
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
async (args: z.infer<typeof SearchFilesArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns });
|
||
const text = results.length > 0 ? results.join("\n") : "No matches found";
|
||
return {
|
||
content: [{ type: "text" as const, text }],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
server.registerTool(
|
||
"get_file_info",
|
||
{
|
||
title: "Get File Info",
|
||
description:
|
||
"Retrieve detailed metadata about a file or directory. Returns comprehensive " +
|
||
"information including size, creation time, last modified time, permissions, " +
|
||
"and type. This tool is perfect for understanding file characteristics " +
|
||
"without reading the actual content. Only works within allowed directories.",
|
||
inputSchema: {
|
||
path: z.string()
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: true }
|
||
},
|
||
async (args: z.infer<typeof GetFileInfoArgsSchema>) => {
|
||
const validPath = await validatePath(args.path);
|
||
const info = await getFileStats(validPath);
|
||
const text = Object.entries(info)
|
||
.map(([key, value]) => `${key}: ${value}`)
|
||
.join("\n");
|
||
return {
|
||
content: [{ type: "text" as const, text }],
|
||
structuredContent: { content: text }
|
||
};
|
||
}
|
||
);
|
||
|
||
async function runCommandToolHandler(rawArgs: unknown) {
|
||
const args = RunCommandArgsSchema.parse(rawArgs);
|
||
const result = await runShellCommand(args);
|
||
|
||
const textLines = [
|
||
`cwd: ${result.effectiveCwd}`,
|
||
`exitCode: ${result.exitCode === null ? "null" : String(result.exitCode)}`,
|
||
`signal: ${result.signal ?? "null"}`,
|
||
`timedOut: ${result.timedOut ? "true" : "false"}`,
|
||
`stdoutTruncated: ${result.stdoutTruncated ? "true" : "false"}`,
|
||
`stderrTruncated: ${result.stderrTruncated ? "true" : "false"}`,
|
||
"",
|
||
"stdout:",
|
||
result.stdout || "(empty)",
|
||
"",
|
||
"stderr:",
|
||
result.stderr || "(empty)",
|
||
];
|
||
|
||
const text = textLines.join("\n");
|
||
return {
|
||
content: [{ type: "text" as const, text }],
|
||
structuredContent: {
|
||
content: text,
|
||
cwd: result.effectiveCwd,
|
||
exitCode: result.exitCode,
|
||
signal: result.signal,
|
||
timedOut: result.timedOut,
|
||
stdout: result.stdout,
|
||
stderr: result.stderr,
|
||
stdoutTruncated: result.stdoutTruncated,
|
||
stderrTruncated: result.stderrTruncated,
|
||
},
|
||
};
|
||
}
|
||
|
||
const runCommandToolDefinition = {
|
||
title: "Run Command",
|
||
description:
|
||
"Execute a shell command inside this same container (same filesystem as the filesystem tools). " +
|
||
"Uses a non-interactive shell (`bash -lc` by default). Returns stdout/stderr, exit code, and timeout info.",
|
||
inputSchema: {
|
||
command: z.string(),
|
||
cwd: z.string().optional().describe("Working directory to run the command in. Must be within allowed directories."),
|
||
timeoutMs: z.number().optional().describe("Kill the command if it runs longer than this (ms). Default: 10 minutes."),
|
||
env: z.record(z.string(), z.string()).optional().describe("Extra environment variables (string values)."),
|
||
shell: z.enum(["bash", "sh"]).optional().describe("Shell to use for the command. Default: bash."),
|
||
maxOutputChars: z.number().optional().describe("Maximum characters captured for each of stdout/stderr."),
|
||
},
|
||
outputSchema: { content: z.string() },
|
||
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true },
|
||
};
|
||
|
||
// Keep snake_case for consistency with existing filesystem tools, and also provide a camelCase alias.
|
||
server.registerTool("run_command", runCommandToolDefinition, runCommandToolHandler);
|
||
server.registerTool("runCommand", runCommandToolDefinition, runCommandToolHandler);
|
||
|
||
// SSE transport session routing (sessionId -> transport)
|
||
const sseTransportsBySessionId = new Map<string, SSEServerTransport>();
|
||
|
||
function runServer() {
|
||
const port = Number(process.env.MCP_PORT ?? process.env.SSE_PORT ?? 3000);
|
||
|
||
const httpServer = http.createServer(async (req, res) => {
|
||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||
const pathname = url.pathname;
|
||
|
||
if (req.method === "GET" && (pathname === "/sse" || pathname === "/")) {
|
||
try {
|
||
const transport = new SSEServerTransport("/messages", res);
|
||
sseTransportsBySessionId.set(transport.sessionId, transport);
|
||
transport.onclose = () => {
|
||
sseTransportsBySessionId.delete(transport.sessionId);
|
||
};
|
||
// SSE heartbeat to prevent client ReadTimeout during idle (e.g. while waiting for Ollama)
|
||
const heartbeatIntervalMs = 15_000;
|
||
const heartbeatInterval = setInterval(() => {
|
||
try {
|
||
if (!res.writableEnded) {
|
||
res.write(': heartbeat\n\n');
|
||
} else {
|
||
clearInterval(heartbeatInterval);
|
||
}
|
||
} catch {
|
||
clearInterval(heartbeatInterval);
|
||
}
|
||
}, heartbeatIntervalMs);
|
||
res.on('close', () => clearInterval(heartbeatInterval));
|
||
await server.connect(transport);
|
||
console.error("Secure MCP Filesystem Server: new SSE client connected");
|
||
} catch (error) {
|
||
console.error("SSE connection error:", error);
|
||
if (!res.headersSent) {
|
||
res.writeHead(500).end("Internal server error");
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && pathname === "/messages") {
|
||
const sessionId = url.searchParams.get("sessionId");
|
||
if (!sessionId) {
|
||
res.writeHead(400).end("Missing sessionId query parameter");
|
||
return;
|
||
}
|
||
const transport = sseTransportsBySessionId.get(sessionId);
|
||
if (!transport) {
|
||
res.writeHead(404).end("Unknown session");
|
||
return;
|
||
}
|
||
await transport.handlePostMessage(req, res);
|
||
return;
|
||
}
|
||
|
||
res.writeHead(404).end("Not found");
|
||
});
|
||
|
||
httpServer.listen(port, () => {
|
||
console.error(`Secure MCP Filesystem Server running on SSE at http://localhost:${port}`);
|
||
console.error(" GET /sse – open SSE stream (then POST to /messages?sessionId=...)");
|
||
console.error(" POST /messages?sessionId=<id> – send MCP messages");
|
||
console.error(" Allowed directory: / (full filesystem)");
|
||
});
|
||
}
|
||
|
||
runServer();
|