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
This commit is contained in:
Nandha Reddy 2025-07-02 13:35:34 +10:00
commit 2c922a93f9
2 changed files with 40 additions and 22 deletions

View file

@ -43,7 +43,7 @@ function expandHome(filepath: string): string {
} }
// Store allowed directories in normalized and resolved form // Store allowed directories in normalized and resolved form
const allowedDirectories = await Promise.all( let allowedDirectories = await Promise.all(
args.map(async (dir) => { args.map(async (dir) => {
const expanded = expandHome(dir); const expanded = expandHome(dir);
const absolute = path.resolve(expanded); const absolute = path.resolve(expanded);
@ -897,10 +897,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}); });
// Updates allowed directories based on MCP client roots // Updates allowed directories based on MCP client roots
async function updateAllowedDirectoriesFromRoots(roots: Root[]) { async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
const rootDirs = await getValidRootDirectories(roots); const validatedRootDirs = await getValidRootDirectories(requestedRoots);
if (rootDirs.length > 0) { if (validatedRootDirs.length > 0) {
allowedDirectories.splice(0, allowedDirectories.length, ...rootDirs); allowedDirectories = [...validatedRootDirs];
console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`);
} else {
console.error("No valid root directories provided by client");
} }
} }

View file

@ -1,16 +1,26 @@
import { promises as fs, type Stats } from 'fs'; import { promises as fs, type Stats } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os';
import { normalizePath } from './path-utils.js'; import { normalizePath } from './path-utils.js';
import type { Root } from '@modelcontextprotocol/sdk/types.js'; import type { Root } from '@modelcontextprotocol/sdk/types.js';
/** /**
* Converts a root URI to a normalized directory path. * Converts a root URI to a normalized directory path with basic security validation.
* @param uri - File URI (file://...) or plain directory path * @param rootUri - File URI (file://...) or plain directory path
* @returns Normalized absolute directory path * @returns Promise resolving to validated path or null if invalid
*/ */
function parseRootUri(uri: string): string { async function parseRootUri(rootUri: string): Promise<string | null> {
const rawPath = uri.startsWith('file://') ? uri.slice(7) : uri; try {
return normalizePath(path.resolve(rawPath)); 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 * Converts root URI specifications (file:// URIs or plain paths) into normalized
* directory paths, validating that each path exists and is a directory. * 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 * @returns Promise resolving to array of validated directory paths
*/ */
export async function getValidRootDirectories( export async function getValidRootDirectories(
roots: readonly Root[] requestedRoots: readonly Root[]
): Promise<string[]> { ): Promise<string[]> {
const validDirectories: string[] = []; const validatedDirectories: string[] = [];
for (const root of roots) { for (const requestedRoot of requestedRoots) {
const dir = parseRootUri(root.uri); const resolvedPath = await parseRootUri(requestedRoot.uri);
if (!resolvedPath) {
console.error(formatDirectoryError(requestedRoot.uri, undefined, 'invalid path or inaccessible'));
continue;
}
try { try {
const stats: Stats = await fs.stat(dir); const stats: Stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
validDirectories.push(dir); validatedDirectories.push(resolvedPath);
} else { } else {
console.error(formatDirectoryError(dir, undefined, 'non-directory root')); console.error(formatDirectoryError(resolvedPath, undefined, 'non-directory root'));
} }
} catch (error) { } catch (error) {
console.error(formatDirectoryError(dir, error)); console.error(formatDirectoryError(resolvedPath, error));
} }
} }
return validDirectories; return validatedDirectories;
} }