diff --git a/src/filesystem/__tests__/startup-validation.test.ts b/src/filesystem/__tests__/startup-validation.test.ts index 3be283df74..c9cfa4928a 100644 --- a/src/filesystem/__tests__/startup-validation.test.ts +++ b/src/filesystem/__tests__/startup-validation.test.ts @@ -36,6 +36,60 @@ async function spawnServer(args: string[], timeoutMs = 2000): Promise<{ exitCode }); } +/** + * Spawns the filesystem server and performs a minimal MCP handshake advertising roots + * capability, then sends a roots/list_changed notification with an unrelated directory. + * Returns collected stderr for assertion. + */ +async function spawnServerWithRootsHandshake( + args: string[], + unrelatedRootUri: string, + timeoutMs = 2500, +): Promise<{ stderr: string }> { + return new Promise((resolve) => { + const proc = spawn('node', [SERVER_PATH, ...args], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stderr = ''; + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + proc.stdout?.on('data', () => { /* drain JSON-RPC responses */ }); + + const initialize = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: 'test-client', version: '0.0.0' }, + }, + }; + proc.stdin?.write(JSON.stringify(initialize) + '\n'); + proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + + setTimeout(() => { + proc.stdin?.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/roots/list_changed', + params: { roots: [{ uri: unrelatedRootUri }] }, + }) + '\n'); + }, 300); + + const timeout = setTimeout(() => proc.kill('SIGTERM'), timeoutMs); + proc.on('close', () => { + clearTimeout(timeout); + resolve({ stderr }); + }); + proc.on('error', (err) => { + clearTimeout(timeout); + resolve({ stderr: err.message }); + }); + }); +} + describe('Startup Directory Validation', () => { let testDir: string; let accessibleDir: string; @@ -97,4 +151,21 @@ describe('Startup Directory Validation', () => { // Should still start with the valid directory expect(result.stderr).toContain('Secure MCP Filesystem Server running on stdio'); }); + + it('should keep CLI directories and ignore client MCP roots', async () => { + const unrelatedDir = path.join(testDir, 'unrelated'); + await fs.mkdir(unrelatedDir, { recursive: true }); + + const { stderr } = await spawnServerWithRootsHandshake( + [accessibleDir], + `file://${unrelatedDir}`, + ); + + // New log path should fire instead of the "does not support" one + expect(stderr).toContain('CLI directories provided - ignoring client MCP roots'); + expect(stderr).toContain(accessibleDir); + expect(stderr).not.toContain('Client does not support MCP Roots'); + // Allowed dirs must stay on the CLI-provided set, never pick up the client root + expect(stderr).not.toContain(unrelatedDir); + }); }); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..67e08b0b5b 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -30,6 +30,7 @@ import { // Command line argument parsing const args = process.argv.slice(2); +const cliDirectoriesProvided = args.length > 0; if (args.length === 0) { console.error("Usage: mcp-server-filesystem [allowed-directory] [additional-directories...]"); console.error("Note: Allowed directories can be provided via:"); @@ -716,8 +717,10 @@ async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { // 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.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { + if (cliDirectoriesProvided) { + return; + } try { - // Request the updated roots list from the client const response = await server.server.listRoots(); if (response && 'roots' in response) { await updateAllowedDirectoriesFromRoots(response.roots); @@ -731,7 +734,7 @@ server.server.setNotificationHandler(RootsListChangedNotificationSchema, async ( server.server.oninitialized = async () => { const clientCapabilities = server.server.getClientCapabilities(); - if (clientCapabilities?.roots) { + if (clientCapabilities?.roots && !cliDirectoriesProvided) { try { const response = await server.server.listRoots(); if (response && 'roots' in response) { @@ -743,9 +746,11 @@ server.server.oninitialized = async () => { console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error)); } } else { - if (allowedDirectories.length > 0) { + if (cliDirectoriesProvided) { + console.error("CLI directories provided - ignoring client MCP roots:", allowedDirectories); + } else if (allowedDirectories.length > 0) { console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories); - }else{ + } 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.`); } }