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

@@ -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);
});
});
});