UNPKG

@modelcontextprotocol/server-filesystem

Version:
206 lines (205 loc) • 9.67 kB
#!/usr/bin/env node // The stdio entry point of the filesystem server (the package's bin). It // parses the command line (its options, such as --read-only or --read-only-dir, // and the allowed directories), resolves the allowed directories and serves // createServer() on stdio through the SDK's serveStdio entry, which answers // 2026-07-28 clients (server/discover, no handshake) and 2025-era clients // (initialize, then Roots) from the same factory (#4852). Everything runs inside // main(), and main() runs only when this file is the process entry point, so // importing the module (as the tests do) starts nothing (#4854). main() takes // the argv and the stdio streams as parameters so the tests can drive the real // startup path in-process. import { realpathSync } from "fs"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; import { serveStdio, StdioServerTransport, } from "@modelcontextprotocol/server/stdio"; import { normalizePathFormat, expandHome, realpathWithRetry, } from "./path-utils.js"; import { createServer } from "./server.js"; import { describeAllowedDirectories } from "./allowed-directories.js"; import { describeReadOnlyDirectories, resolveReadOnlyDirectories, } from "./read-only-directories.js"; /** * Resolve the allowed directories given on the command line, warning about and * dropping any that are missing or not directories. Exits the process when * directories were given and none of them is usable. */ export async function resolveAllowedDirectories(args) { // Store allowed directories in normalized and resolved form // We store BOTH the original path AND the resolved path to handle symlinks correctly // This fixes the macOS /tmp -> /private/tmp symlink issue where users specify /tmp // but the resolved path is /private/tmp const allowedDirectories = (await Promise.all(args.map(async (dir) => { const expanded = expandHome(dir); const absolute = path.resolve(expanded); // normalizePathFormat, not normalizePath, for both forms: a trailing // space or quote is part of a POSIX filename, so trimming it would // allow the sibling that lacks it (#5320). Trimming after // path.resolve could only ever remove a trailing character, never // shell-quoting leftovers at the start, so the argument is taken // exactly as the shell passed it. const normalizedOriginal = normalizePathFormat(absolute); 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 realpathWithRetry(absolute); const normalizedResolved = normalizePathFormat(resolved); // Return both original and resolved paths if they differ // This allows matching against either /tmp or /private/tmp on macOS if (normalizedOriginal !== normalizedResolved) { return [normalizedOriginal, normalizedResolved]; } return [normalizedResolved]; } 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 [normalizedOriginal]; } }))).flat(); // Filter to only accessible directories, warn about inaccessible ones const accessibleDirectories = []; for (const dir of allowedDirectories) { try { const stats = await fs.stat(dir); if (stats.isDirectory()) { accessibleDirectories.push(dir); } else { console.error(`Warning: ${dir} is not a directory, skipping`); } } catch { console.error(`Warning: Cannot access directory ${dir}, skipping`); } } // Exit only if ALL paths are inaccessible (and some were specified) if (accessibleDirectories.length === 0 && allowedDirectories.length > 0) { console.error("Error: None of the specified directories are accessible"); process.exit(1); } return accessibleDirectories; } /** * Split the command line into options and allowed directories. An option may * appear anywhere; `--` ends the options, so a directory whose name is an * option's can still be given after it. Any other argument is a directory, as * every argument was before options existed. `--read-only-dir` takes the next * argument as its directory, whatever it looks like, and throws when there is * none. */ export function parseArgs(args) { const parsed = { directories: [], readOnly: false, confineRoots: false, readOnlyDirectories: [], }; let options = true; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (options && arg === "--") { options = false; } else if (options && arg === "--read-only") { parsed.readOnly = true; } else if (options && arg === "--confine-roots") { parsed.confineRoots = true; } else if (options && arg === "--read-only-dir") { index++; if (index >= args.length) { throw new Error("--read-only-dir needs a directory after it"); } parsed.readOnlyDirectories.push(args[index]); } else { parsed.directories.push(arg); } } return parsed; } /** * Serve the server over stdio with the allowed directories in `args`. The * first message the client sends picks the connection's protocol era, and the * factory builds the server for it. A transport error, including one that * stops the transport from starting, is reported on stderr. */ export async function main(args = process.argv.slice(2), stdin = process.stdin, stdout = process.stdout) { const { directories, readOnly, confineRoots, readOnlyDirectories } = parseArgs(args); if (directories.length === 0 && readOnlyDirectories.length === 0) { console.error("Usage: mcp-server-filesystem [--read-only] [--confine-roots] [--read-only-dir <dir>]... [allowed-directory] [additional-directories...]"); console.error("Note: Allowed directories can be provided via:"); console.error(" 1. Command-line arguments (shown above)"); console.error(" 2. MCP roots protocol (deprecated; 2025-era clients that support it)"); console.error("At least one directory must be provided by EITHER method for the server to operate."); } // A read-only directory is an allowed directory too (#5348). const allowedDirectories = await resolveAllowedDirectories([ ...directories, ...readOnlyDirectories, ]); // Rejects, so the server does not start, when a read-only directory // cannot be resolved: it could not be protected reliably. const resolvedReadOnlyDirectories = await resolveReadOnlyDirectories(readOnlyDirectories); // With no directories and a 2025-era client without Roots, the server // closes the connection after initialize (#4992); exit non-zero so the // failure shows. const handle = serveStdio(({ era }) => createServer(allowedDirectories, { era, readOnly, confineRoots, readOnlyDirectories: resolvedReadOnlyDirectories, onCannotOperate: () => process.exit(1), }), { transport: new StdioServerTransport(stdin, stdout), // serveStdio reports a transport that fails, including one that cannot // start, here rather than by rejecting, so record the failure in the // exit status for whatever supervises the process. onerror: (error) => { console.error("Transport error:", error); process.exitCode = 1; }, }); console.error("Secure MCP Filesystem Server running on stdio"); if (readOnly) { console.error("Read-only mode: only tools that cannot change the filesystem are served"); } if (resolvedReadOnlyDirectories.length > 0) { console.error(describeReadOnlyDirectories(resolvedReadOnlyDirectories)); } if (confineRoots && allowedDirectories.length === 0) { console.error("Warning: --confine-roots has no effect without command-line directories; a client's Roots will set the allowed directories"); } if (allowedDirectories.length === 0) { console.error("Started without allowed directories - waiting for a 2025-era client to provide roots via MCP protocol"); } else { // The directories in force until a 2025-era client's Roots replace them; // on 2026-07-28 they are final (#4912). console.error(describeAllowedDirectories(allowedDirectories, { from: "command-line" })); } return handle; } /** * True when `entryPath` (process.argv[1]) is this module. Both sides are * realpath'd, because npm installs the bin as a symlink to dist/index.js. */ export function isEntryPoint(entryPath, moduleUrl) { if (!entryPath) return false; try { return realpathSync(entryPath) === realpathSync(fileURLToPath(moduleUrl)); } catch { return false; } } /* v8 ignore start -- runs only when node executes this file as the bin, never under vitest; the spawn smoke in __tests__/bin-smoke.test.ts covers it */ if (isEntryPoint(process.argv[1], import.meta.url)) { main().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); }); } /* v8 ignore stop */