#
tokens: 3835/50000 9/9 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .clinerules
├── .github
│   └── workflows
│       └── build.yml
├── .gitignore
├── .reposearchignore
├── package-lock.json
├── package.json
├── README_en.md
├── README.md
├── src
│   ├── index.ts
│   ├── searchEngine.ts
│   ├── types
│   │   └── ignore.d.ts
│   └── types.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.reposearchignore:
--------------------------------------------------------------------------------

```
# Default .reposearchignore file
# Directories to exclude
node_modules/
.git/
build/
dist/
coverage/
.cache/
.tmp/
temp/
logs/

# Binary and media files
*.exe
*.dll
*.so
*.dylib
*.bin
*.dat
*.db
*.sqlite
*.jpg
*.jpeg
*.png
*.gif
*.ico
*.svg
*.mp3
*.mp4
*.zip
*.tar
*.gz
*.rar
*.7z

# IDE and system files
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo
*.swn
*.bak

# Allow common text files (these override exclusions above)
!*.txt
!*.md
!*.js
!*.ts
!*.jsx
!*.tsx
!*.json
!*.html
!*.css
!*.scss
!*.less
!*.py
!*.java
!*.c
!*.cpp
!*.h
!*.hpp
!*.rs
!*.go
!*.rb
!*.php
!*.xml
!*.yaml
!*.yml
!*.sh
!*.bash
!*.zsh
!*.fish
!*.conf
!*.ini
!*.properties
!*.env

```

--------------------------------------------------------------------------------
/src/types/ignore.d.ts:
--------------------------------------------------------------------------------

```typescript
declare module 'ignore' {
  interface Ignore {
    add(pattern: string | string[]): Ignore;
    ignores(filePath: string): boolean;
  }

  function createIgnore(): Ignore;
  export default createIgnore;
}

```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "build",
    "rootDir": "src",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build"]
}

```

--------------------------------------------------------------------------------
/README_en.md:
--------------------------------------------------------------------------------

```markdown
[中文](./README.md) | [English](./README_en.md)

A mcp server to provide better content search than Cline's builtin `search_files` tools.

Features:
- [x] control the filter by `.reposearchignore` file, and use gitignore format.
- [x] support regex for searching
- output format control:
  - [x] whether include content in result
  - [ ] include surrounding lines
- [x] tokens boom prevention

Notes:
- Currently you need to tell Cline to stop using `search_files` in the system prompt.
```

--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------

```yaml
name: Build and Release

