Skip to content

Commit 1264dba

Browse files
committed
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
1 parent 2d2936f commit 1264dba

File tree

4 files changed

+167
-28
lines changed

4 files changed

+167
-28
lines changed

src/filesystem/README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
1616
The server uses a flexible directory access control system. Directories can be specified via command-line arguments or dynamically via the MCP roots protocol.
1717

1818
### Method 1: Command-line Arguments
19-
Specify allowed directories when starting the server:
19+
Specify Allowed directories when starting the server:
2020
```bash
2121
mcp-server-filesystem /path/to/dir1 /path/to/dir2
2222
```
2323

24-
### Method 2: MCP Roots Protocol
25-
MCP clients that support the roots protocol can dynamically provide allowed directories. Client roots completely replace any command-line directories when provided.
24+
### Method 2: MCP Roots Protocol (Recommended)
25+
MCP clients that support the roots protocol can dynamically update the Allowed directories.
26+
27+
Roots notified by Client to Server, completely replace any server-side Allowed directories when provided.
28+
29+
**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.
30+
31+
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.
2632

2733
### How It Works
2834

@@ -52,7 +58,9 @@ The server's directory access control follows this flow:
5258
- Use `list_allowed_directories` tool to see current directories
5359
- Server requires at least ONE allowed directory to operate
5460

55-
**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.
61+
**Note**: The server will only allow operations within directories specified either via `args` or via Roots.
62+
63+
5664

5765
## API
5866

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2+
import { getValidRootDirectories } from '../roots-utils.js';
3+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from 'fs';
4+
import { tmpdir } from 'os';
5+
import { join } from 'path';
6+
import type { Root } from '@modelcontextprotocol/sdk/types.js';
7+
8+
describe('getValidRootDirectories', () => {
9+
let testDir1: string;
10+
let testDir2: string;
11+
let testDir3: string;
12+
let testFile: string;
13+
14+
beforeEach(() => {
15+
// Create test directories
16+
testDir1 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test1-')));
17+
testDir2 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test2-')));
18+
testDir3 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test3-')));
19+
20+
// Create a test file (not a directory)
21+
testFile = join(testDir1, 'test-file.txt');
22+
writeFileSync(testFile, 'test content');
23+
});
24+
25+
afterEach(() => {
26+
// Cleanup
27+
rmSync(testDir1, { recursive: true, force: true });
28+
rmSync(testDir2, { recursive: true, force: true });
29+
rmSync(testDir3, { recursive: true, force: true });
30+
});
31+
32+
describe('valid directory processing', () => {
33+
it('should process all URI formats and edge cases', async () => {
34+
const roots = [
35+
{ uri: `file://${testDir1}`, name: 'File URI' },
36+
{ uri: testDir2, name: 'Plain path' },
37+
{ uri: testDir3 } // Plain path without name property
38+
];
39+
40+
const result = await getValidRootDirectories(roots);
41+
42+
expect(result).toContain(testDir1);
43+
expect(result).toContain(testDir2);
44+
expect(result).toContain(testDir3);
45+
expect(result).toHaveLength(3);
46+
});
47+
48+
it('should normalize complex paths', async () => {
49+
const subDir = join(testDir1, 'subdir');
50+
mkdirSync(subDir);
51+
52+
const roots = [
53+
{ uri: `file://${testDir1}/./subdir/../subdir`, name: 'Complex Path' }
54+
];
55+
56+
const result = await getValidRootDirectories(roots);
57+
58+
expect(result).toHaveLength(1);
59+
expect(result[0]).toBe(subDir);
60+
});
61+
});
62+
63+
describe('error handling', () => {
64+
65+
it('should handle various error types', async () => {
66+
const nonExistentDir = join(tmpdir(), 'non-existent-directory-12345');
67+
const invalidPath = '\0invalid\0path'; // Null bytes cause different error types
68+
const roots = [
69+
{ uri: `file://${testDir1}`, name: 'Valid Dir' },
70+
{ uri: `file://${nonExistentDir}`, name: 'Non-existent Dir' },
71+
{ uri: `file://${testFile}`, name: 'File Not Dir' },
72+
{ uri: `file://${invalidPath}`, name: 'Invalid Path' }
73+
];
74+
75+
const result = await getValidRootDirectories(roots);
76+
77+
expect(result).toContain(testDir1);
78+
expect(result).not.toContain(nonExistentDir);
79+
expect(result).not.toContain(testFile);
80+
expect(result).not.toContain(invalidPath);
81+
expect(result).toHaveLength(1);
82+
});
83+
});
84+
});

src/filesystem/index.ts

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ListToolsRequestSchema,
88
ToolSchema,
99
RootsListChangedNotificationSchema,
10+
type Root,
1011
} from "@modelcontextprotocol/sdk/types.js";
1112
import fs from "fs/promises";
1213
import path from "path";
@@ -15,6 +16,7 @@ import { z } from "zod";
1516
import { zodToJsonSchema } from "zod-to-json-schema";
1617
import { diffLines, createTwoFilesPatch } from 'diff';
1718
import { minimatch } from 'minimatch';
19+
import { getValidRootDirectories } from './roots-utils.js';
1820

