diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index f7e585af22..c54b8c9ade 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -205,6 +205,36 @@ describe('Lib Functions', () => { .rejects.toThrow('Parent directory does not exist'); }); + it('validates paths with literal tilde in directory names', async () => { + // Paths containing ~ as a literal character in directory names should work + // This is the scenario from issue #3412 + if (process.platform === 'win32') { + setAllowedDirectories(['C:\\Users\\test']); + const testPath = 'C:\\Users\\test\\~MyFolder\\file.txt'; + mockFs.realpath.mockImplementation(async (path: any) => path.toString()); + const result = await validatePath(testPath); + expect(result).toBe(testPath); + } else { + setAllowedDirectories(['/home/user']); + const testPath = '/home/user/~MyFolder/file.txt'; + mockFs.realpath.mockImplementation(async (path: any) => path.toString()); + const result = await validatePath(testPath); + expect(result).toBe(testPath); + } + }); + + it('validates paths with tilde used for home expansion combined with tilde in dir name', async () => { + // ~/path/~folder should expand ~ to home, preserve ~folder as literal + const homedir = os.homedir(); + if (process.platform !== 'win32') { + setAllowedDirectories([homedir]); + const expectedPath = path.join(homedir, '~MyFolder', 'file.txt'); + mockFs.realpath.mockImplementation(async (path: any) => path.toString()); + const result = await validatePath('~/~MyFolder/file.txt'); + expect(result).toBe(expectedPath); + } + }); + it('resolves relative paths against allowed directories instead of process.cwd()', async () => { const relativePath = 'test-file.txt'; const originalCwd = process.cwd; diff --git a/src/filesystem/__tests__/path-utils.test.ts b/src/filesystem/__tests__/path-utils.test.ts index 5530cba1c3..11f038c4bf 100644 --- a/src/filesystem/__tests__/path-utils.test.ts +++ b/src/filesystem/__tests__/path-utils.test.ts @@ -225,6 +225,31 @@ describe('Path Utilities', () => { it('leaves other paths unchanged', () => { expect(expandHome('C:/test')).toBe('C:/test'); }); + + it('preserves literal tilde in directory names', () => { + // Tilde as part of a directory name should NOT be expanded + expect(expandHome('/Volumes/Drive/Projects/~MyFolder')) + .toBe('/Volumes/Drive/Projects/~MyFolder'); + expect(expandHome('/home/user/~archive')) + .toBe('/home/user/~archive'); + expect(expandHome('/tmp/~backup/files')) + .toBe('/tmp/~backup/files'); + }); + + it('does not expand ~username patterns (treated as literal)', () => { + // ~SomeName without a slash could be a literal directory name + // We intentionally do NOT try to resolve ~username to avoid + // misinterpreting literal directory names starting with ~ + const result = expandHome('~MyFolder'); + expect(result).toBe('~MyFolder'); + }); + + it('handles Windows short names with tilde', () => { + expect(expandHome('C:\\PROGRA~1\\MyApp')) + .toBe('C:\\PROGRA~1\\MyApp'); + expect(expandHome('/Users/NEMANS~1/FOLDER~2')) + .toBe('/Users/NEMANS~1/FOLDER~2'); + }); }); describe('WSL path handling (issue #2795 fix)', () => { diff --git a/src/filesystem/__tests__/roots-utils.test.ts b/src/filesystem/__tests__/roots-utils.test.ts index 1a39483953..8d260416c7 100644 --- a/src/filesystem/__tests__/roots-utils.test.ts +++ b/src/filesystem/__tests__/roots-utils.test.ts @@ -60,6 +60,69 @@ describe('getValidRootDirectories', () => { }); }); + describe('tilde path handling', () => { + let tildeDirParent: string; + let tildeDir: string; + + beforeEach(() => { + // Create a directory with a literal tilde in its name + tildeDirParent = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-tilde-test-'))); + tildeDir = join(tildeDirParent, '~MyFolder'); + mkdirSync(tildeDir); + }); + + afterEach(() => { + rmSync(tildeDirParent, { recursive: true, force: true }); + }); + + it('should handle directories with literal tilde in name via file URI', async () => { + const roots: Root[] = [ + { uri: `file://${tildeDir}`, name: 'Tilde Dir' } + ]; + + const result = await getValidRootDirectories(roots); + expect(result).toHaveLength(1); + expect(result[0]).toBe(tildeDir); + }); + + it('should handle directories with literal tilde in name via plain path', async () => { + const roots: Root[] = [ + { uri: tildeDir, name: 'Tilde Dir' } + ]; + + const result = await getValidRootDirectories(roots); + expect(result).toHaveLength(1); + expect(result[0]).toBe(tildeDir); + }); + + it('should handle file URI with tilde in authority position gracefully', async () => { + // file://~/path is malformed (~ becomes the host), should not crash + const roots: Root[] = [ + { uri: `file://~/some/path`, name: 'Bad URI' }, + { uri: `file://${tildeDir}`, name: 'Good URI' } + ]; + + const result = await getValidRootDirectories(roots); + // The malformed URI should be skipped, the valid one should work + expect(result).toContain(tildeDir); + }); + + it('should handle multiple directories with tildes', async () => { + const tildeDir2 = join(tildeDirParent, '~AnotherFolder'); + mkdirSync(tildeDir2); + + const roots: Root[] = [ + { uri: `file://${tildeDir}`, name: 'Tilde Dir 1' }, + { uri: `file://${tildeDir2}`, name: 'Tilde Dir 2' } + ]; + + const result = await getValidRootDirectories(roots); + expect(result).toHaveLength(2); + expect(result).toContain(tildeDir); + expect(result).toContain(tildeDir2); + }); + }); + describe('error handling', () => { it('should handle various error types', async () => { diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..284924669d 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -736,6 +736,14 @@ server.server.oninitialized = async () => { const response = await server.server.listRoots(); if (response && 'roots' in response) { await updateAllowedDirectoriesFromRoots(response.roots); + if (allowedDirectories.length === 0 && response.roots.length > 0) { + console.error( + `Warning: Client provided ${response.roots.length} root(s), but none could be validated. ` + + `Root URIs: ${response.roots.map(r => r.uri).join(', ')}. ` + + `This may happen if the directories do not exist or contain special characters that were not resolved correctly. ` + + `Check that the configured paths are accessible and properly formatted.` + ); + } } else { console.error("Client returned no roots set, keeping current settings"); } @@ -745,8 +753,10 @@ server.server.oninitialized = async () => { } else { if (allowedDirectories.length > 0) { console.error("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 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.`); + } else { + const errorMsg = `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.`; + console.error(errorMsg); + process.exit(1); } } }; diff --git a/src/filesystem/path-utils.ts b/src/filesystem/path-utils.ts index 50910b995b..553d2fc162 100644 --- a/src/filesystem/path-utils.ts +++ b/src/filesystem/path-utils.ts @@ -106,12 +106,19 @@ export function normalizePath(p: string): string { } /** - * Expands home directory tildes in paths + * Expands home directory tildes in paths. + * + * Handles: + * - `~` or `~/path` → current user's home directory + * - Paths with literal `~` in non-leading position (e.g. `/path/~folder`) → unchanged + * - Paths starting with `~` but not followed by `/` (e.g. `~MyFolder`) → unchanged + * (treated as literal directory name, not home directory expansion) + * * @param filepath The path to expand * @returns Expanded path */ export function expandHome(filepath: string): string { - if (filepath.startsWith('~/') || filepath === '~') { + if (filepath === '~' || filepath.startsWith('~/')) { return path.join(os.homedir(), filepath.slice(1)); } return filepath; diff --git a/src/filesystem/roots-utils.ts b/src/filesystem/roots-utils.ts index 5e26bb246b..9bda4c5574 100644 --- a/src/filesystem/roots-utils.ts +++ b/src/filesystem/roots-utils.ts @@ -1,26 +1,70 @@ import { promises as fs, type Stats } from 'fs'; import path from 'path'; -import os from 'os'; -import { normalizePath } from './path-utils.js'; +import { normalizePath, expandHome } from './path-utils.js'; import type { Root } from '@modelcontextprotocol/sdk/types.js'; import { fileURLToPath } from "url"; +/** + * Safely converts a file:// URI to a filesystem path. + * Handles edge cases where tilde (~) or other characters in the URI + * are misinterpreted as the URI authority/host component. + * + * @param uri - The file:// URI to convert + * @returns The filesystem path, or null if the URI is invalid + */ +function safeFileURLToPath(uri: string): string | null { + try { + return fileURLToPath(uri); + } catch { + // fileURLToPath can throw when the URI has an unexpected host component. + // This happens when a path like "~/folder" is naively concatenated as + // "file://~/folder" — the URL parser treats "~" as the hostname. + // Try to recover by extracting the path after "file://" and normalizing it. + try { + const withoutScheme = uri.slice('file://'.length); + // If the path starts with / it's absolute (file:///path or file:///~/path) + if (withoutScheme.startsWith('/')) { + return decodeURIComponent(withoutScheme); + } + // Otherwise treat the whole part after file:// as a path + // (e.g., file://~/folder -> ~/folder) + return decodeURIComponent(withoutScheme); + } catch { + return null; + } + } +} + /** * Converts a root URI to a normalized directory path with basic security validation. + * Handles file:// URIs, plain paths, and paths containing tilde (~) characters + * both as home directory shorthand and as literal characters in directory names. + * * @param rootUri - File URI (file://...) or plain directory path * @returns Promise resolving to validated path or null if invalid */ async function parseRootUri(rootUri: string): Promise { try { - const rawPath = rootUri.startsWith('file://') ? fileURLToPath(rootUri) : rootUri; - const expandedPath = rawPath.startsWith('~/') || rawPath === '~' - ? path.join(os.homedir(), rawPath.slice(1)) - : rawPath; + let rawPath: string; + if (rootUri.startsWith('file://')) { + const parsed = safeFileURLToPath(rootUri); + if (parsed === null) { + console.error(`Warning: Could not parse file URI: ${rootUri}`); + return null; + } + rawPath = parsed; + } else { + rawPath = rootUri; + } + + const expandedPath = expandHome(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 + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Warning: Could not resolve root path "${rootUri}": ${message}`); + return null; } }