# 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:
--------------------------------------------------------------------------------
```
1 | # Default .reposearchignore file
2 | # Directories to exclude
3 | node_modules/
4 | .git/
5 | build/
6 | dist/
7 | coverage/
8 | .cache/
9 | .tmp/
10 | temp/
11 | logs/
12 |
13 | # Binary and media files
14 | *.exe
15 | *.dll
16 | *.so
17 | *.dylib
18 | *.bin
19 | *.dat
20 | *.db
21 | *.sqlite
22 | *.jpg
23 | *.jpeg
24 | *.png
25 | *.gif
26 | *.ico
27 | *.svg
28 | *.mp3
29 | *.mp4
30 | *.zip
31 | *.tar
32 | *.gz
33 | *.rar
34 | *.7z
35 |
36 | # IDE and system files
37 | .DS_Store
38 | Thumbs.db
39 | .idea/
40 | .vscode/
41 | *.swp
42 | *.swo
43 | *.swn
44 | *.bak
45 |
46 | # Allow common text files (these override exclusions above)
47 | !*.txt
48 | !*.md
49 | !*.js
50 | !*.ts
51 | !*.jsx
52 | !*.tsx
53 | !*.json
54 | !*.html
55 | !*.css
56 | !*.scss
57 | !*.less
58 | !*.py
59 | !*.java
60 | !*.c
61 | !*.cpp
62 | !*.h
63 | !*.hpp
64 | !*.rs
65 | !*.go
66 | !*.rb
67 | !*.php
68 | !*.xml
69 | !*.yaml
70 | !*.yml
71 | !*.sh
72 | !*.bash
73 | !*.zsh
74 | !*.fish
75 | !*.conf
76 | !*.ini
77 | !*.properties
78 | !*.env
79 |
```
--------------------------------------------------------------------------------
/src/types/ignore.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | declare module 'ignore' {
2 | interface Ignore {
3 | add(pattern: string | string[]): Ignore;
4 | ignores(filePath: string): boolean;
5 | }
6 |
7 | function createIgnore(): Ignore;
8 | export default createIgnore;
9 | }
10 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext",
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "outDir": "build",
9 | "rootDir": "src",
10 | "declaration": true,
11 | "sourceMap": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules", "build"]
15 | }
16 |
```
--------------------------------------------------------------------------------
/README_en.md:
--------------------------------------------------------------------------------
```markdown
1 | [中文](./README.md) | [English](./README_en.md)
2 |
3 | A mcp server to provide better content search than Cline's builtin `search_files` tools.
4 |
5 | Features:
6 | - [x] control the filter by `.reposearchignore` file, and use gitignore format.
7 | - [x] support regex for searching
8 | - output format control:
9 | - [x] whether include content in result
10 | - [ ] include surrounding lines
11 | - [x] tokens boom prevention
12 |
13 | Notes:
14 | - Currently you need to tell Cline to stop using `search_files` in the system prompt.
```
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Build and Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*.*.*"
7 | - "v*.*.*.*"
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Checkout code
15 | uses: actions/checkout@v4
16 |
17 | - name: Setup Node.js
18 | uses: actions/setup-node@v4
19 | with:
20 | node-version: '20'
21 | cache: 'npm'
22 |
23 | - name: Install dependencies
24 | run: npm ci
25 |
26 | - name: Build
27 | run: npm run build
28 |
29 | - name: Create Release
30 | uses: softprops/action-gh-release@v2
31 | with:
32 | files: build/*.js
33 | generate_release_notes: true
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "mcp-server-reposearch",
3 | "version": "1.1.0",
4 | "description": "MCP Server for searching text content in files",
5 | "main": "build/index.js",
6 | "type": "module",
7 | "scripts": {
8 | "build": "tsc",
9 | "start": "node build/index.js",
10 | "dev": "tsc && node build/index.js"
11 | },
12 | "keywords": [
13 | "mcp",
14 | "search",
15 | "files"
16 | ],
17 | "author": "",
18 | "license": "ISC",
19 | "dependencies": {
20 | "@modelcontextprotocol/sdk": "^1.5.0",
21 | "ignore": "^7.0.3"
22 | },
23 | "devDependencies": {
24 | "@types/node": "^20.0.0",
25 | "typescript": "^5.0.0"
26 | }
27 | }
28 |
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | export interface SearchOptions {
2 | query: string;
3 | isRegex?: boolean;
4 | caseSensitive?: boolean;
5 | wholeWord?: boolean;
6 | includeContent?: boolean;
7 | maxOutputBytes?: number;
8 | }
9 |
10 | export interface SearchResult {
11 | file: string;
12 | line: number;
13 | content?: string;
14 | matchStart: number;
15 | matchEnd: number;
16 | }
17 |
18 | export interface SearchToolArgs {
19 | directory: string;
20 | query: string;
21 | isRegex?: boolean;
22 | caseSensitive?: boolean;
23 | wholeWord?: boolean;
24 | includeContent?: boolean;
25 | maxOutputBytes?: number;
26 | }
27 |
28 | export interface SearchSummary {
29 | matchCount: number;
30 | totalBytes: number;
31 | limitExceeded: boolean;
32 | }
33 |
```
--------------------------------------------------------------------------------
/src/searchEngine.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { promises as fs } from 'fs';
2 | import path from 'path';
3 | import createIgnore from 'ignore';
4 | import { SearchOptions, SearchResult, SearchSummary } from './types.js';
5 |
6 | class FileFilter {
7 | private ignoreInstance = createIgnore();
8 | private initialized = false;
9 |
10 | async initialize(baseDir: string) {
11 | if (this.initialized) return;
12 |
13 | try {
14 | // Try to read .reposearchignore file
15 | const ignoreFilePath = path.join(baseDir, '.reposearchignore');
16 | const ignoreContent = await fs.readFile(ignoreFilePath, 'utf-8');
17 | this.ignoreInstance.add(ignoreContent);
18 | } catch (error) {
19 | // If .reposearchignore doesn't exist, use default patterns
20 | const defaultPatterns = [
21 | 'node_modules/',
22 | '.git/',
23 | 'build/',
24 | 'dist/',
25 | // Add basic binary file patterns
26 | '*.exe', '*.dll', '*.so', '*.dylib',
27 | '*.jpg', '*.jpeg', '*.png', '*.gif',
28 | '*.mp3', '*.mp4', '*.zip', '*.tar.gz',
29 | // Allow common text files
30 | '!*.txt', '!*.md', '!*.js', '!*.ts',
31 | '!*.jsx', '!*.tsx', '!*.json', '!*.html',
32 | '!*.css', '!*.scss', '!*.less', '!*.py',
33 | '!*.java', '!*.c', '!*.cpp', '!*.h',
34 | '!*.hpp', '!*.rs', '!*.go', '!*.rb',
35 | '!*.php', '!*.xml', '!*.yaml', '!*.yml'
36 | ];
37 | this.ignoreInstance.add(defaultPatterns);
38 | }
39 |
40 | this.initialized = true;
41 | }
42 |
43 | shouldIncludeFile(filePath: string, rootDir: string, isDirectory: boolean = false): boolean {
44 | // Always use path relative to the root directory
45 | let relativePath = path.relative(rootDir, filePath);
46 |
47 | // For directories, ensure path ends with '/' as per ignore rules
48 | if (isDirectory) {
49 | relativePath = relativePath.endsWith('/') ? relativePath : relativePath + '/';
50 | }
51 |
52 | return !this.ignoreInstance.ignores(relativePath);
53 | }
54 | }
55 |
56 | const fileFilter = new FileFilter();
57 |
58 | async function* walkDirectory(currentDir: string, rootDir: string): AsyncGenerator<string> {
59 | // Initialize with root directory
60 | await fileFilter.initialize(rootDir);
61 | const entries = await fs.readdir(currentDir, { withFileTypes: true });
62 |
63 | for (const entry of entries) {
64 | const fullPath = path.join(currentDir, entry.name);
65 |
66 | if (entry.isDirectory()) {
67 | // Check if directory should be included, passing isDirectory flag
68 | if (fileFilter.shouldIncludeFile(fullPath, rootDir, true)) {
69 | yield* walkDirectory(fullPath, rootDir);
70 | }
71 | } else if (fileFilter.shouldIncludeFile(fullPath, rootDir, false)) {
72 | try {
73 | // Basic binary file check
74 | const buffer = await fs.readFile(fullPath, { encoding: 'utf8', flag: 'r' });
75 | // Check for null bytes which typically indicate binary content
76 | if (!buffer.includes('\0')) {
77 | yield fullPath;
78 | }
79 | } catch {
80 | // If we can't read the file or it's binary, skip it
81 | continue;
82 | }
83 | }
84 | }
85 | }
86 |
87 | function createSearchRegex(options: SearchOptions): RegExp {
88 | let { query, isRegex, caseSensitive, wholeWord } = options;
89 |
90 | if (!isRegex) {
91 | // Escape special regex characters for literal search
92 | query = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93 | }
94 |
95 | if (wholeWord) {
96 | query = `\\b${query}\\b`;
97 | }
98 |
99 | return new RegExp(query, caseSensitive ? 'g' : 'gi');
100 | }
101 |
102 | // Calculate the byte size of a search result
103 | function calculateResultSize(result: SearchResult): number {
104 | // Calculate JSON string size in bytes
105 | return Buffer.from(JSON.stringify(result)).length;
106 | }
107 |
108 | export async function searchFiles(
109 | directory: string,
110 | options: SearchOptions
111 | ): Promise<{ results: SearchResult[], summary: SearchSummary }> {
112 | const results: SearchResult[] = [];
113 | const regex = createSearchRegex(options);
114 | let totalBytes = 0;
115 | let matchCount = 0;
116 | const maxOutputBytes = options.maxOutputBytes ?? 4096; // Default to 4096 bytes
117 | const hasLimit = maxOutputBytes >= 0; // -1 means no limit
118 |
119 | try {
120 | for await (const filePath of walkDirectory(directory, directory)) {
121 | const content = await fs.readFile(filePath, 'utf-8');
122 | const lines = content.split('\n');
123 |
124 | for (let lineNum = 0; lineNum < lines.length; lineNum++) {
125 | const line = lines[lineNum];
126 | let match: RegExpExecArray | null;
127 |
128 | regex.lastIndex = 0; // Reset regex state
129 | while ((match = regex.exec(line)) !== null) {
130 | matchCount++;
131 |
132 | const result: SearchResult = {
133 | file: path.relative(directory, filePath),
134 | line: lineNum + 1,
135 | matchStart: match.index,
136 | matchEnd: match.index + match[0].length,
137 | };
138 |
139 | if (options.includeContent !== false) {
140 | result.content = line.trim();
141 | }
142 |
143 | const resultSize = calculateResultSize(result);
144 | totalBytes += resultSize;
145 |
146 | // Only add to results if we're under the limit or if there's no limit
147 | if (!hasLimit || maxOutputBytes === -1 || totalBytes <= maxOutputBytes) {
148 | results.push(result);
149 | }
150 | }
151 | }
152 | }
153 | } catch (error) {
154 | console.error('Error during file search:', error);
155 | throw error;
156 | }
157 |
158 | const summary: SearchSummary = {
159 | matchCount,
160 | totalBytes,
161 | limitExceeded: hasLimit && maxOutputBytes !== -1 && totalBytes > maxOutputBytes
162 | };
163 |
164 | // If limit exceeded, clear results to save bandwidth
165 | if (summary.limitExceeded) {
166 | return { results: [], summary };
167 | }
168 |
169 | return { results, summary };
170 | }
171 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4 | import {
5 | CallToolRequestSchema,
6 | ErrorCode,
7 | ListToolsRequestSchema,
8 | McpError,
9 | } from '@modelcontextprotocol/sdk/types.js';
10 | import { searchFiles } from './searchEngine.js';
11 | import { SearchToolArgs } from './types.js';
12 |
13 | class RepoSearchServer {
14 | private server: Server;
15 |
16 | constructor() {
17 | this.server = new Server(
18 | {
19 | name: 'mcp-server-reposearch',
20 | version: '1.0.0',
21 | },
22 | {
23 | capabilities: {
24 | tools: {},
25 | },
26 | }
27 | );
28 |
29 | this.setupToolHandlers();
30 |
31 | // Error handling
32 | this.server.onerror = (error) => console.error('[MCP Error]', error);
33 | process.on('SIGINT', async () => {
34 | await this.server.close();
35 | process.exit(0);
36 | });
37 | }
38 |
39 | private setupToolHandlers() {
40 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
41 | tools: [
42 | {
43 | name: 'search',
44 | 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.',
45 | inputSchema: {
46 | type: 'object',
47 | properties: {
48 | directory: {
49 | type: 'string',
50 | description: 'Directory to search in (use absolute path)',
51 | },
52 | query: {
53 | type: 'string',
54 | description: 'Search query (keyword or regex)',
55 | },
56 | isRegex: {
57 | type: 'boolean',
58 | description: 'Whether to treat query as regex pattern',
59 | default: false,
60 | },
61 | caseSensitive: {
62 | type: 'boolean',
63 | description: 'Whether to match case sensitively',
64 | default: false,
65 | },
66 | wholeWord: {
67 | type: 'boolean',
68 | description: 'Whether to match whole words only',
69 | default: false,
70 | },
71 | includeContent: {
72 | type: 'boolean',
73 | 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.',
74 | default: true,
75 | },
76 | maxOutputBytes: {
77 | type: 'number',
78 | description: 'Maximum allowed output size in bytes. Default is 4096. Set to -1 for unlimited output.',
79 | default: 4096,
80 | }
81 | },
82 | required: ['directory', 'query'],
83 | },
84 | },
85 | ],
86 | }));
87 |
88 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
89 | if (request.params.name !== 'search') {
90 | throw new McpError(
91 | ErrorCode.MethodNotFound,
92 | `Unknown tool: ${request.params.name}`
93 | );
94 | }
95 |
96 | const args = request.params.arguments;
97 | if (!args || typeof args !== 'object') {
98 | throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments');
99 | }
100 |
101 | if (!('directory' in args) || typeof args.directory !== 'string') {
102 | throw new McpError(ErrorCode.InvalidParams, 'Directory must be a string');
103 | }
104 |
105 | if (!('query' in args) || typeof args.query !== 'string') {
106 | throw new McpError(ErrorCode.InvalidParams, 'Query must be a string');
107 | }
108 |
109 | const searchArgs: SearchToolArgs = {
110 | directory: args.directory,
111 | query: args.query,
112 | isRegex: typeof args.isRegex === 'boolean' ? args.isRegex : false,
113 | caseSensitive: typeof args.caseSensitive === 'boolean' ? args.caseSensitive : false,
114 | wholeWord: typeof args.wholeWord === 'boolean' ? args.wholeWord : false,
115 | includeContent: typeof args.includeContent === 'boolean' ? args.includeContent : true,
116 | maxOutputBytes: typeof args.maxOutputBytes === 'number' ? args.maxOutputBytes : 4096,
117 | };
118 |
119 | const { directory, query, isRegex, caseSensitive, wholeWord, includeContent, maxOutputBytes } = searchArgs;
120 |
121 | try {
122 | const { results, summary } = await searchFiles(directory, {
123 | query,
124 | isRegex,
125 | caseSensitive,
126 | wholeWord,
127 | includeContent,
128 | maxOutputBytes,
129 | });
130 |
131 | if (summary.limitExceeded) {
132 | return {
133 | content: [
134 | {
135 | type: 'text',
136 | 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.`,
137 | },
138 | ],
139 | };
140 | }
141 |
142 | return {
143 | content: [
144 | {
145 | type: 'text',
146 | text: JSON.stringify({
147 | results,
148 | summary: {
149 | matchCount: summary.matchCount,
150 | totalBytes: summary.totalBytes
151 | }
152 | }, null, 2),
153 | },
154 | ],
155 | };
156 | } catch (error) {
157 | return {
158 | content: [
159 | {
160 | type: 'text',
161 | text: `Search error: ${error instanceof Error ? error.message : String(error)}`,
162 | },
163 | ],
164 | isError: true,
165 | };
166 | }
167 | });
168 | }
169 |
170 | async run() {
171 | const transport = new StdioServerTransport();
172 | await this.server.connect(transport);
173 | console.error('RepoSearch MCP server running on stdio');
174 | }
175 | }
176 |
177 | const server = new RepoSearchServer();
178 | server.run().catch(console.error);
179 |
```