diff --git a/src/filesystem/README.md b/src/filesystem/README.md index d1621d1ef3..cd6d0a9f06 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -9,8 +9,58 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - Move files/directories - Search files - Get file metadata +- Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/concepts/roots) + +## Directory Access Control + +The server uses a flexible directory access control system. Directories can be specified via command-line arguments or dynamically via [Roots](https://modelcontextprotocol.io/docs/concepts/roots). + +### Method 1: Command-line Arguments +Specify Allowed directories when starting the server: +```bash +mcp-server-filesystem /path/to/dir1 /path/to/dir2 +``` + +### Method 2: MCP Roots (Recommended) +MCP clients that support [Roots](https://modelcontextprotocol.io/docs/concepts/roots) can dynamically update the Allowed directories. + +Roots notified by Client to Server, completely replace any server-side Allowed directories when provided. + +**Important**: If server starts without command-line arguments AND client doesn't support roots protocol (or provides empty roots), the server will throw an error during initialization. + +This is the recommended method, as this enables runtime directory updates via `roots/list_changed` notifications without server restart, providing a more flexible and modern integration experience. + +### How It Works + +The server's directory access control follows this flow: + +1. **Server Startup** + - Server starts with directories from command-line arguments (if provided) + - If no arguments provided, server starts with empty allowed directories + +2. **Client Connection & Initialization** + - Client connects and sends `initialize` request with capabilities + - Server checks if client supports roots protocol (`capabilities.roots`) + +3. **Roots Protocol Handling** (if client supports roots) + - **On initialization**: Server requests roots from client via `roots/list` + - Client responds with its configured roots + - Server replaces ALL allowed directories with client's roots + - **On runtime updates**: Client can send `notifications/roots/list_changed` + - Server requests updated roots and replaces allowed directories again + +4. **Fallback Behavior** (if client doesn't support roots) + - Server continues using command-line directories only + - No dynamic updates possible + +5. **Access Control** + - All filesystem operations are restricted to allowed directories + - Use `list_allowed_directories` tool to see current directories + - Server requires at least ONE allowed directory to operate + +**Note**: The server will only allow operations within directories specified either via `args` or via Roots. + -**Note**: The server will only allow operations within directories specified via `args`. ## API diff --git a/src/filesystem/__tests__/roots-utils.test.ts b/src/filesystem/__tests__/roots-utils.test.ts new file mode 100644 index 0000000000..758219588c --- /dev/null +++ b/src/filesystem/__tests__/roots-utils.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { getValidRootDirectories } from '../roots-utils.js'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import type { Root } from '@modelcontextprotocol/sdk/types.js'; + +describe('getValidRootDirectories', () => { + let testDir1: string; + let testDir2: string; + let testDir3: string; + let testFile: string; + + beforeEach(() => { + // Create test directories + testDir1 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test1-'))); + testDir2 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test2-'))); + testDir3 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test3-'))); + + // Create a test file (not a directory) + testFile = join(testDir1, 'test-file.txt'); + writeFileSync(testFile, 'test content'); + }); + + afterEach(() => { + // Cleanup + rmSync(testDir1, { recursive: true, force: true }); + rmSync(testDir2, { recursive: true, force: true }); + rmSync(testDir3, { recursive: true, force: true }); + }); + + describe('valid directory processing', () => { + it('should process all URI formats and edge cases', async () => { + const roots = [ + { uri: `file://${testDir1}`, name: 'File URI' }, + { uri: testDir2, name: 'Plain path' }, + { uri: testDir3 } // Plain path without name property + ]; + + const result = await getValidRootDirectories(roots); + + expect(result).toContain(testDir1); + expect(result).toContain(testDir2); + expect(result).toContain(testDir3); + expect(result).toHaveLength(3); + }); + + it('should normalize complex paths', async () => { + const subDir = join(testDir1, 'subdir'); + mkdirSync(subDir); + + const roots = [ + { uri: `file://${testDir1}/./subdir/../subdir`, name: 'Complex Path' } + ]; + + const result = await getValidRootDirectories(roots); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(subDir); + }); + }); + + describe('error handling', () => { + + it('should handle various error types', async () => { + const nonExistentDir = join(tmpdir(), 'non-existent-directory-12345'); + const invalidPath = '\0invalid\0path'; // Null bytes cause different error types + const roots = [ + { uri: `file://${testDir1}`, name: 'Valid Dir' }, + { uri: `file://${nonExistentDir}`, name: 'Non-existent Dir' }, + { uri: `file://${testFile}`, name: 'File Not Dir' }, + { uri: `file://${invalidPath}`, name: 'Invalid Path' } + ]; + + const result = await getValidRootDirectories(roots); + + expect(result).toContain(testDir1); + expect(result).not.toContain(nonExistentDir); + expect(result).not.toContain(testFile); + expect(result).not.toContain(invalidPath); + expect(result).toHaveLength(1); + }); + }); +}); \ No newline at end of file diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index fc32477b52..524c9c2608 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -6,6 +6,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, + RootsListChangedNotificationSchema, + type Root, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import path from "path"; @@ -16,12 +18,16 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import { diffLines, createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; import { isPathWithinAllowedDirectories } from './path-validation.js'; +import { getValidRootDirectories } from './roots-utils.js'; // Command line argument parsing const args = process.argv.slice(2); if (args.length === 0) { - console.error("Usage: mcp-server-filesystem [additional-directories...]"); - process.exit(1); + console.error("Usage: mcp-server-filesystem [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 (if client supports it)"); + console.error("At least one directory must be provided by EITHER method for the server to operate."); } // Normalize all paths consistently @@ -37,7 +43,7 @@ function expandHome(filepath: string): string { } // Store allowed directories in normalized and resolved form -const allowedDirectories = await Promise.all( +let allowedDirectories = await Promise.all( args.map(async (dir) => { const expanded = expandHome(dir); const absolute = path.resolve(expanded); @@ -573,8 +579,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { 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 access files.", + "Returns the list of root directories that this server is allowed to access. " + + "Use this to understand which directories are available before trying to access files. ", inputSchema: { type: "object", properties: {}, @@ -890,12 +896,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } }); +// Updates allowed directories based on MCP client roots +async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { + const validatedRootDirs = await getValidRootDirectories(requestedRoots); + if (validatedRootDirs.length > 0) { + allowedDirectories = [...validatedRootDirs]; + console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`); + } else { + console.error("No valid root directories provided by client"); + } +} + +// Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots. +server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { + try { + // Request the updated roots list from the client + const response = await server.listRoots(); + if (response && 'roots' in response) { + await updateAllowedDirectoriesFromRoots(response.roots); + } + } catch (error) { + console.error("Failed to request roots from client:", error instanceof Error ? error.message : String(error)); + } +}); + +// Handles post-initialization setup, specifically checking for and fetching MCP roots. +server.oninitialized = async () => { + const clientCapabilities = server.getClientCapabilities(); + + if (clientCapabilities?.roots) { + try { + const response = await server.listRoots(); + if (response && 'roots' in response) { + await updateAllowedDirectoriesFromRoots(response.roots); + } else { + console.error("Client returned no roots set, keeping current settings"); + } + } catch (error) { + console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error)); + } + } else { + if (allowedDirectories.length > 0) { + console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories); + }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.`); + } + } +}; + // Start server async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Secure MCP Filesystem Server running on stdio"); - console.error("Allowed directories:", allowedDirectories); + if (allowedDirectories.length === 0) { + console.error("Started without allowed directories - waiting for client to provide roots via MCP protocol"); + } } runServer().catch((error) => { diff --git a/src/filesystem/roots-utils.ts b/src/filesystem/roots-utils.ts new file mode 100644 index 0000000000..8732997757 --- /dev/null +++ b/src/filesystem/roots-utils.ts @@ -0,0 +1,76 @@ +import { promises as fs, type Stats } from 'fs'; +import path from 'path'; +import os from 'os'; +import { normalizePath } from './path-utils.js'; +import type { Root } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Converts a root URI to a normalized directory path with basic security validation. + * @param rootUri - File URI (file://...) or plain directory path + * @returns Promise resolving to validated path or null if invalid + */ +async function parseRootUri(rootUri: string): Promise { + try { + const rawPath = rootUri.startsWith('file://') ? rootUri.slice(7) : rootUri; + const expandedPath = rawPath.startsWith('~/') || rawPath === '~' + ? path.join(os.homedir(), rawPath.slice(1)) + : rawPath; + const absolutePath = path.resolve(expandedPath); + const resolvedPath = await fs.realpath(absolutePath); + return normalizePath(resolvedPath); + } catch { + return null; // Path doesn't exist or other error + } +} + +/** + * Formats error message for directory validation failures. + * @param dir - Directory path that failed validation + * @param error - Error that occurred during validation + * @param reason - Specific reason for failure + * @returns Formatted error message + */ +function formatDirectoryError(dir: string, error?: unknown, reason?: string): string { + if (reason) { + return `Skipping ${reason}: ${dir}`; + } + const message = error instanceof Error ? error.message : String(error); + return `Skipping invalid directory: ${dir} due to error: ${message}`; +} + +/** + * Resolves requested root directories from MCP root specifications. + * + * Converts root URI specifications (file:// URIs or plain paths) into normalized + * directory paths, validating that each path exists and is a directory. + * Includes symlink resolution for security. + * + * @param requestedRoots - Array of root specifications with URI and optional name + * @returns Promise resolving to array of validated directory paths + */ +export async function getValidRootDirectories( + requestedRoots: readonly Root[] +): Promise { + const validatedDirectories: string[] = []; + + for (const requestedRoot of requestedRoots) { + const resolvedPath = await parseRootUri(requestedRoot.uri); + if (!resolvedPath) { + console.error(formatDirectoryError(requestedRoot.uri, undefined, 'invalid path or inaccessible')); + continue; + } + + try { + const stats: Stats = await fs.stat(resolvedPath); + if (stats.isDirectory()) { + validatedDirectories.push(resolvedPath); + } else { + console.error(formatDirectoryError(resolvedPath, undefined, 'non-directory root')); + } + } catch (error) { + console.error(formatDirectoryError(resolvedPath, error)); + } + } + + return validatedDirectories; +} \ No newline at end of file