#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { RootsListChangedNotificationSchema, type Root } from "@modelcontextprotocol/sdk/types.js";
import type { Request, Response, NextFunction } from "express";
import express from "express";
import cors from "cors";
import { randomUUID, createHash, randomBytes } from "node:crypto";
import fs from "fs/promises";
import { createReadStream, existsSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import readline from "readline";
import { z } from "zod";
import { minimatch } from "minimatch";
import { normalizePath, expandHome } from "./path-utils.js";
import { getValidRootDirectories } from "./roots-utils.js";
import { logger } from "./logger.js";
import {
// Function imports
formatSize,
validatePath,
getFileStats,
readFileContent,
writeFileContent,
searchFilesWithValidation,
applyFileEdits,
tailFile,
headFile,
setAllowedDirectories,
} from "./lib.js";
// Get the directory where this script is located
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const envPath = path.join(projectRoot, ".env");
// Generate random credentials
function generateRandomCredentials(): { clientId: string; clientSecret: string } {
return {
clientId: randomBytes(16).toString("hex"),
clientSecret: randomBytes(32).toString("hex"),
};
}
// Create .env file with random credentials
async function createEnvFile(): Promise<{ clientId: string; clientSecret: string }> {
const credentials = generateRandomCredentials();
const envContent = `# OAuth Client Credentials (required)
CLIENT_ID=${credentials.clientId}
CLIENT_SECRET=${credentials.clientSecret}
# Server configuration (optional)
PORT=24024
`;
await fs.writeFile(envPath, envContent);
return credentials;
}
// Load .env file manually
async function loadEnvFile(): Promise<void> {
try {
const content = await fs.readFile(envPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const eqIndex = trimmed.indexOf("=");
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex);
const value = trimmed.substring(eqIndex + 1);
if (!process.env[key]) {
process.env[key] = value;
}
}
}
}
} catch {
// .env doesn't exist, that's fine
}
}
// Ask user a yes/no question
async function askYesNo(question: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
});
});
}
// Handle --init flag
const rawArgs = process.argv.slice(2);
const hasInit = rawArgs.includes("--init");
const hasForce = rawArgs.includes("--force");
if (hasInit) {
if (existsSync(envPath) && !hasForce) {
logger.info(`.env file already exists at: ${envPath}`);
logger.info("Use --force to overwrite.");
process.exit(1);
}
const credentials = await createEnvFile();
logger.info("Created .env file with random credentials:");
logger.info(` CLIENT_ID=${credentials.clientId}`);
logger.info(` CLIENT_SECRET=${credentials.clientSecret}`);
logger.info(`File location: ${envPath}`);
logger.info("You can now start the server with:");
logger.info(" node dist/index.js /path/to/allowed/directory");
process.exit(0);
}
// Load .env file if it exists
await loadEnvFile();
// Check for credentials, prompt to create if missing
let CLIENT_ID = process.env.CLIENT_ID;
let CLIENT_SECRET = process.env.CLIENT_SECRET;
if (!CLIENT_ID || !CLIENT_SECRET) {
logger.warn("Missing required environment variables: CLIENT_ID and CLIENT_SECRET");
if (existsSync(envPath)) {
logger.error(".env file exists but CLIENT_ID or CLIENT_SECRET is not set.");
logger.info(`Please check your .env file at: ${envPath}`);
process.exit(1);
}
// Check if stdin is a TTY (interactive terminal)
if (process.stdin.isTTY) {
const shouldCreate = await askYesNo(
"Would you like to create .env with random credentials? (y/n): "
);
if (shouldCreate) {
const credentials = await createEnvFile();
CLIENT_ID = credentials.clientId;
CLIENT_SECRET = credentials.clientSecret;
logger.info("Created .env file with random credentials:");
logger.info(` CLIENT_ID=${CLIENT_ID}`);
logger.info(` CLIENT_SECRET=${CLIENT_SECRET}`);
logger.info(`File location: ${envPath}`);
} else {
logger.info("Please create .env from .env.example or set environment variables.");
logger.info("You can also use: node dist/index.js --init");
process.exit(1);
}
} else {
logger.info("Not running in interactive mode.");
logger.info("Please either:");
logger.info(" 1. Run with --init to create .env: node dist/index.js --init");
logger.info(" 2. Create .env file manually (see .env.example)");
logger.info(" 3. Set CLIENT_ID and CLIENT_SECRET environment variables");
process.exit(1);
}
}
// Token storage (in production, use Redis or similar)
const accessTokens: Map<string, { expiresAt: number }> = new Map();
const refreshTokens: Map<string, { clientId: string; expiresAt: number }> = new Map();
// Authorization code storage for PKCE flow
type AuthorizationCode = {
clientId: string;
redirectUri: string;
codeChallenge: string;
codeChallengeMethod: string;
expiresAt: number;
};
const authorizationCodes: Map<string, AuthorizationCode> = new Map();
// Pending authorization requests - CSRF protection
// Only requests that were shown the consent screen can be approved
type PendingAuthorization = {
clientId: string;
redirectUri: string;
state: string | undefined;
codeChallenge: string;
codeChallengeMethod: string;
expiresAt: number;
};
const pendingAuthorizations: Map<string, PendingAuthorization> = new Map();
const PENDING_AUTH_EXPIRATION_MS = 10 * 60 * 1000; // 10 minutes
// Dynamic client registration storage
type RegisteredClient = {
clientId: string;
clientSecret?: string | undefined;
redirectUris: string[];
clientName?: string | undefined;
createdAt: number;
};
const registeredClients: Map<string, RegisteredClient> = new Map();
// Pre-register the static client from environment variables
// This is for machine-to-machine access (client_credentials flow) and local testing
// ChatGPT and other MCP clients use dynamic registration and provide their own redirect URIs
if (CLIENT_ID) {
registeredClients.set(CLIENT_ID, {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
redirectUris: ["http://localhost:3000/callback", "http://127.0.0.1:3000/callback"],
clientName: "Static Admin Client",
createdAt: Date.now(),
});
logger.info(`Pre-registered static client: ${CLIENT_ID}`);
}
// Token expiration times
const TOKEN_EXPIRATION_MS = 60 * 60 * 1000; // 1 hour
const REFRESH_TOKEN_EXPIRATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const AUTH_CODE_EXPIRATION_MS = 10 * 60 * 1000; // 10 minutes
// Generate access token
function generateAccessToken(): string {
return createHash("sha256")
.update(randomUUID() + Date.now().toString())
.digest("hex");
}
// Generate refresh token
function generateRefreshToken(): string {
return randomBytes(32).toString("hex");
}
// Base64URL encode for PKCE
function base64UrlEncode(buffer: Buffer): string {
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
// Verify PKCE code challenge
function verifyCodeChallenge(codeVerifier: string, codeChallenge: string, method: string): boolean {
if (method === "S256") {
const hash = createHash("sha256").update(codeVerifier).digest();
const computed = base64UrlEncode(hash);
return computed === codeChallenge;
}
// plain method (not recommended but supported)
return codeVerifier === codeChallenge;
}
// Get server URL from request
function getServerUrl(req: Request): string {
const protocol = req.headers["x-forwarded-proto"] || req.protocol || "http";
const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost";
return `${protocol}://${host}`;
}
// Validate access token
function validateAccessToken(token: string): boolean {
const tokenData = accessTokens.get(token);
if (!tokenData) return false;
if (Date.now() > tokenData.expiresAt) {
accessTokens.delete(token);
return false;
}
return true;
}
// Auth middleware
function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({
error: "unauthorized",
error_description: "Missing or invalid Authorization header. Use Bearer token.",
});
return;
}
const token = authHeader.substring(7);
if (!validateAccessToken(token)) {
res.status(401).json({
error: "invalid_token",
error_description: "Access token is invalid or expired.",
});
return;
}
next();
}
// Check if a path is unsafe to serve (root, home directory, or system paths)
function isUnsafePath(dirPath: string): boolean {
const normalized = path.resolve(dirPath);
// Root directory
if (normalized === "/") return true;
// Home directory (Linux/Mac)
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
if (homeDir && normalized === path.resolve(homeDir)) return true;
// System directories (Linux/Mac)
const unsafePaths = [
"/bin",
"/sbin",
"/usr",
"/etc",
"/var",
"/lib",
"/lib64",
"/boot",
"/dev",
"/proc",
"/sys",
"/root",
// Mac specific
"/System",
"/Library",
"/Applications",
"/Users",
// Windows
"C:\\",
"C:\\Windows",
"C:\\Program Files",
"C:\\Program Files (x86)",
];
for (const unsafe of unsafePaths) {
if (normalized === path.resolve(unsafe)) return true;
}
return false;
}
// Command line argument parsing - filter out flags
const args = process.argv.slice(2).filter((arg) => !arg.startsWith("--"));
// If no directories provided, try to use current directory if safe
if (args.length === 0) {
const cwd = process.cwd();
if (isUnsafePath(cwd)) {
logger.error("No directories specified and current directory is unsafe to serve.");
logger.error(`Current directory: ${cwd}`);
logger.info("Usage: mcpfs [allowed-directory] [additional-directories...]");
logger.info("For safety, the server won't automatically serve:");
logger.info(" - Root directory (/)");
logger.info(" - Home directory (~)");
logger.info(" - System directories (/usr, /etc, /var, etc.)");
logger.info("Please specify allowed directories explicitly, or run from a project directory.");
process.exit(1);
} else {
args.push(cwd);
logger.info(`No directories specified. Serving current directory: ${cwd}`);
}
}
// Store allowed directories in normalized and resolved form
let allowedDirectories = await Promise.all(
args.map(async (dir) => {
const expanded = expandHome(dir);
const absolute = path.resolve(expanded);
try {
// Security: Resolve symlinks in allowed directories during startup
// This ensures we know the real paths and can validate against them later
const resolved = await fs.realpath(absolute);
return normalizePath(resolved);
} catch {
// If we can't resolve (doesn't exist), use the normalized absolute path
// This allows configuring allowed dirs that will be created later
return normalizePath(absolute);
}
})
);
// Validate that all directories exist and are accessible
await Promise.all(
allowedDirectories.map(async (dir) => {
try {
const stats = await fs.stat(dir);
if (!stats.isDirectory()) {
logger.error(`Error: ${dir} is not a directory`);
process.exit(1);
}
} catch (err) {
logger.error(`Error accessing directory ${dir}`, err);
process.exit(1);
}
})
);
// Initialize the global allowedDirectories in lib.ts
setAllowedDirectories(allowedDirectories);
// Type definitions for tool arguments (with undefined to satisfy exactOptionalPropertyTypes)
type ReadTextFileArgs = {
path: string;
tail?: number | undefined;
head?: number | undefined;
};
type ReadMediaFileArgs = {
path: string;
};
type ReadMultipleFilesArgs = {
paths: string[];
};
type WriteFileArgs = {
path: string;
content: string;
};
type EditFileArgs = {
path: string;
edits: Array<{ oldText: string; newText: string }>;
dryRun?: boolean | undefined;
};
type CreateDirectoryArgs = {
path: string;
};
type ListDirectoryArgs = {
path: string;
};
type ListDirectoryWithSizesArgs = {
path: string;
sortBy?: "name" | "size" | undefined;
};
type DirectoryTreeArgs = {
path: string;
excludePatterns?: string[] | undefined;
};
type MoveFileArgs = {
source: string;
destination: string;
};
type SearchFilesArgs = {
path: string;
pattern: string;
excludePatterns?: string[] | undefined;
};
type GetFileInfoArgs = {
path: string;
};
// 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));
});
}
// Factory function to create a new MCP server instance
function createServer() {
const server = new McpServer({
name: "secure-filesystem-server",
version: "0.2.0",
});
// Tool registrations
// read_file (deprecated) and read_text_file
const readTextFileHandler = async (args: ReadTextFileArgs) => {
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: {
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_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: ReadMediaFileArgs) => {
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: ReadMultipleFilesArgs) => {
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: WriteFileArgs) => {
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: EditFileArgs) => {
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: CreateDirectoryArgs) => {
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: ListDirectoryArgs) => {
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: ListDirectoryWithSizesArgs) => {
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 {
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: DirectoryTreeArgs) => {
type 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: MoveFileArgs) => {
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: SearchFilesArgs) => {
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: GetFileInfoArgs) => {
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 },
};
}
);
server.registerTool(
"list_allowed_directories",
{
title: "List Allowed Directories",
description:
"Returns the list of directories that this server is allowed to access. " +
"Subdirectories within these allowed directories are also accessible. " +
"Use this to understand which directories and their nested paths are available " +
"before trying to access files.",
inputSchema: {},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true },
},
async () => {
const text = `Allowed directories:\n${allowedDirectories.join("\n")}`;
return {
content: [{ type: "text" as const, text }],
structuredContent: { content: text },
};
}
);
// Updates allowed directories based on MCP client roots
async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
const validatedRootDirs = await getValidRootDirectories(requestedRoots);
if (validatedRootDirs.length > 0) {
allowedDirectories = [...validatedRootDirs];
setAllowedDirectories(allowedDirectories); // Update the global state in lib.ts
logger.info(
`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`
);
} else {
logger.warn("No valid root directories provided by client");
}
}
// Handles dynamic roots updates during runtime
server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
try {
const response = await server.server.listRoots();
if (response && "roots" in response) {
await updateAllowedDirectoriesFromRoots(response.roots);
}
} catch (err) {
logger.error("Failed to request roots from client", err);
}
});
// Handles post-initialization setup
server.server.oninitialized = async () => {
const clientCapabilities = server.server.getClientCapabilities();
if (clientCapabilities?.roots) {
try {
const response = await server.server.listRoots();
if (response && "roots" in response) {
await updateAllowedDirectoriesFromRoots(response.roots);
} else {
logger.warn("Client returned no roots set, keeping current settings");
}
} catch (err) {
logger.error("Failed to request initial roots from client", err);
}
} else {
if (allowedDirectories.length > 0) {
logger.info(
`Client does not support MCP Roots, using allowed directories set from server args: ${allowedDirectories.join(", ")}`
);
} else {
throw new Error(
`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`
);
}
}
};
return server;
}
// Express app setup
const app = express();
app.use(
cors({
origin: "*",
methods: "GET,POST,DELETE",
preflightContinue: false,
optionsSuccessStatus: 204,
exposedHeaders: ["mcp-session-id", "last-event-id", "mcp-protocol-version"],
})
);
// OAuth 2.1 Discovery Endpoints (RFC 9728, RFC 8414)
// Protected Resource Metadata (RFC 9728)
app.get("/.well-known/oauth-protected-resource", (req: Request, res: Response) => {
const serverUrl = getServerUrl(req);
res.json({
resource: serverUrl,
authorization_servers: [serverUrl],
bearer_methods_supported: ["header"],
});
});
// Authorization Server Metadata (RFC 8414)
app.get("/.well-known/oauth-authorization-server", (req: Request, res: Response) => {
const serverUrl = getServerUrl(req);
res.json({
issuer: serverUrl,
authorization_endpoint: `${serverUrl}/authorize`,
token_endpoint: `${serverUrl}/token`,
registration_endpoint: `${serverUrl}/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token", "client_credentials"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
code_challenge_methods_supported: ["S256"],
service_documentation: "https://github.com/jeswin/mcpfs",
});
});
// Dynamic Client Registration (RFC 7591)
app.post("/register", express.json(), (req: Request, res: Response) => {
const { redirect_uris, client_name } = req.body;
if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
res.status(400).json({
error: "invalid_redirect_uri",
error_description: "At least one redirect_uri is required",
});
return;
}
const clientId = randomBytes(16).toString("hex");
const clientSecret = randomBytes(32).toString("hex");
registeredClients.set(clientId, {
clientId,
clientSecret,
redirectUris: redirect_uris,
clientName: client_name,
createdAt: Date.now(),
});
logger.info(`Registered new client: ${clientId} (${client_name || "unnamed"})`);
res.status(201).json({
client_id: clientId,
client_secret: clientSecret,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris,
client_name,
});
});
// Authorization Endpoint with PKCE - Shows consent screen
app.get("/authorize", (req: Request, res: Response) => {
const { response_type, client_id, redirect_uri, state, code_challenge, code_challenge_method } =
req.query as Record<string, string>;
// Validate required parameters
if (response_type !== "code") {
res.status(400).send("Invalid response_type. Only 'code' is supported.");
return;
}
if (!client_id) {
res.status(400).send("Missing client_id");
return;
}
// Check if client is registered
const client = registeredClients.get(client_id);
if (!client) {
res.status(400).send(`Unknown client_id: ${client_id}`);
return;
}
if (!redirect_uri) {
res.status(400).send("Missing redirect_uri");
return;
}
// Validate redirect_uri against registered URIs (with wildcard support)
const isValidRedirect = client.redirectUris.some((uri) => {
if (uri.includes("*")) {
const pattern = uri.replace(/\*/g, ".*");
return new RegExp(`^${pattern}$`).test(redirect_uri);
}
return uri === redirect_uri;
});
if (!isValidRedirect) {
res.status(400).send(`Invalid redirect_uri: ${redirect_uri}`);
return;
}
if (!code_challenge || !code_challenge_method) {
res.status(400).send("PKCE required: missing code_challenge or code_challenge_method");
return;
}
if (code_challenge_method !== "S256") {
res.status(400).send("Only S256 code_challenge_method is supported");
return;
}
// Generate CSRF token and store pending authorization
const csrfToken = randomBytes(32).toString("hex");
pendingAuthorizations.set(csrfToken, {
clientId: client_id,
redirectUri: redirect_uri,
state: state || undefined,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method,
expiresAt: Date.now() + PENDING_AUTH_EXPIRATION_MS,
});
// Show consent screen
const clientName = client.clientName || client_id;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize Access - mcpfs</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
background: white;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
padding: 40px;
max-width: 420px;
width: 100%;
}
h1 {
color: #1a202c;
font-size: 24px;
margin-bottom: 8px;
}
.subtitle {
color: #718096;
font-size: 14px;
margin-bottom: 24px;
}
.client-info {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.client-name {
font-weight: 600;
color: #2d3748;
font-size: 16px;
}
.permissions {
margin-bottom: 24px;
}
.permissions h3 {
color: #4a5568;
font-size: 14px;
margin-bottom: 12px;
}
.permission-item {
display: flex;
align-items: center;
padding: 8px 0;
color: #4a5568;
font-size: 14px;
}
.permission-item::before {
content: "✓";
color: #48bb78;
font-weight: bold;
margin-right: 10px;
}
.buttons {
display: flex;
gap: 12px;
}
button {
flex: 1;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.allow {
background: #667eea;
color: white;
border: none;
}
.allow:hover { background: #5a67d8; }
.deny {
background: white;
color: #4a5568;
border: 1px solid #e2e8f0;
}
.deny:hover { background: #f7fafc; }
.directories {
margin-top: 16px;
padding: 12px;
background: #fffaf0;
border: 1px solid #fbd38d;
border-radius: 8px;
font-size: 12px;
color: #744210;
}
.directories strong { display: block; margin-bottom: 4px; }
.dir-list { font-family: monospace; word-break: break-all; }
</style>
</head>
<body>
<div class="card">
<h1>Authorize Access</h1>
<p class="subtitle">An application is requesting access to your files</p>
<div class="client-info">
<div class="client-name">${clientName}</div>
</div>
<div class="permissions">
<h3>This application will be able to:</h3>
<div class="permission-item">Read files and directories</div>
<div class="permission-item">Write and modify files</div>
<div class="permission-item">Create and delete directories</div>
<div class="permission-item">Search and list files</div>
</div>
<div class="directories">
<strong>Allowed directories:</strong>
<div class="dir-list">${allowedDirectories.join(", ") || "Configured by MCP client"}</div>
</div>
<form method="POST" action="/authorize" style="margin-top: 24px;">
<input type="hidden" name="csrf_token" value="${csrfToken}">
<div class="buttons">
<button type="submit" name="action" value="deny" class="deny">Deny</button>
<button type="submit" name="action" value="allow" class="allow">Allow</button>
</div>
</form>
</div>
</body>
</html>`;
res.type("html").send(html);
});
// Handle authorization form submission
app.post("/authorize", express.urlencoded({ extended: true }), (req: Request, res: Response) => {
const { csrf_token, action } = req.body;
// Verify CSRF token - this proves the user saw our consent screen
if (!csrf_token || typeof csrf_token !== "string") {
res.status(400).send("Invalid request: missing CSRF token");
return;
}
const pendingAuth = pendingAuthorizations.get(csrf_token);
if (!pendingAuth) {
res.status(400).send("Invalid or expired authorization request. Please try again.");
return;
}
// Delete the pending auth immediately (one-time use)
pendingAuthorizations.delete(csrf_token);
// Check expiration
if (Date.now() > pendingAuth.expiresAt) {
res.status(400).send("Authorization request expired. Please try again.");
return;
}
// Build redirect URL
const redirectUrl = new URL(pendingAuth.redirectUri);
if (action !== "allow") {
// User denied access
redirectUrl.searchParams.set("error", "access_denied");
redirectUrl.searchParams.set("error_description", "User denied the authorization request");
if (pendingAuth.state) redirectUrl.searchParams.set("state", pendingAuth.state);
res.redirect(redirectUrl.toString());
return;
}
// User approved - generate authorization code
const code = randomBytes(32).toString("hex");
authorizationCodes.set(code, {
clientId: pendingAuth.clientId,
redirectUri: pendingAuth.redirectUri,
codeChallenge: pendingAuth.codeChallenge,
codeChallengeMethod: pendingAuth.codeChallengeMethod,
expiresAt: Date.now() + AUTH_CODE_EXPIRATION_MS,
});
logger.info(`Authorization code issued for client: ${pendingAuth.clientId}`);
redirectUrl.searchParams.set("code", code);
if (pendingAuth.state) redirectUrl.searchParams.set("state", pendingAuth.state);
const redirectTarget = redirectUrl.toString();
logger.debug(`Redirecting to: ${redirectTarget}`);
res.redirect(redirectTarget);
});
// Token Endpoint - Supports authorization_code, refresh_token, and client_credentials
app.post(
"/token",
express.json(),
express.urlencoded({ extended: true }),
(req: Request, res: Response) => {
logger.debug(`Token request received: ${JSON.stringify(req.body)}`);
const grantType = req.body.grant_type;
const clientId = req.body.client_id;
const clientSecret = req.body.client_secret;
// Handle authorization_code grant (OAuth 2.1 with PKCE)
if (grantType === "authorization_code") {
const code = req.body.code;
const codeVerifier = req.body.code_verifier;
const redirectUri = req.body.redirect_uri;
if (!code || !codeVerifier) {
logger.debug(`Token error: Missing code or code_verifier`);
res.status(400).json({
error: "invalid_request",
error_description: "Missing code or code_verifier",
});
return;
}
const authCode = authorizationCodes.get(code);
if (!authCode) {
logger.debug(`Token error: Invalid or expired authorization code`);
res.status(400).json({
error: "invalid_grant",
error_description: "Invalid or expired authorization code",
});
return;
}
// Verify code hasn't expired
if (Date.now() > authCode.expiresAt) {
authorizationCodes.delete(code);
logger.debug(`Token error: Authorization code has expired`);
res.status(400).json({
error: "invalid_grant",
error_description: "Authorization code has expired",
});
return;
}
// Verify client_id matches (if provided)
// OAuth 2.1 allows client_id to be omitted if the code is already bound to the client
if (clientId && authCode.clientId !== clientId) {
logger.debug(
`Token error: Client ID mismatch - expected ${authCode.clientId}, got ${clientId}`
);
res.status(400).json({
error: "invalid_grant",
error_description: "Client ID mismatch",
});
return;
}
// Verify redirect_uri matches
if (authCode.redirectUri !== redirectUri) {
logger.debug(
`Token error: Redirect URI mismatch - expected ${authCode.redirectUri}, got ${redirectUri}`
);
res.status(400).json({
error: "invalid_grant",
error_description: "Redirect URI mismatch",
});
return;
}
// Verify PKCE code_verifier
if (
!verifyCodeChallenge(codeVerifier, authCode.codeChallenge, authCode.codeChallengeMethod)
) {
logger.debug(`Token error: Invalid code_verifier`);
res.status(400).json({
error: "invalid_grant",
error_description: "Invalid code_verifier",
});
return;
}
// Delete the used authorization code (one-time use)
authorizationCodes.delete(code);
// Generate tokens
const accessToken = generateAccessToken();
const refreshToken = generateRefreshToken();
const expiresIn = TOKEN_EXPIRATION_MS / 1000;
const effectiveClientId = clientId || authCode.clientId;
accessTokens.set(accessToken, {
expiresAt: Date.now() + TOKEN_EXPIRATION_MS,
});
refreshTokens.set(refreshToken, {
clientId: effectiveClientId,
expiresAt: Date.now() + REFRESH_TOKEN_EXPIRATION_MS,
});
logger.info(`Access token issued for client: ${effectiveClientId} (authorization_code)`);
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: refreshToken,
});
return;
}
// Handle refresh_token grant
if (grantType === "refresh_token") {
const refreshToken = req.body.refresh_token;
if (!refreshToken) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing refresh_token",
});
return;
}
const storedRefresh = refreshTokens.get(refreshToken);
if (!storedRefresh) {
res.status(400).json({
error: "invalid_grant",
error_description: "Invalid refresh token",
});
return;
}
if (Date.now() > storedRefresh.expiresAt) {
refreshTokens.delete(refreshToken);
res.status(400).json({
error: "invalid_grant",
error_description: "Refresh token has expired",
});
return;
}
// Rotate refresh token (OAuth 2.1 requirement)
refreshTokens.delete(refreshToken);
const newRefreshToken = generateRefreshToken();
const accessToken = generateAccessToken();
const expiresIn = TOKEN_EXPIRATION_MS / 1000;
accessTokens.set(accessToken, {
expiresAt: Date.now() + TOKEN_EXPIRATION_MS,
});
refreshTokens.set(newRefreshToken, {
clientId: storedRefresh.clientId,
expiresAt: Date.now() + REFRESH_TOKEN_EXPIRATION_MS,
});
logger.info(`Access token refreshed for client: ${storedRefresh.clientId}`);
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: newRefreshToken,
});
return;
}
// Handle client_credentials grant (machine-to-machine)
if (grantType === "client_credentials") {
// Validate client credentials against static config or registered clients
const client = registeredClients.get(clientId);
const isValidStatic = clientId === CLIENT_ID && clientSecret === CLIENT_SECRET;
const isValidRegistered = client && client.clientSecret === clientSecret;
if (!isValidStatic && !isValidRegistered) {
res.status(401).json({
error: "invalid_client",
error_description: "Invalid client_id or client_secret.",
});
return;
}
const accessToken = generateAccessToken();
const expiresIn = TOKEN_EXPIRATION_MS / 1000;
accessTokens.set(accessToken, {
expiresAt: Date.now() + TOKEN_EXPIRATION_MS,
});
logger.info(`Access token issued for client: ${clientId} (client_credentials)`);
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
});
return;
}
res.status(400).json({
error: "unsupported_grant_type",
error_description:
"Supported grant types: authorization_code, refresh_token, client_credentials",
});
}
);
// Map sessionId to server transport for each client
const transports: Map<string, StreamableHTTPServerTransport> = new Map();
// Handle POST requests for client messages (protected)
app.post("/mcp", authMiddleware, async (req: Request, res: Response) => {
logger.debug("Received MCP POST request");
try {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports.has(sessionId)) {
const existingTransport = transports.get(sessionId);
if (!existingTransport) {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Session not found" },
id: req?.body?.id,
});
return;
}
transport = existingTransport;
} else if (!sessionId) {
const server = createServer();
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore,
onsessioninitialized: (sid: string) => {
logger.debug(`Session initialized with ID: ${sid}`);
transports.set(sid, transport);
},
});
server.server.onclose = async () => {
const sid = transport.sessionId;
if (sid && transports.has(sid)) {
logger.debug(`Transport closed for session ${sid}, removing from transports map`);
transports.delete(sid);
}
};
await server.connect(transport as Parameters<typeof server.connect>[0]);
await transport.handleRequest(req, res);
return;
} else {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
await transport.handleRequest(req, res);
} catch (err) {
logger.error("Error handling MCP request", err);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: req?.body?.id,
});
}
}
});
// Handle GET requests for SSE streams (protected)
app.get("/mcp", authMiddleware, async (req: Request, res: Response) => {
logger.debug("Received MCP GET request");
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports.has(sessionId)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
const transport = transports.get(sessionId);
if (!transport) {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Session not found" },
id: req?.body?.id,
});
return;
}
await transport.handleRequest(req, res);
});
// Handle DELETE requests for session termination (protected)
app.delete("/mcp", authMiddleware, async (req: Request, res: Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports.has(sessionId)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
logger.debug(`Session termination request for session ${sessionId}`);
const transport = transports.get(sessionId);
if (!transport) {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Session not found" },
id: req?.body?.id,
});
return;
}
try {
await transport.handleRequest(req, res);
} catch (err) {
logger.error("Error handling session termination", err);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Error handling session termination",
},
id: req?.body?.id,
});
}
}
});
// Start the server
const PORT = process.env.PORT || 24024;
const httpServer = app.listen(PORT, () => {
logger.info(`MCP Filesystem Server (HTTP Streaming) listening on port ${PORT}`);
if (allowedDirectories.length === 0) {
logger.info(
"Started without allowed directories - waiting for client to provide roots via MCP protocol"
);
} else {
logger.info(`Allowed directories: ${allowedDirectories.join(", ")}`);
}
});
// Handle server errors
httpServer.on("error", (err: unknown) => {
const code =
typeof err === "object" && err !== null && "code" in err
? (err as { code?: unknown }).code
: undefined;
if (code === "EADDRINUSE") {
logger.error(`Failed to start: Port ${PORT} is already in use.`);
} else {
logger.error("HTTP server encountered an error", err);
}
process.exit(1);
});
// Handle server shutdown
process.on("SIGINT", async () => {
logger.info("Shutting down server...");
for (const [sessionId, transport] of transports) {
try {
logger.debug(`Closing transport for session ${sessionId}`);
await transport.close();
transports.delete(sessionId);
} catch (err) {
logger.error(`Error closing transport for session ${sessionId}`, err);
}
}
httpServer.close(() => {
logger.info("Server shutdown complete");
process.exit(0);
});
});