on:
  push:
    tags:
      - "v*.*.*"
      - "v*.*.*.*"

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'

    - name: Install dependencies
      run: npm ci

    - name: Build
      run: npm run build

    - name: Create Release
      uses: softprops/action-gh-release@v2
      with:
        files: build/*.js
        generate_release_notes: true
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
  "name": "mcp-server-reposearch",
  "version": "1.1.0",
  "description": "MCP Server for searching text content in files",
  "main": "build/index.js",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js",
    "dev": "tsc && node build/index.js"
  },
  "keywords": [
    "mcp",
    "search",
    "files"
  ],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.5.0",
    "ignore": "^7.0.3"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0"
  }
}

```

--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------

```typescript
export interface SearchOptions {
  query: string;
  isRegex?: boolean;
  caseSensitive?: boolean;
  wholeWord?: boolean;
  includeContent?: boolean;
  maxOutputBytes?: number;
}

export interface SearchResult {
  file: string;
  line: number;
  content?: string;
  matchStart: number;
  matchEnd: number;
}

export interface SearchToolArgs {
  directory: string;
  query: string;
  isRegex?: boolean;
  caseSensitive?: boolean;
  wholeWord?: boolean;
  includeContent?: boolean;
  maxOutputBytes?: number;
}

export interface SearchSummary {
  matchCount: number;
  totalBytes: number;
  limitExceeded: boolean;
}

```

--------------------------------------------------------------------------------
/src/searchEngine.ts:
--------------------------------------------------------------------------------

```typescript
import { promises as fs } from 'fs';
import path from 'path';
import createIgnore from 'ignore';
import { SearchOptions, SearchResult, SearchSummary } from './types.js';

class FileFilter {
  private ignoreInstance = createIgnore();
  private initialized = false;

  async initialize(baseDir: string) {
    if (this.initialized) return;

    try {
      // Try to read .reposearchignore file
      const ignoreFilePath = path.join(baseDir, '.reposearchignore');
      const ignoreContent = await fs.readFile(ignoreFilePath, 'utf-8');
      this.ignoreInstance.add(ignoreContent);
    } catch (error) {
      // If .reposearchignore doesn't exist, use default patterns
      const defaultPatterns = [
        'node_modules/',
        '.git/',
        'build/',
        'dist/',
        // Add basic binary file patterns
        '*.exe', '*.dll', '*.so', '*.dylib',
        '*.jpg', '*.jpeg', '*.png', '*.gif',
        '*.mp3', '*.mp4', '*.zip', '*.tar.gz',
        // Allow common text files
        '!*.txt', '!*.md', '!*.js', '!*.ts',
        '!*.jsx', '!*.tsx', '!*.json', '!*.html',
        '!*.css', '!*.scss', '!*.less', '!*.py',
        '!*.java', '!*.c', '!*.cpp', '!*.h',
        '!*.hpp', '!*.rs', '!*.go', '!*.rb',
        '!*.php', '!*.xml', '!*.yaml', '!*.yml'
      ];
      this.ignoreInstance.add(defaultPatterns);
    }

    this.initialized = true;
  }

  shouldIncludeFile(filePath: string, rootDir: string, isDirectory: boolean = false): boolean {
    // Always use path relative to the root directory
    let relativePath = path.relative(rootDir, filePath);
    
    // For directories, ensure path ends with '/' as per ignore rules
    if (isDirectory) {
      relativePath = relativePath.endsWith('/') ? relativePath : relativePath + '/';
    }
    
    return !this.ignoreInstance.ignores(relativePath);
  }
}

const fileFilter = new FileFilter();

async function* walkDirectory(currentDir: string, rootDir: string): AsyncGenerator<string> {
  // Initialize with root directory
  await fileFilter.initialize(rootDir);
  const entries = await fs.readdir(currentDir, { withFileTypes: true });
  
  for (const entry of entries) {
    const fullPath = path.join(currentDir, entry.name);
    
    if (entry.isDirectory()) {
      // Check if directory should be included, passing isDirectory flag
      if (fileFilter.shouldIncludeFile(fullPath, rootDir, true)) {
        yield* walkDirectory(fullPath, rootDir);
      }
    } else if (fileFilter.shouldIncludeFile(fullPath, rootDir, false)) {
      try {
        // Basic binary file check
        const buffer = await fs.readFile(fullPath, { encoding: 'utf8', flag: 'r' });
        // Check for null bytes which typically indicate binary content
        if (!buffer.includes('\0')) {
          yield fullPath;
        }
      } catch {
        // If we can't read the file or it's binary, skip it
        continue;
      }
    }
  }
}

function createSearchRegex(options: SearchOptions): RegExp {
  let { query, isRegex, caseSensitive, wholeWord } = options;
  
  if (!isRegex) {
    // Escape special regex characters for literal search
    query = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }
  
  if (wholeWord) {
    query = `\\b${query}\\b`;
  }
  
  return new RegExp(query, caseSensitive ? 'g' : 'gi');
}

// Calculate the byte size of a search result
function calculateResultSize(result: SearchResult): number {
  // Calculate JSON string size in bytes
  return Buffer.from(JSON.stringify(result)).length;
}

export async function searchFiles(
  directory: string,
  options: SearchOptions
): Promise<{ results: SearchResult[], summary: SearchSummary }> {
  const results: SearchResult[] = [];
  const regex = createSearchRegex(options);
  let totalBytes = 0;
  let matchCount = 0;
  const maxOutputBytes = options.maxOutputBytes ?? 4096; // Default to 4096 bytes
  const hasLimit = maxOutputBytes >= 0; // -1 means no limit
  
  try {
    for await (const filePath of walkDirectory(directory, directory)) {
      const content = await fs.readFile(filePath, 'utf-8');
      const lines = content.split('\n');
      
      for (let lineNum = 0; lineNum < lines.length; lineNum++) {
        const line = lines[lineNum];
        let match: RegExpExecArray | null;
        
        regex.lastIndex = 0; // Reset regex state
        while ((match = regex.exec(line)) !== null) {
          matchCount++;
          
          const result: SearchResult = {
            file: path.relative(directory, filePath),
            line: lineNum + 1,
            matchStart: match.index,
            matchEnd: match.index + match[0].length,
          };
          
          if (options.includeContent !== false) {
            result.content = line.trim();
          }
          
          const resultSize = calculateResultSize(result);
          totalBytes += resultSize;
          
          // Only add to results if we're under the limit or if there's no limit
          if (!hasLimit || maxOutputBytes === -1 || totalBytes <= maxOutputBytes) {
            results.push(result);
          }
        }
      }
    }
  } catch (error) {
    console.error('Error during file search:', error);
    throw error;
  }
  
  const summary: SearchSummary = {
    matchCount,
    totalBytes,
    limitExceeded: hasLimit && maxOutputBytes !== -1 && totalBytes > maxOutputBytes
  };
  
  // If limit exceeded, clear results to save bandwidth
  if (summary.limitExceeded) {
    return { results: [], summary };
  }
  
  return { results, summary };
}

```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { searchFiles } from './searchEngine.js';
import { SearchToolArgs } from './types.js';

class RepoSearchServer {
  private server: Server;

  constructor() {
    this.server = new Server(
      {
        name: 'mcp-server-reposearch',
        version: '1.0.0',
      },
      {
        capabilities: {
          tools: {},
        },
      }
    );

    this.setupToolHandlers();
    
    // Error handling
    this.server.onerror = (error) => console.error('[MCP Error]', error);
    process.on('SIGINT', async () => {
      await this.server.close();
      process.exit(0);
    });
  }

  private setupToolHandlers() {
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: 'search',
          description: 'Search for text content in files within a directory, provide more features than builtin `search_files` tool. CAREFULLY set the arguments to avoid introducing too much content. You should use this tool if you need to search the content of file in a repo.',
          inputSchema: {
            type: 'object',
            properties: {
              directory: {
                type: 'string',
                description: 'Directory to search in (use absolute path)',
              },
              query: {
                type: 'string',
                description: 'Search query (keyword or regex)',
              },
              isRegex: {
                type: 'boolean',
                description: 'Whether to treat query as regex pattern',
                default: false,
              },
              caseSensitive: {
                type: 'boolean',
                description: 'Whether to match case sensitively',
                default: false,
              },
              wholeWord: {
                type: 'boolean',
                description: 'Whether to match whole words only',
                default: false,
              },
              includeContent: {
                type: 'boolean',
                description: 'Whether to include matching line content in results. When you don\'t need the detailed content, you MUST disable it to save tokens. When you don\'t need the detailed content, you MUST disable it to save tokens. When you don\'t need the detailed content, you MUST disable it to save tokens.',
                default: true,
              },
              maxOutputBytes: {
                type: 'number',
                description: 'Maximum allowed output size in bytes. Default is 4096. Set to -1 for unlimited output.',
                default: 4096,
              }
            },
            required: ['directory', 'query'],
          },
        },
      ],
    }));

    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      if (request.params.name !== 'search') {
        throw new McpError(
          ErrorCode.MethodNotFound,
          `Unknown tool: ${request.params.name}`
        );
      }

      const args = request.params.arguments;
      if (!args || typeof args !== 'object') {
        throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments');
      }

      if (!('directory' in args) || typeof args.directory !== 'string') {
        throw new McpError(ErrorCode.InvalidParams, 'Directory must be a string');
      }

      if (!('query' in args) || typeof args.query !== 'string') {
        throw new McpError(ErrorCode.InvalidParams, 'Query must be a string');
      }

      const searchArgs: SearchToolArgs = {
        directory: args.directory,
        query: args.query,
        isRegex: typeof args.isRegex === 'boolean' ? args.isRegex : false,
        caseSensitive: typeof args.caseSensitive === 'boolean' ? args.caseSensitive : false,
        wholeWord: typeof args.wholeWord === 'boolean' ? args.wholeWord : false,
        includeContent: typeof args.includeContent === 'boolean' ? args.includeContent : true,
        maxOutputBytes: typeof args.maxOutputBytes === 'number' ? args.maxOutputBytes : 4096,
      };

      const { directory, query, isRegex, caseSensitive, wholeWord, includeContent, maxOutputBytes } = searchArgs;

      try {
        const { results, summary } = await searchFiles(directory, {
          query,
          isRegex,
          caseSensitive,
          wholeWord,
          includeContent,
          maxOutputBytes,
        });

        if (summary.limitExceeded) {
          return {
            content: [
              {
                type: 'text',
                text: `Output size limit exceeded. Found ${summary.matchCount} matches with a total size of ${summary.totalBytes} bytes, which exceeds the limit of ${maxOutputBytes} bytes. Try narrowing your search or increasing the maxOutputBytes limit.`,
              },
            ],
          };
        }

        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({
                results,
                summary: {
                  matchCount: summary.matchCount,
                  totalBytes: summary.totalBytes
                }
              }, null, 2),
            },
          ],
        };
      } catch (error) {
        return {
          content: [
            {
              type: 'text',
              text: `Search error: ${error instanceof Error ? error.message : String(error)}`,
            },
          ],
          isError: true,
        };
      }
    });
  }

  async run() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error('RepoSearch MCP server running on stdio');
  }
}

const server = new RepoSearchServer();
server.run().catch(console.error);

```