mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-04-18 00:03:23 +02:00
Merge branch 'main' into claude/issue-2526-20250824-0240
This commit is contained in:
@@ -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 **read‑only** tools from write‑capable 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` | Re‑creating the same dir is a no‑op |
|
||||
| `write_file` | `false` | `true` | `true` | Overwrites existing files |
|
||||
| `edit_file` | `false` | `false` | `true` | Re‑applying edits can fail or double‑apply |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
158
src/filesystem/__tests__/structured-content.test.ts
Normal file
158
src/filesystem/__tests__/structured-content.test.ts
Normal 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
@@ -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/**',
|
||||
],
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,5 +8,11 @@
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/__tests__/**",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
14
src/filesystem/vitest.config.ts
Normal file
14
src/filesystem/vitest.config.ts
Normal 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/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user