1921
// Command line argument parsing
2022
const args = process.argv.slice(2);
@@ -843,26 +845,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
843845
}
844846
});
845847

846-
// Replaces any existing allowed directories based on roots provided by the MCP client.
847-
async function updateAllowedDirectoriesFromRoots(roots: Array<{ uri: string; name?: string }>) {
848-
const rootDirs: string[] = [];
849-
for (const root of roots) {
850-
let dir: string;
851-
// Handle both file:// URIs (MCP standard) and plain directory paths (for flexibility)
852-
dir = normalizePath(path.resolve(root.uri.startsWith('file://')? root.uri.slice(7) : root.uri));
853-
try {
854-
const stats = await fs.stat(dir);
855-
if (stats.isDirectory()) {
856-
rootDirs.push(dir);
857-
}else {
858-
console.error(`Skipping non-directory root: ${dir}`);
859-
}
860-
} catch (error) {
861-
// Skip invalid directories
862-
console.error(`Skipping invalid directory: ${dir} due to error:`, error instanceof Error ? error.message : String(error));
863-
}
864-
}
865-
if(rootDirs.length > 0) {
848+
// Updates allowed directories based on MCP client roots
849+
async function updateAllowedDirectoriesFromRoots(roots: Root[]) {
850+
const rootDirs = await getValidRootDirectories(roots);
851+
if (rootDirs.length > 0) {
866852
allowedDirectories.splice(0, allowedDirectories.length, ...rootDirs);
867853
}
868854
}
@@ -890,16 +876,16 @@ server.oninitialized = async () => {
890876
if (response && 'roots' in response) {
891877
await updateAllowedDirectoriesFromRoots(response.roots);
892878
} else {
893-
console.error("Client returned no roots set, keeping current settings");
879+
console.log("Client returned no roots set, keeping current settings");
894880
}
895881
} catch (error) {
896882
console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error));
897883
}
898884
} else {
899885
if (allowedDirectories.length > 0) {
900-
console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories);
886+
console.log("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories);
901887
}else{
902-
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.`);
888+
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.`);
903889
}
904890
}
905891
};
@@ -910,7 +896,7 @@ async function runServer() {
910896
await server.connect(transport);
911897
console.error("Secure MCP Filesystem Server running on stdio");
912898
if (allowedDirectories.length === 0) {
913-
console.error("Started without allowed directories - waiting for client to provide roots via MCP protocol");
899+
console.log("Started without allowed directories - waiting for client to provide roots via MCP protocol");
914900
}
915901
}
916902

src/filesystem/roots-utils.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { promises as fs, type Stats } from 'fs';
2+
import path from 'path';
3+
import { normalizePath } from './path-utils.js';
4+
import type { Root } from '@modelcontextprotocol/sdk/types.js';
5+
6+
/**
7+
* Converts a root URI to a normalized directory path.
8+
* @param uri - File URI (file://...) or plain directory path
9+
* @returns Normalized absolute directory path
10+
*/
11+
function parseRootUri(uri: string): string {
12+
const rawPath = uri.startsWith('file://') ? uri.slice(7) : uri;
13+
return normalizePath(path.resolve(rawPath));
14+
}
15+
16+
/**
17+
* Formats error message for directory validation failures.
18+
* @param dir - Directory path that failed validation
19+
* @param error - Error that occurred during validation
20+
* @param reason - Specific reason for failure
21+
* @returns Formatted error message
22+
*/
23+
function formatDirectoryError(dir: string, error?: unknown, reason?: string): string {
24+
if (reason) {
25+
return `Skipping ${reason}: ${dir}`;
26+
}
27+
const message = error instanceof Error ? error.message : String(error);
28+
return `Skipping invalid directory: ${dir} due to error: ${message}`;
29+
}
30+
31+
/**
32+
* Gets valid directory paths from MCP root specifications.
33+
*
34+
* Converts root URI specifications (file:// URIs or plain paths) into normalized
35+
* directory paths, validating that each path exists and is a directory.
36+
*
37+
* @param roots - Array of root specifications with URI and optional name
38+
* @returns Promise resolving to array of validated directory paths
39+
*/
40+
export async function getValidRootDirectories(
41+
roots: readonly Root[]
42+
): Promise<string[]> {
43+
const validDirectories: string[] = [];
44+
45+
for (const root of roots) {
46+
const dir = parseRootUri(root.uri);
47+
48+
try {
49+
const stats: Stats = await fs.stat(dir);
50+
if (stats.isDirectory()) {
51+
validDirectories.push(dir);
52+
} else {
53+
console.error(formatDirectoryError(dir, undefined, 'non-directory root'));
54+
}
55+
} catch (error) {
56+
console.error(formatDirectoryError(dir, error));
57+
}
58+
}
59+
60+
return validDirectories;
61+
}

0 commit comments

Comments
 (0)