Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions src/filesystem/__tests__/path-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
63 changes: 63 additions & 0 deletions src/filesystem/__tests__/roots-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
14 changes: 12 additions & 2 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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);
}
}
};
Expand Down
11 changes: 9 additions & 2 deletions src/filesystem/path-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 52 additions & 8 deletions src/filesystem/roots-utils.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +22 to +31
} 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<string | null> {
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;
Comment on lines +64 to +67
}
}

Expand Down
Loading