From 2c922a93f9ce7c00f36d69140597656bd33e2a1a Mon Sep 17 00:00:00 2001 From: Nandha Reddy Date: Wed, 2 Jul 2025 13:35:34 +1000 Subject: [PATCH] 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 --- src/filesystem/index.ts | 13 ++++++---- src/filesystem/roots-utils.ts | 49 +++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 8dfca07c..524c9c26 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -43,7 +43,7 @@ function expandHome(filepath: string): string { } // Store allowed directories in normalized and resolved form -const allowedDirectories = await Promise.all( +let allowedDirectories = await Promise.all( args.map(async (dir) => { const expanded = expandHome(dir); const absolute = path.resolve(expanded); @@ -897,10 +897,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }); // 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); +async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { + const validatedRootDirs = await getValidRootDirectories(requestedRoots); + if (validatedRootDirs.length > 0) { + allowedDirectories = [...validatedRootDirs]; + console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`); + } else { + console.error("No valid root directories provided by client"); } } diff --git a/src/filesystem/roots-utils.ts b/src/filesystem/roots-utils.ts index ae23be98..87329977 100644 --- a/src/filesystem/roots-utils.ts +++ b/src/filesystem/roots-utils.ts @@ -1,16 +1,26 @@ import { promises as fs, type Stats } from 'fs'; import path from 'path'; +import os from 'os'; 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 + * Converts a root URI to a normalized directory path with basic security validation. + * @param rootUri - File URI (file://...) or plain directory path + * @returns Promise resolving to validated path or null if invalid */ -function parseRootUri(uri: string): string { - const rawPath = uri.startsWith('file://') ? uri.slice(7) : uri; - return normalizePath(path.resolve(rawPath)); +async function parseRootUri(rootUri: string): Promise { + try { + const rawPath = rootUri.startsWith('file://') ? rootUri.slice(7) : rootUri; + const expandedPath = rawPath.startsWith('~/') || rawPath === '~' + ? path.join(os.homedir(), rawPath.slice(1)) + : rawPath; + const absolutePath = path.resolve(expandedPath); + const resolvedPath = await fs.realpath(absolutePath); + return normalizePath(resolvedPath); + } catch { + return null; // Path doesn't exist or other error + } } /** @@ -29,33 +39,38 @@ function formatDirectoryError(dir: string, error?: unknown, reason?: string): st } /** - * Gets valid directory paths from MCP root specifications. + * Resolves requested root directories 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. + * Includes symlink resolution for security. * - * @param roots - Array of root specifications with URI and optional name + * @param requestedRoots - 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[] + requestedRoots: readonly Root[] ): Promise { - const validDirectories: string[] = []; + const validatedDirectories: string[] = []; - for (const root of roots) { - const dir = parseRootUri(root.uri); + for (const requestedRoot of requestedRoots) { + const resolvedPath = await parseRootUri(requestedRoot.uri); + if (!resolvedPath) { + console.error(formatDirectoryError(requestedRoot.uri, undefined, 'invalid path or inaccessible')); + continue; + } try { - const stats: Stats = await fs.stat(dir); + const stats: Stats = await fs.stat(resolvedPath); if (stats.isDirectory()) { - validDirectories.push(dir); + validatedDirectories.push(resolvedPath); } else { - console.error(formatDirectoryError(dir, undefined, 'non-directory root')); + console.error(formatDirectoryError(resolvedPath, undefined, 'non-directory root')); } } catch (error) { - console.error(formatDirectoryError(dir, error)); + console.error(formatDirectoryError(resolvedPath, error)); } } - return validDirectories; + return validatedDirectories; } \ No newline at end of file