Merge branch 'main' into add-roots-support-to-everything-server
This commit is contained in:
commit
c51480bb30
8 changed files with 282 additions and 124 deletions
|
|
@ -145,14 +145,27 @@ The server's directory access control follows this flow:
|
|||
- Fails if destination exists
|
||||
|
||||
- **search_files**
|
||||
- Recursively search for files/directories
|
||||
- Recursively search for files/directories that match or do not match patterns
|
||||
- Inputs:
|
||||
- `path` (string): Starting directory
|
||||
- `pattern` (string): Search pattern
|
||||
- `excludePatterns` (string[]): Exclude any patterns. Glob formats are supported.
|
||||
- Case-insensitive matching
|
||||
- `excludePatterns` (string[]): Exclude any patterns.
|
||||
- Glob-style pattern matching
|
||||
- Returns full paths to matches
|
||||
|
||||
- **directory_tree**
|
||||
- Get recursive JSON tree structure of directory contents
|
||||
- Inputs:
|
||||
- `path` (string): Starting directory
|
||||
- `excludePatterns` (string[]): Exclude any patterns. Glob formats are supported.
|
||||
- Returns:
|
||||
- JSON array where each entry contains:
|
||||
- `name` (string): File/directory name
|
||||
- `type` ('file'|'directory'): Entry type
|
||||
- `children` (array): Present only for directories
|
||||
- Empty array for empty directories
|
||||
- Omitted for files
|
||||
|
||||
- **get_file_info**
|
||||
- Get detailed file/directory metadata
|
||||
- Input: `path` (string)
|
||||
|
|
|
|||
147
src/filesystem/__tests__/directory-tree.test.ts
Normal file
147
src/filesystem/__tests__/directory-tree.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// We need to test the buildTree function, but it's defined inside the request handler
|
||||
// So we'll extract the core logic into a testable function
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
interface TreeEntry {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: TreeEntry[];
|
||||
}
|
||||
|
||||
async function buildTreeForTesting(currentPath: string, rootPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
|
||||
const entries = await fs.readdir(currentPath, {withFileTypes: true});
|
||||
const result: TreeEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
|
||||
const shouldExclude = excludePatterns.some(pattern => {
|
||||
if (pattern.includes('*')) {
|
||||
return minimatch(relativePath, pattern, {dot: true});
|
||||
}
|
||||
// For files: match exact name or as part of path
|
||||
// For directories: match as directory path
|
||||
return minimatch(relativePath, pattern, {dot: true}) ||
|
||||
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
|
||||
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
|
||||
});
|
||||
if (shouldExclude)
|
||||
continue;
|
||||
|
||||
const entryData: TreeEntry = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subPath = path.join(currentPath, entry.name);
|
||||
entryData.children = await buildTreeForTesting(subPath, rootPath, excludePatterns);
|
||||
}
|
||||
|
||||
result.push(entryData);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('buildTree exclude patterns', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-'));
|
||||
|
||||
// Create test directory structure
|
||||
await fs.mkdir(path.join(testDir, 'src'));
|
||||
await fs.mkdir(path.join(testDir, 'node_modules'));
|
||||
await fs.mkdir(path.join(testDir, '.git'));
|
||||
await fs.mkdir(path.join(testDir, 'nested', 'node_modules'), { recursive: true });
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(testDir, '.env'), 'SECRET=value');
|
||||
await fs.writeFile(path.join(testDir, '.env.local'), 'LOCAL_SECRET=value');
|
||||
await fs.writeFile(path.join(testDir, 'src', 'index.js'), 'console.log("hello");');
|
||||
await fs.writeFile(path.join(testDir, 'package.json'), '{}');
|
||||
await fs.writeFile(path.join(testDir, 'node_modules', 'module.js'), 'module.exports = {};');
|
||||
await fs.writeFile(path.join(testDir, 'nested', 'node_modules', 'deep.js'), 'module.exports = {};');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should exclude files matching simple patterns', async () => {
|
||||
// Test the current implementation - this will fail until the bug is fixed
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['.env']);
|
||||
const fileNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(fileNames).not.toContain('.env');
|
||||
expect(fileNames).toContain('.env.local'); // Should not exclude this
|
||||
expect(fileNames).toContain('src');
|
||||
expect(fileNames).toContain('package.json');
|
||||
});
|
||||
|
||||
it('should exclude directories matching simple patterns', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
|
||||
const dirNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(dirNames).not.toContain('node_modules');
|
||||
expect(dirNames).toContain('src');
|
||||
expect(dirNames).toContain('.git');
|
||||
});
|
||||
|
||||
it('should exclude nested directories with same pattern', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
|
||||
|
||||
// Find the nested directory
|
||||
const nestedDir = tree.find(entry => entry.name === 'nested');
|
||||
expect(nestedDir).toBeDefined();
|
||||
expect(nestedDir!.children).toBeDefined();
|
||||
|
||||
// The nested/node_modules should also be excluded
|
||||
const nestedChildren = nestedDir!.children!.map(child => child.name);
|
||||
expect(nestedChildren).not.toContain('node_modules');
|
||||
});
|
||||
|
||||
it('should handle glob patterns correctly', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['*.env']);
|
||||
const fileNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(fileNames).not.toContain('.env');
|
||||
expect(fileNames).toContain('.env.local'); // *.env should not match .env.local
|
||||
expect(fileNames).toContain('src');
|
||||
});
|
||||
|
||||
it('should handle dot files correctly', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['.git']);
|
||||
const dirNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(dirNames).not.toContain('.git');
|
||||
expect(dirNames).toContain('.env'); // Should not exclude this
|
||||
});
|
||||
|
||||
it('should work with multiple exclude patterns', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules', '.env', '.git']);
|
||||
const entryNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(entryNames).not.toContain('node_modules');
|
||||
expect(entryNames).not.toContain('.env');
|
||||
expect(entryNames).not.toContain('.git');
|
||||
expect(entryNames).toContain('src');
|
||||
expect(entryNames).toContain('package.json');
|
||||
});
|
||||
|
||||
it('should handle empty exclude patterns', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, []);
|
||||
const entryNames = tree.map(entry => entry.name);
|
||||
|
||||
// All entries should be included
|
||||
expect(entryNames).toContain('node_modules');
|
||||
expect(entryNames).toContain('.env');
|
||||
expect(entryNames).toContain('.git');
|
||||
expect(entryNames).toContain('src');
|
||||
});
|
||||
});
|
||||
|
|
@ -316,7 +316,7 @@ describe('Lib Functions', () => {
|
|||
|
||||
const result = await searchFilesWithValidation(
|
||||
testDir,
|
||||
'test',
|
||||
'*test*',
|
||||
allowedDirs,
|
||||
{ excludePatterns: ['*.log', 'node_modules'] }
|
||||
);
|
||||
|
|
@ -346,7 +346,7 @@ describe('Lib Functions', () => {
|
|||
|
||||
const result = await searchFilesWithValidation(
|
||||
testDir,
|
||||
'test',
|
||||
'*test*',
|
||||
allowedDirs,
|
||||
{}
|
||||
);
|
||||
|
|
@ -370,7 +370,7 @@ describe('Lib Functions', () => {
|
|||
|
||||
const result = await searchFilesWithValidation(
|
||||
testDir,
|
||||
'test',
|
||||
'*test*',
|
||||
allowedDirs,
|
||||
{ excludePatterns: ['*.backup'] }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createReadStream } from "fs";
|
|||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { minimatch } from "minimatch";
|
||||
import { normalizePath, expandHome } from './path-utils.js';
|
||||
import { getValidRootDirectories } from './roots-utils.js';
|
||||
import {
|
||||
|
|
@ -87,7 +88,10 @@ const ReadMediaFileArgsSchema = z.object({
|
|||
});
|
||||
|
||||
const ReadMultipleFilesArgsSchema = z.object({
|
||||
paths: z.array(z.string()),
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.min(1, "At least one file path must be provided")
|
||||
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories."),
|
||||
});
|
||||
|
||||
const WriteFileArgsSchema = z.object({
|
||||
|
|
@ -121,6 +125,7 @@ const ListDirectoryWithSizesArgsSchema = z.object({
|
|||
|
||||
const DirectoryTreeArgsSchema = z.object({
|
||||
path: z.string(),
|
||||
excludePatterns: z.array(z.string()).optional().default([])
|
||||
});
|
||||
|
||||
const MoveFileArgsSchema = z.object({
|
||||
|
|
@ -275,9 +280,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||
name: "search_files",
|
||||
description:
|
||||
"Recursively search for files and directories matching a pattern. " +
|
||||
"Searches through all subdirectories from the starting path. The search " +
|
||||
"is case-insensitive and matches partial names. Returns full paths to all " +
|
||||
"matching items. Great for finding files when you don't know their exact location. " +
|
||||
"The patterns should be glob-style patterns that match paths relative to the working directory. " +
|
||||
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
|
||||
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
|
||||
"Only searches within allowed directories.",
|
||||
inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput,
|
||||
},
|
||||
|
|
@ -528,13 +533,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||
type: 'file' | 'directory';
|
||||
children?: TreeEntry[];
|
||||
}
|
||||
const rootPath = parsed.data.path;
|
||||
|
||||
async function buildTree(currentPath: string): Promise<TreeEntry[]> {
|
||||
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
|
||||
const validPath = await validatePath(currentPath);
|
||||
const entries = await fs.readdir(validPath, {withFileTypes: true});
|
||||
const result: TreeEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
|
||||
const shouldExclude = excludePatterns.some(pattern => {
|
||||
if (pattern.includes('*')) {
|
||||
return minimatch(relativePath, pattern, {dot: true});
|
||||
}
|
||||
// For files: match exact name or as part of path
|
||||
// For directories: match as directory path
|
||||
return minimatch(relativePath, pattern, {dot: true}) ||
|
||||
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
|
||||
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
|
||||
});
|
||||
if (shouldExclude)
|
||||
continue;
|
||||
|
||||
const entryData: TreeEntry = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
|
|
@ -542,7 +562,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||
|
||||
if (entry.isDirectory()) {
|
||||
const subPath = path.join(currentPath, entry.name);
|
||||
entryData.children = await buildTree(subPath);
|
||||
entryData.children = await buildTree(subPath, excludePatterns);
|
||||
}
|
||||
|
||||
result.push(entryData);
|
||||
|
|
@ -551,7 +571,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||
return result;
|
||||
}
|
||||
|
||||
const treeData = await buildTree(parsed.data.path);
|
||||
const treeData = await buildTree(rootPath, parsed.data.excludePatterns);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
|
|
|
|||
|
|
@ -367,14 +367,14 @@ export async function searchFilesWithValidation(
|
|||
await validatePath(fullPath);
|
||||
|
||||
const relativePath = path.relative(rootPath, fullPath);
|
||||
const shouldExclude = excludePatterns.some(excludePattern => {
|
||||
const globPattern = excludePattern.includes('*') ? excludePattern : `**/${excludePattern}/**`;
|
||||
return minimatch(relativePath, globPattern, { dot: true });
|
||||
});
|
||||
const shouldExclude = excludePatterns.some(excludePattern =>
|
||||
minimatch(relativePath, excludePattern, { dot: true })
|
||||
);
|
||||
|
||||
if (shouldExclude) continue;
|
||||
|
||||
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
// Use glob matching for the search pattern
|
||||
if (minimatch(relativePath, pattern, { dot: true })) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,10 +57,12 @@ Please note that mcp-server-git is currently in early development. The functiona
|
|||
- Returns: Confirmation of reset operation
|
||||
|
||||
8. `git_log`
|
||||
- Shows the commit logs
|
||||
- Shows the commit logs with optional date filtering
|
||||
- Inputs:
|
||||
- `repo_path` (string): Path to Git repository
|
||||
- `max_count` (number, optional): Maximum number of commits to show (default: 10)
|
||||
- `start_timestamp` (string, optional): Start timestamp for filtering commits. Accepts ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates (e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', 'Jan 15 2024')
|
||||
- `end_timestamp` (string, optional): End timestamp for filtering commits. Accepts ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates (e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', 'Jan 15 2024')
|
||||
- Returns: Array of commit entries with hash, author, date, and message
|
||||
|
||||
9. `git_create_branch`
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ class GitReset(BaseModel):
|
|||
class GitLog(BaseModel):
|
||||
repo_path: str
|
||||
max_count: int = 10
|
||||
start_timestamp: Optional[str] = Field(
|
||||
None,
|
||||
description="Start timestamp for filtering commits. Accepts: ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates (e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', 'Jan 15 2024')"
|
||||
)
|
||||
end_timestamp: Optional[str] = Field(
|
||||
None,
|
||||
description="End timestamp for filtering commits. Accepts: ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates (e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', 'Jan 15 2024')"
|
||||
)
|
||||
|
||||
class GitCreateBranch(BaseModel):
|
||||
repo_path: str
|
||||
|
|
@ -83,6 +91,7 @@ class GitBranch(BaseModel):
|
|||
description="The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified",
|
||||
)
|
||||
|
||||
|
||||
class GitTools(str, Enum):
|
||||
STATUS = "git_status"
|
||||
DIFF_UNSTAGED = "git_diff_unstaged"
|
||||
|
|
@ -125,17 +134,41 @@ def git_reset(repo: git.Repo) -> str:
|
|||
repo.index.reset()
|
||||
return "All staged changes reset"
|
||||
|
||||
def git_log(repo: git.Repo, max_count: int = 10) -> list[str]:
|
||||
commits = list(repo.iter_commits(max_count=max_count))
|
||||
log = []
|
||||
for commit in commits:
|
||||
log.append(
|
||||
f"Commit: {commit.hexsha!r}\n"
|
||||
f"Author: {commit.author!r}\n"
|
||||
f"Date: {commit.authored_datetime}\n"
|
||||
f"Message: {commit.message!r}\n"
|
||||
)
|
||||
return log
|
||||
def git_log(repo: git.Repo, max_count: int = 10, start_timestamp: Optional[str] = None, end_timestamp: Optional[str] = None) -> list[str]:
|
||||
if start_timestamp or end_timestamp:
|
||||
# Use git log command with date filtering
|
||||
args = []
|
||||
if start_timestamp:
|
||||
args.extend(['--since', start_timestamp])
|
||||
if end_timestamp:
|
||||
args.extend(['--until', end_timestamp])
|
||||
args.extend(['--format=%H%n%an%n%ad%n%s%n'])
|
||||
|
||||
log_output = repo.git.log(*args).split('\n')
|
||||
|
||||
log = []
|
||||
# Process commits in groups of 4 (hash, author, date, message)
|
||||
for i in range(0, len(log_output), 4):
|
||||
if i + 3 < len(log_output) and len(log) < max_count:
|
||||
log.append(
|
||||
f"Commit: {log_output[i]}\n"
|
||||
f"Author: {log_output[i+1]}\n"
|
||||
f"Date: {log_output[i+2]}\n"
|
||||
f"Message: {log_output[i+3]}\n"
|
||||
)
|
||||
return log
|
||||
else:
|
||||
# Use existing logic for simple log without date filtering
|
||||
commits = list(repo.iter_commits(max_count=max_count))
|
||||
log = []
|
||||
for commit in commits:
|
||||
log.append(
|
||||
f"Commit: {commit.hexsha!r}\n"
|
||||
f"Author: {commit.author!r}\n"
|
||||
f"Date: {commit.authored_datetime}\n"
|
||||
f"Message: {commit.message!r}\n"
|
||||
)
|
||||
return log
|
||||
|
||||
def git_create_branch(repo: git.Repo, branch_name: str, base_branch: str | None = None) -> str:
|
||||
if base_branch:
|
||||
|
|
@ -203,6 +236,7 @@ def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, no
|
|||
|
||||
return branch_info
|
||||
|
||||
|
||||
async def serve(repository: Path | None) -> None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -283,6 +317,7 @@ async def serve(repository: Path | None) -> None:
|
|||
name=GitTools.BRANCH,
|
||||
description="List Git branches",
|
||||
inputSchema=GitBranch.model_json_schema(),
|
||||
|
||||
)
|
||||
]
|
||||
|
||||
|
|
@ -380,13 +415,19 @@ async def serve(repository: Path | None) -> None:
|
|||
text=result
|
||||
)]
|
||||
|
||||
# Update the LOG case:
|
||||
case GitTools.LOG:
|
||||
log = git_log(repo, arguments.get("max_count", 10))
|
||||
log = git_log(
|
||||
repo,
|
||||
arguments.get("max_count", 10),
|
||||
arguments.get("start_timestamp"),
|
||||
arguments.get("end_timestamp")
|
||||
)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text="Commit history:\n" + "\n".join(log)
|
||||
)]
|
||||
|
||||
|
||||
case GitTools.CREATE_BRANCH:
|
||||
result = git_create_branch(
|
||||
repo,
|
||||
|
|
@ -423,7 +464,7 @@ async def serve(repository: Path | None) -> None:
|
|||
type="text",
|
||||
text=result
|
||||
)]
|
||||
|
||||
|
||||
case _:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
|
|
|||
|
|
@ -206,12 +206,12 @@ You should:
|
|||
},
|
||||
thoughtNumber: {
|
||||
type: "integer",
|
||||
description: "Current thought number",
|
||||
description: "Current thought number (numeric value, e.g., 1, 2, 3)",
|
||||
minimum: 1
|
||||
},
|
||||
totalThoughts: {
|
||||
type: "integer",
|
||||
description: "Estimated total thoughts needed",
|
||||
description: "Estimated total thoughts needed (numeric value, e.g., 5, 10)",
|
||||
minimum: 1
|
||||
},
|
||||
isRevision: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue