Skip to content

Commit 2c922a9

Browse files
committed
feat(filesystem): add symlink resolution and home directory support to roots protocol
- Add symlink resolution using fs.realpath() for security consistency - Support home directory expansion (~/) in root URI specifications - Improve error handling with null checks, detailed error messages, and informative logging - Change allowedDirectories from constant to variable to support roots protocol directory management
1 parent f3891aa commit 2c922a9

2 files changed

Lines changed: 40 additions & 22 deletions

File tree

src/filesystem/index.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function expandHome(filepath: string): string {
4343
}
4444

4545
// Store allowed directories in normalized and resolved form
46-
const allowedDirectories = await Promise.all(
46+
let allowedDirectories = await Promise.all(
4747
args.map(async (dir) => {
4848
const expanded = expandHome(dir);
4949
const absolute = path.resolve(expanded);
@@ -897,10 +897,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
897897
});
898898

899899
// Updates allowed directories based on MCP client roots
900-
async function updateAllowedDirectoriesFromRoots(roots: Root[]) {
901-
const rootDirs = await getValidRootDirectories(roots);
902-
if (rootDirs.length > 0) {
903-
allowedDirectories.splice(0, allowedDirectories.length, ...rootDirs);
900+
async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
901+
const validatedRootDirs = await getValidRootDirectories(requestedRoots);
902+
if (validatedRootDirs.length > 0) {
903+
allowedDirectories = [...validatedRootDirs];
904+
console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`);
905+
} else {
906+
console.error("No valid root directories provided by client");
904907
}
905908
}
906909

src/filesystem/roots-utils.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import { promises as fs, type Stats } from 'fs';
22
import path from 'path';
3+
import os from 'os';
34
import { normalizePath } from './path-utils.js';
45
import type { Root } from '@modelcontextprotocol/sdk/types.js';
56

67
/**
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
8+
* Converts a root URI to a normalized directory path with basic security validation.
9+
* @param rootUri - File URI (file://...) or plain directory path
10+
* @returns Promise resolving to validated path or null if invalid
1011
*/
11-
function parseRootUri(uri: string): string {
12-
const rawPath = uri.startsWith('file://') ? uri.slice(7) : uri;
13-
return normalizePath(path.resolve(rawPath));
12+
async function parseRootUri(rootUri: string): Promise<string | null> {
13+
try {
14+
const rawPath = rootUri.startsWith('file://') ? rootUri.slice(7) : rootUri;
15+
const expandedPath = rawPath.startsWith('~/') || rawPath === '~'
16+
? path.join(os.homedir(), rawPath.slice(1))
17+
: rawPath;
18+
const absolutePath = path.resolve(expandedPath);
19+
const resolvedPath = await fs.realpath(absolutePath);
20+
return normalizePath(resolvedPath);
21+
} catch {
22+
return null; // Path doesn't exist or other error
23+
}
1424
}
1525

1626
/**
@@ -29,33 +39,38 @@ function formatDirectoryError(dir: string, error?: unknown, reason?: string): st
2939
}
3040

3141
/**
32-
* Gets valid directory paths from MCP root specifications.
42+
* Resolves requested root directories from MCP root specifications.
3343
*
3444
* Converts root URI specifications (file:// URIs or plain paths) into normalized
3545
* directory paths, validating that each path exists and is a directory.
46+
* Includes symlink resolution for security.
3647
*
37-
* @param roots - Array of root specifications with URI and optional name
48+
* @param requestedRoots - Array of root specifications with URI and optional name
3849
* @returns Promise resolving to array of validated directory paths
3950
*/
4051
export async function getValidRootDirectories(
41-
roots: readonly Root[]
52+
requestedRoots: readonly Root[]
4253
): Promise<string[]> {
43-
const validDirectories: string[] = [];
54+
const validatedDirectories: string[] = [];
4455

45-
for (const root of roots) {
46-
const dir = parseRootUri(root.uri);
56+
for (const requestedRoot of requestedRoots) {
57+
const resolvedPath = await parseRootUri(requestedRoot.uri);
58+
if (!resolvedPath) {
59+
console.error(formatDirectoryError(requestedRoot.uri, undefined, 'invalid path or inaccessible'));
60+
continue;
61+
}
4762

4863
try {
49-
const stats: Stats = await fs.stat(dir);
64+
const stats: Stats = await fs.stat(resolvedPath);
5065
if (stats.isDirectory()) {
51-
validDirectories.push(dir);
66+
validatedDirectories.push(resolvedPath);
5267
} else {
53-
console.error(formatDirectoryError(dir, undefined, 'non-directory root'));
68+
console.error(formatDirectoryError(resolvedPath, undefined, 'non-directory root'));
5469
}
5570
} catch (error) {
56-
console.error(formatDirectoryError(dir, error));
71+
console.error(formatDirectoryError(resolvedPath, error));
5772
}
5873
}
5974

60-
return validDirectories;
75+
return validatedDirectories;
6176
}

0 commit comments

Comments
 (0)