feat(filesystem): implement MCP roots protocol for dynamic directory management

- Extract roots processing logic from index.ts into testable roots-utils.ts module and add Test suite
- Update README to recommend MCP roots protocol for dynamic directory management
This commit is contained in:
Nandha Reddy
2025-06-25 01:13:07 +10:00
parent b37da40003
commit f8dd74576b
4 changed files with 167 additions and 28 deletions

View File

@@ -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

View File

@@ -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);
});
});
});

View File

@@ -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");
}
}

View File

@@ -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<string[]> {
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;
}