diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 793ba159..292fac6d 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -16,13 +16,19 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio The server uses a flexible directory access control system. Directories can be specified via command-line arguments or dynamically via the MCP roots protocol. ### Method 1: Command-line Arguments -Specify allowed directories when starting the server: +Specify Allowed directories when starting the server: ```bash mcp-server-filesystem /path/to/dir1 /path/to/dir2 ``` -### Method 2: MCP Roots Protocol -MCP clients that support the roots protocol can dynamically provide allowed directories. Client roots completely replace any command-line directories when provided. +### Method 2: MCP Roots Protocol (Recommended) +MCP clients that support the roots protocol 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 MCP roots protocol for dynamic directory management. This enables runtime directory updates via `roots/list_changed` notifications without server restart, providing a more flexible and modern integration experience. ### How It Works @@ -52,7 +58,9 @@ The server's directory access control follows this flow: - Use `list_allowed_directories` tool to see current directories - Server requires at least ONE allowed directory to operate -**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. +**Note**: The server will only allow operations within directories specified either via `args` or via Roots. + + ## API diff --git a/src/filesystem/__tests__/roots-utils.test.ts b/src/filesystem/__tests__/roots-utils.test.ts new file mode 100644 index 00000000..75821958 --- /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 3886d40b..25be8497 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -7,6 +7,7 @@ import { ListToolsRequestSchema, ToolSchema, RootsListChangedNotificationSchema, + type Root, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import path from "path"; @@ -17,6 +18,7 @@ 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); @@ -894,26 +896,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } }); -// Replaces any existing allowed directories based on roots provided by the MCP client. -async function updateAllowedDirectoriesFromRoots(roots: Array<{ uri: string; name?: string }>) { - const rootDirs: string[] = []; - for (const root of roots) { - let dir: string; - // Handle both file:// URIs (MCP standard) and plain directory paths (for flexibility) - dir = normalizePath(path.resolve(root.uri.startsWith('file://')? root.uri.slice(7) : root.uri)); - try { - const stats = await fs.stat(dir); - if (stats.isDirectory()) { - rootDirs.push(dir); - }else { - console.error(`Skipping non-directory root: ${dir}`); - } - } catch (error) { - // Skip invalid directories - console.error(`Skipping invalid directory: ${dir} due to error:`, error instanceof Error ? error.message : String(error)); - } - } - if(rootDirs.length > 0) { +// Updates allowed directories based on MCP client roots +async function updateAllowedDirectoriesFromRoots(roots: Root[]) { + const rootDirs = await getValidRootDirectories(roots); + if (rootDirs.length > 0) { allowedDirectories.splice(0, allowedDirectories.length, ...rootDirs); } } @@ -941,16 +927,16 @@ server.oninitialized = async () => { if (response && 'roots' in response) { await updateAllowedDirectoriesFromRoots(response.roots); } else { - console.error("Client returned no roots set, keeping current settings"); + console.log("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); + console.log("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 does not support MCP roots protocol. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol.`); + 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.`); } } }; @@ -961,7 +947,7 @@ async function runServer() { await server.connect(transport); console.error("Secure MCP Filesystem Server running on stdio"); if (allowedDirectories.length === 0) { - console.error("Started without allowed directories - waiting for client to provide roots via MCP protocol"); + console.log("Started without allowed directories - waiting for client to provide roots via MCP protocol"); } } diff --git a/src/filesystem/roots-utils.ts b/src/filesystem/roots-utils.ts new file mode 100644 index 00000000..ae23be98 --- /dev/null +++ b/src/filesystem/roots-utils.ts @@ -0,0 +1,61 @@ +import { promises as fs, type Stats } from 'fs'; +import path from 'path'; +import { normalizePath } from './path-utils.js'; +import type { Root } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Converts a root URI to a normalized directory path. + * @param uri - File URI (file://...) or plain directory path + * @returns Normalized absolute directory path + */ +function parseRootUri(uri: string): string { + const rawPath = uri.startsWith('file://') ? uri.slice(7) : uri; + return normalizePath(path.resolve(rawPath)); +} + +/** + * 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}`; +} + +/** + * Gets valid directory paths 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. + * + * @param roots - Array of root specifications with URI and optional name + * @returns Promise resolving to array of validated directory paths + */ +export async function getValidRootDirectories( + roots: readonly Root[] +): Promise { + const validDirectories: string[] = []; + + for (const root of roots) { + const dir = parseRootUri(root.uri); + + try { + const stats: Stats = await fs.stat(dir); + if (stats.isDirectory()) { + validDirectories.push(dir); + } else { + console.error(formatDirectoryError(dir, undefined, 'non-directory root')); + } + } catch (error) { + console.error(formatDirectoryError(dir, error)); + } + } + + return validDirectories; +} \ No newline at end of file