Merge branch 'main' into claude/issue-2526-20250824-0240

This commit is contained in:
Ola Hungerford
2026-02-03 19:59:23 -07:00
committed by GitHub
110 changed files with 11780 additions and 8014 deletions

View File

@@ -128,15 +128,6 @@ The server's directory access control follows this flow:
- Returns detailed listing with file sizes and summary statistics
- Shows total files, directories, and combined size
- **directory_tree**
- Get a recursive tree view of files and directories as a JSON structure
- Input: `path` (string): Starting directory path
- Returns JSON structure with:
- `name`: File/directory name
- `type`: "file" or "directory"
- `children`: Array of child entries (for directories only)
- Output is formatted with 2-space indentation for readability
- **move_file**
- Move or rename files and directories
- Inputs:
@@ -165,6 +156,7 @@ The server's directory access control follows this flow:
- `children` (array): Present only for directories
- Empty array for empty directories
- Omitted for files
- Output is formatted with 2-space indentation for readability
- **get_file_info**
- Get detailed file/directory metadata
@@ -183,6 +175,35 @@ The server's directory access control follows this flow:
- Returns:
- Directories that this server can read/write from
### Tool annotations (MCP hints)
This server sets [MCP ToolAnnotations](https://modelcontextprotocol.io/specification/2025-03-26/server/tools#toolannotations)
on each tool so clients can:
- Distinguish **readonly** tools from writecapable tools.
- Understand which write operations are **idempotent** (safe to retry with the same arguments).
- Highlight operations that may be **destructive** (overwriting or heavily mutating data).
The mapping for filesystem tools is:
| Tool | readOnlyHint | idempotentHint | destructiveHint | Notes |
|-----------------------------|--------------|----------------|-----------------|--------------------------------------------------|
| `read_text_file` | `true` | | | Pure read |
| `read_media_file` | `true` | | | Pure read |
| `read_multiple_files` | `true` | | | Pure read |
| `list_directory` | `true` | | | Pure read |
| `list_directory_with_sizes` | `true` | | | Pure read |
| `directory_tree` | `true` | | | Pure read |
| `search_files` | `true` | | | Pure read |
| `get_file_info` | `true` | | | Pure read |
| `list_allowed_directories` | `true` | | | Pure read |
| `create_directory` | `false` | `true` | `false` | Recreating the same dir is a noop |
| `write_file` | `false` | `true` | `true` | Overwrites existing files |
| `edit_file` | `false` | `false` | `true` | Reapplying edits can fail or doubleapply |
| `move_file` | `false` | `false` | `false` | Move/rename only; repeat usually errors |
> Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec.
## Usage with Claude Desktop
Add this to your `claude_desktop_config.json`:
@@ -245,7 +266,7 @@ Add the configuration to your user-level MCP configuration file. Open the Comman
**Method 2: Workspace Configuration**
Alternatively, you can add the configuration to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
> For more details about MCP configuration in VS Code, see the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/mcp).
> For more details about MCP configuration in VS Code, see the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers).
You can provide sandboxed directories to the server by mounting them to `/projects`. Adding the `ro` flag will make the directory readonly by the server.

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
@@ -23,19 +23,19 @@ import {
} from '../lib.js';
// Mock fs module
jest.mock('fs/promises');
const mockFs = fs as jest.Mocked<typeof fs>;
vi.mock('fs/promises');
const mockFs = fs as any;
describe('Lib Functions', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
// Set up allowed directories for tests
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp', 'C:\\allowed'] : ['/home/user', '/tmp', '/allowed'];
setAllowedDirectories(allowedDirs);
});
afterEach(() => {
jest.restoreAllMocks();
vi.restoreAllMocks();
// Clear allowed directories after tests
setAllowedDirectories([]);
});
@@ -591,8 +591,8 @@ describe('Lib Functions', () => {
// Mock file handle with proper typing
const mockFileHandle = {
read: jest.fn(),
close: jest.fn()
read: vi.fn(),
close: vi.fn()
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
@@ -610,8 +610,8 @@ describe('Lib Functions', () => {
mockFs.stat.mockResolvedValue({ size: 50 } as any);
const mockFileHandle = {
read: jest.fn(),
close: jest.fn()
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading file content in chunks
@@ -631,8 +631,8 @@ describe('Lib Functions', () => {
mockFs.stat.mockResolvedValue({ size: 100 } as any);
const mockFileHandle = {
read: jest.fn(),
close: jest.fn()
read: vi.fn(),
close: vi.fn()
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
@@ -650,8 +650,8 @@ describe('Lib Functions', () => {
it('opens file for reading', async () => {
// Mock file handle with proper typing
const mockFileHandle = {
read: jest.fn(),
close: jest.fn()
read: vi.fn(),
close: vi.fn()
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
@@ -666,8 +666,8 @@ describe('Lib Functions', () => {
it('handles files with content and returns first lines', async () => {
const mockFileHandle = {
read: jest.fn(),
close: jest.fn()
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading file content with newlines
@@ -685,8 +685,8 @@ describe('Lib Functions', () => {
it('handles files with leftover content', async () => {
const mockFileHandle = {
read: jest.fn(),
close: jest.fn()
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading file content without final newline
@@ -704,8 +704,8 @@ describe('Lib Functions', () => {
it('handles reaching requested line count', async () => {
const mockFileHandle = {
read: jest.fn(),
close: jest.fn()
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading exactly the requested number of lines

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from '@jest/globals';
import { describe, it, expect, afterEach } from 'vitest';
import { normalizePath, expandHome, convertToWindowsPath } from '../path-utils.js';
describe('Path Utilities', () => {
@@ -10,14 +10,25 @@ describe('Path Utilities', () => {
.toBe('/home/user/some path');
});
it('converts WSL paths to Windows format', () => {
it('never converts WSL paths (they work correctly in WSL with Node.js fs)', () => {
// WSL paths should NEVER be converted, regardless of platform
// They are valid Linux paths that work with Node.js fs operations inside WSL
expect(convertToWindowsPath('/mnt/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
.toBe('/mnt/c/NS/MyKindleContent');
expect(convertToWindowsPath('/mnt/d/Documents'))
.toBe('/mnt/d/Documents');
});
it('converts Unix-style Windows paths to Windows format', () => {
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
it('converts Unix-style Windows paths only on Windows platform', () => {
// On Windows, /c/ style paths should be converted
if (process.platform === 'win32') {
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
} else {
// On Linux, leave them unchanged
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
.toBe('/c/NS/MyKindleContent');
}
});
it('leaves Windows paths unchanged but ensures backslashes', () => {
@@ -34,11 +45,20 @@ describe('Path Utilities', () => {
.toBe('C:\\Program Files\\Some App');
});
it('handles uppercase and lowercase drive letters', () => {
it('handles drive letter paths based on platform', () => {
// WSL paths should never be converted
expect(convertToWindowsPath('/mnt/d/some/path'))
.toBe('D:\\some\\path');
expect(convertToWindowsPath('/d/some/path'))
.toBe('D:\\some\\path');
.toBe('/mnt/d/some/path');
if (process.platform === 'win32') {
// On Windows, Unix-style paths like /d/ should be converted
expect(convertToWindowsPath('/d/some/path'))
.toBe('D:\\some\\path');
} else {
// On Linux, /d/ is just a regular Unix path
expect(convertToWindowsPath('/d/some/path'))
.toBe('/d/some/path');
}
});
});
@@ -50,6 +70,12 @@ describe('Path Utilities', () => {
.toBe('/home/user/some path');
expect(normalizePath('"/usr/local/some app/"'))
.toBe('/usr/local/some app');
expect(normalizePath('/usr/local//bin/app///'))
.toBe('/usr/local/bin/app');
expect(normalizePath('/'))
.toBe('/');
expect(normalizePath('///'))
.toBe('/');
});
it('removes surrounding quotes', () => {
@@ -67,21 +93,33 @@ describe('Path Utilities', () => {
.toBe('C:\\NS\\MyKindleContent');
});
it('handles WSL paths', () => {
it('always preserves WSL paths (they work correctly in WSL)', () => {
// WSL paths should ALWAYS be preserved, regardless of platform
// This is the fix for issue #2795
expect(normalizePath('/mnt/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
.toBe('/mnt/c/NS/MyKindleContent');
expect(normalizePath('/mnt/d/Documents'))
.toBe('/mnt/d/Documents');
});
it('handles Unix-style Windows paths', () => {
expect(normalizePath('/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
// On Windows, /c/ paths should be converted
if (process.platform === 'win32') {
expect(normalizePath('/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
} else if (process.platform === 'linux') {
// On Linux, /c/ is just a regular Unix path
expect(normalizePath('/c/NS/MyKindleContent'))
.toBe('/c/NS/MyKindleContent');
}
});
it('handles paths with spaces and mixed slashes', () => {
expect(normalizePath('C:/NS/My Kindle Content'))
.toBe('C:\\NS\\My Kindle Content');
// WSL paths should always be preserved
expect(normalizePath('/mnt/c/NS/My Kindle Content'))
.toBe('C:\\NS\\My Kindle Content');
.toBe('/mnt/c/NS/My Kindle Content');
expect(normalizePath('C:\\Program Files (x86)\\App Name'))
.toBe('C:\\Program Files (x86)\\App Name');
expect(normalizePath('"C:\\Program Files\\App Name"'))
@@ -91,10 +129,19 @@ describe('Path Utilities', () => {
});
it('preserves spaces in all path formats', () => {
// WSL paths should always be preserved
expect(normalizePath('/mnt/c/Program Files/App Name'))
.toBe('C:\\Program Files\\App Name');
expect(normalizePath('/c/Program Files/App Name'))
.toBe('C:\\Program Files\\App Name');
.toBe('/mnt/c/Program Files/App Name');
if (process.platform === 'win32') {
// On Windows, Unix-style paths like /c/ should be converted
expect(normalizePath('/c/Program Files/App Name'))
.toBe('C:\\Program Files\\App Name');
} else {
// On Linux, /c/ is just a regular Unix path
expect(normalizePath('/c/Program Files/App Name'))
.toBe('/c/Program Files/App Name');
}
expect(normalizePath('C:/Program Files/App Name'))
.toBe('C:\\Program Files\\App Name');
});
@@ -105,15 +152,16 @@ describe('Path Utilities', () => {
.toBe('C:\\NS\\Sub&Folder');
expect(normalizePath('C:/NS/Sub&Folder'))
.toBe('C:\\NS\\Sub&Folder');
// WSL paths should always be preserved
expect(normalizePath('/mnt/c/NS/Sub&Folder'))
.toBe('C:\\NS\\Sub&Folder');
.toBe('/mnt/c/NS/Sub&Folder');
// Test tilde in path (short names in Windows)
expect(normalizePath('C:\\NS\\MYKIND~1'))
.toBe('C:\\NS\\MYKIND~1');
expect(normalizePath('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1'))
.toBe('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1');
// Test other special characters
expect(normalizePath('C:\\Path with #hash'))
.toBe('C:\\Path with #hash');
@@ -128,10 +176,19 @@ describe('Path Utilities', () => {
it('capitalizes lowercase drive letters for Windows paths', () => {
expect(normalizePath('c:/windows/system32'))
.toBe('C:\\windows\\system32');
expect(normalizePath('/mnt/d/my/folder')) // WSL path with lowercase drive
.toBe('D:\\my\\folder');
expect(normalizePath('/e/another/folder')) // Unix-style Windows path with lowercase drive
.toBe('E:\\another\\folder');
// WSL paths should always be preserved
expect(normalizePath('/mnt/d/my/folder'))
.toBe('/mnt/d/my/folder');
if (process.platform === 'win32') {
// On Windows, Unix-style paths should be converted and capitalized
expect(normalizePath('/e/another/folder'))
.toBe('E:\\another\\folder');
} else {
// On Linux, /e/ is just a regular Unix path
expect(normalizePath('/e/another/folder'))
.toBe('/e/another/folder');
}
});
it('handles UNC paths correctly', () => {
@@ -145,11 +202,8 @@ describe('Path Utilities', () => {
});
it('returns normalized non-Windows/WSL/Unix-style Windows paths as is after basic normalization', () => {
// Relative path
const relativePath = 'some/relative/path';
expect(normalizePath(relativePath)).toBe(relativePath.replace(/\//g, '\\'));
// A path that looks somewhat absolute but isn't a drive or recognized Unix root for Windows conversion
// These paths should be preserved as-is (not converted to Windows C:\ format or WSL format)
const otherAbsolutePath = '\\someserver\\share\\file';
expect(normalizePath(otherAbsolutePath)).toBe(otherAbsolutePath);
});
@@ -172,4 +226,146 @@ describe('Path Utilities', () => {
expect(expandHome('C:/test')).toBe('C:/test');
});
});
describe('WSL path handling (issue #2795 fix)', () => {
// Save original platform
const originalPlatform = process.platform;
afterEach(() => {
// Restore platform after each test
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
configurable: true
});
});
it('should NEVER convert WSL paths - they work correctly in WSL with Node.js fs', () => {
// The key insight: When running `wsl npx ...`, Node.js runs INSIDE WSL (process.platform === 'linux')
// and /mnt/c/ paths work correctly with Node.js fs operations in that environment.
// Converting them to C:\ format breaks fs operations because Windows paths don't work inside WSL.
// Mock Linux platform (inside WSL)
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
// WSL paths should NOT be converted, even inside WSL
expect(normalizePath('/mnt/c/Users/username/folder'))
.toBe('/mnt/c/Users/username/folder');
expect(normalizePath('/mnt/d/Documents/project'))
.toBe('/mnt/d/Documents/project');
});
it('should also preserve WSL paths when running on Windows', () => {
// Mock Windows platform
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
// WSL paths should still be preserved (though they wouldn't be accessible from Windows Node.js)
expect(normalizePath('/mnt/c/Users/username/folder'))
.toBe('/mnt/c/Users/username/folder');
expect(normalizePath('/mnt/d/Documents/project'))
.toBe('/mnt/d/Documents/project');
});
it('should convert Unix-style Windows paths (/c/) only when running on Windows (win32)', () => {
// Mock process.platform to be 'win32' (Windows)
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
// Unix-style Windows paths like /c/ should be converted on Windows
expect(normalizePath('/c/Users/username/folder'))
.toBe('C:\\Users\\username\\folder');
expect(normalizePath('/d/Documents/project'))
.toBe('D:\\Documents\\project');
});
it('should NOT convert Unix-style paths (/c/) when running inside WSL (linux)', () => {
// Mock process.platform to be 'linux' (WSL/Linux)
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
// When on Linux, /c/ is just a regular Unix directory, not a drive letter
expect(normalizePath('/c/some/path'))
.toBe('/c/some/path');
expect(normalizePath('/d/another/path'))
.toBe('/d/another/path');
});
it('should preserve regular Unix paths on all platforms', () => {
// Test on Linux
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
expect(normalizePath('/home/user/documents'))
.toBe('/home/user/documents');
expect(normalizePath('/var/log/app'))
.toBe('/var/log/app');
// Test on Windows (though these paths wouldn't work on Windows)
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
expect(normalizePath('/home/user/documents'))
.toBe('/home/user/documents');
expect(normalizePath('/var/log/app'))
.toBe('/var/log/app');
});
it('reproduces exact scenario from issue #2795', () => {
// Simulate running inside WSL: wsl npx @modelcontextprotocol/server-filesystem /mnt/c/Users/username/folder
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
// This is the exact path from the issue
const inputPath = '/mnt/c/Users/username/folder';
const result = normalizePath(inputPath);
// Should NOT convert to C:\Users\username\folder
expect(result).toBe('/mnt/c/Users/username/folder');
expect(result).not.toContain('C:');
expect(result).not.toContain('\\');
});
it('should handle relative path slash conversion based on platform', () => {
// This test verifies platform-specific behavior naturally without mocking
// On Windows: forward slashes converted to backslashes
// On Linux/Unix: forward slashes preserved
const relativePath = 'some/relative/path';
const result = normalizePath(relativePath);
if (originalPlatform === 'win32') {
expect(result).toBe('some\\relative\\path');
} else {
expect(result).toBe('some/relative/path');
}
});
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as os from 'os';

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getValidRootDirectories } from '../roots-utils.js';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from 'fs';
import { tmpdir } from 'os';

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { spawn } from 'child_process';
/**
* Integration tests to verify that tool handlers return structuredContent
* that matches the declared outputSchema.
*
* These tests address issues #3110, #3106, #3093 where tools were returning
* structuredContent: { content: [contentBlock] } (array) instead of
* structuredContent: { content: string } as declared in outputSchema.
*/
describe('structuredContent schema compliance', () => {
let client: Client;
let transport: StdioClientTransport;
let testDir: string;
beforeEach(async () => {
// Create a temp directory for testing
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-test-'));
// Create test files
await fs.writeFile(path.join(testDir, 'test.txt'), 'test content');
await fs.mkdir(path.join(testDir, 'subdir'));
await fs.writeFile(path.join(testDir, 'subdir', 'nested.txt'), 'nested content');
// Start the MCP server
const serverPath = path.resolve(__dirname, '../dist/index.js');
transport = new StdioClientTransport({
command: 'node',
args: [serverPath, testDir],
});
client = new Client({
name: 'test-client',
version: '1.0.0',
}, {
capabilities: {}
});
await client.connect(transport);
});
afterEach(async () => {
await client?.close();
await fs.rm(testDir, { recursive: true, force: true });
});
describe('directory_tree', () => {
it('should return structuredContent.content as a string, not an array', async () => {
const result = await client.callTool({
name: 'directory_tree',
arguments: { path: testDir }
});
// The result should have structuredContent
expect(result.structuredContent).toBeDefined();
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
// It should NOT be an array
expect(Array.isArray(structuredContent.content)).toBe(false);
// The content should be valid JSON representing the tree
const treeData = JSON.parse(structuredContent.content as string);
expect(Array.isArray(treeData)).toBe(true);
});
});
describe('list_directory_with_sizes', () => {
it('should return structuredContent.content as a string, not an array', async () => {
const result = await client.callTool({
name: 'list_directory_with_sizes',
arguments: { path: testDir }
});
// The result should have structuredContent
expect(result.structuredContent).toBeDefined();
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
// It should NOT be an array
expect(Array.isArray(structuredContent.content)).toBe(false);
// The content should contain directory listing info
expect(structuredContent.content).toContain('[FILE]');
});
});
describe('move_file', () => {
it('should return structuredContent.content as a string, not an array', async () => {
const sourcePath = path.join(testDir, 'test.txt');
const destPath = path.join(testDir, 'moved.txt');
const result = await client.callTool({
name: 'move_file',
arguments: {
source: sourcePath,
destination: destPath
}
});
// The result should have structuredContent
expect(result.structuredContent).toBeDefined();
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
// It should NOT be an array
expect(Array.isArray(structuredContent.content)).toBe(false);
// The content should contain success message
expect(structuredContent.content).toContain('Successfully moved');
});
});
describe('list_directory (control - already working)', () => {
it('should return structuredContent.content as a string', async () => {
const result = await client.callTool({
name: 'list_directory',
arguments: { path: testDir }
});
expect(result.structuredContent).toBeDefined();
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
expect(Array.isArray(structuredContent.content)).toBe(false);
});
});
describe('search_files (control - already working)', () => {
it('should return structuredContent.content as a string', async () => {
const result = await client.callTool({
name: 'search_files',
arguments: {
path: testDir,
pattern: '*.txt'
}
});
expect(result.structuredContent).toBeDefined();
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
expect(Array.isArray(structuredContent.content)).toBe(false);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
},
],
},
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverageFrom: [
'**/*.ts',
'!**/__tests__/**',
'!**/dist/**',
],
}

View File

@@ -3,9 +3,14 @@
"version": "0.6.3",
"description": "MCP server for filesystem access",
"license": "MIT",
"mcpName": "io.github.modelcontextprotocol/server-filesystem",
"author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/servers/issues",
"repository": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/servers.git"
},
"type": "module",
"bin": {
"mcp-server-filesystem": "dist/index.js"
@@ -17,25 +22,22 @@
"build": "tsc && shx chmod +x dist/*.js",
"prepare": "npm run build",
"watch": "tsc --watch",
"test": "jest --config=jest.config.cjs --coverage"
"test": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
"diff": "^5.1.0",
"glob": "^10.3.10",
"@modelcontextprotocol/sdk": "^1.25.2",
"diff": "^8.0.3",
"glob": "^10.5.0",
"minimatch": "^10.0.1",
"zod-to-json-schema": "^3.23.5"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/diff": "^5.0.9",
"@types/jest": "^29.5.14",
"@types/minimatch": "^5.1.2",
"@types/node": "^22",
"jest": "^29.7.0",
"@vitest/coverage-v8": "^2.1.8",
"shx": "^0.3.4",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
"typescript": "^5.8.2",
"vitest": "^2.1.8"
}
}

View File

@@ -8,14 +8,15 @@ import os from 'os';
*/
export function convertToWindowsPath(p: string): string {
// Handle WSL paths (/mnt/c/...)
// NEVER convert WSL paths - they are valid Linux paths that work with Node.js fs operations in WSL
// Converting them to Windows format (C:\...) breaks fs operations inside WSL
if (p.startsWith('/mnt/')) {
const driveLetter = p.charAt(5).toUpperCase();
const pathPart = p.slice(6).replace(/\//g, '\\');
return `${driveLetter}:${pathPart}`;
return p; // Leave WSL paths unchanged
}
// Handle Unix-style Windows paths (/c/...)
if (p.match(/^\/[a-zA-Z]\//)) {
// Only convert when running on Windows
if (p.match(/^\/[a-zA-Z]\//) && process.platform === 'win32') {
const driveLetter = p.charAt(1).toUpperCase();
const pathPart = p.slice(2).replace(/\//g, '\\');
return `${driveLetter}:${pathPart}`;
@@ -38,21 +39,29 @@ export function convertToWindowsPath(p: string): string {
export function normalizePath(p: string): string {
// Remove any surrounding quotes and whitespace
p = p.trim().replace(/^["']|["']$/g, '');
// Check if this is a Unix path (starts with / but not a Windows or WSL path)
const isUnixPath = p.startsWith('/') &&
!p.match(/^\/mnt\/[a-z]\//i) &&
!p.match(/^\/[a-zA-Z]\//);
// Check if this is a Unix path that should not be converted
// WSL paths (/mnt/) should ALWAYS be preserved as they work correctly in WSL with Node.js fs
// Regular Unix paths should also be preserved
const isUnixPath = p.startsWith('/') && (
// Always preserve WSL paths (/mnt/c/, /mnt/d/, etc.)
p.match(/^\/mnt\/[a-z]\//i) ||
// On non-Windows platforms, treat all absolute paths as Unix paths
(process.platform !== 'win32') ||
// On Windows, preserve Unix paths that aren't Unix-style Windows paths (/c/, /d/, etc.)
(process.platform === 'win32' && !p.match(/^\/[a-zA-Z]\//))
);
if (isUnixPath) {
// For Unix paths, just normalize without converting to Windows format
// Replace double slashes with single slashes and remove trailing slashes
return p.replace(/\/+/g, '/').replace(/\/+$/, '');
return p.replace(/\/+/g, '/').replace(/(?<!^)\/$/, '');
}
// Convert WSL or Unix-style Windows paths to Windows format
// Convert Unix-style Windows paths (/c/, /d/) to Windows format if on Windows
// This function will now leave /mnt/ paths unchanged
p = convertToWindowsPath(p);
// Handle double backslashes, preserving leading UNC \\
if (p.startsWith('\\\\')) {
// For UNC paths, first normalize any excessive leading backslashes to exactly \\
@@ -67,15 +76,15 @@ export function normalizePath(p: string): string {
// For non-UNC paths, normalize all double backslashes
p = p.replace(/\\\\/g, '\\');
}
// Use Node's path normalization, which handles . and .. segments
let normalized = path.normalize(p);
// Fix UNC paths after normalization (path.normalize can remove a leading backslash)
if (p.startsWith('\\\\') && !normalized.startsWith('\\\\')) {
normalized = '\\' + normalized;
}
// Handle Windows paths: convert slashes and ensure drive letter is capitalized
if (normalized.match(/^[a-zA-Z]:/)) {
let result = normalized.replace(/\//g, '\\');
@@ -85,10 +94,15 @@ export function normalizePath(p: string): string {
}
return result;
}
// For all other paths (including relative paths), convert forward slashes to backslashes
// This ensures relative paths like "some/relative/path" become "some\\relative\\path"
return normalized.replace(/\//g, '\\');
// On Windows, convert forward slashes to backslashes for relative paths
// On Linux/Unix, preserve forward slashes
if (process.platform === 'win32') {
return normalized.replace(/\//g, '\\');
}
// On non-Windows platforms, keep the normalized path as-is
return normalized;
}
/**

View File

@@ -8,5 +8,11 @@
},
"include": [
"./**/*.ts"
],
"exclude": [
"**/__tests__/**",
"**/*.test.ts",
"**/*.spec.ts",
"vitest.config.ts"
]
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/__tests__/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['**/*.ts'],
exclude: ['**/__tests__/**', '**/dist/**'],
},
},
});