@twahaa/codefinder 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +188 -0
- package/build/cli/search.js +37 -0
- package/build/client.js +189 -0
- package/build/server.js +122 -0
- package/build/tools/listFile.js +50 -0
- package/build/tools/readFile.js +26 -0
- package/build/tools/searchCode.js +46 -0
- package/build/ui/openTUI.js +61 -0
- package/build/utils/flattenFIleTrees.js +13 -0
- package/build/utils/pathGuard.js +18 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, CodeFinder contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
16
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
This repo contains an MCP server (tool provider) and a sample Gemini-based MCP client with an interactive TUI.
|
|
2
|
+
The server exposes safe, deterministic capabilities; the client handles reasoning and tool orchestration.
|
|
3
|
+
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Security Model
|
|
7
|
+
|
|
8
|
+
### Path Guard
|
|
9
|
+
|
|
10
|
+
All filesystem access is restricted to a fixed project root (`target-codebase`).
|
|
11
|
+
|
|
12
|
+
Every user-supplied path is validated using a path guard that:
|
|
13
|
+
|
|
14
|
+
- Resolves paths against the project root
|
|
15
|
+
- Rejects absolute paths
|
|
16
|
+
- Resolves symlinks using `realpath`
|
|
17
|
+
- Ensures the final resolved path cannot escape the sandbox
|
|
18
|
+
|
|
19
|
+
This prevents:
|
|
20
|
+
|
|
21
|
+
- `../` path traversal attacks
|
|
22
|
+
- Symlink-based escapes
|
|
23
|
+
- Accidental access to system files (e.g. `/etc`, `/home`)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### Resource Limits
|
|
28
|
+
|
|
29
|
+
To prevent excessive resource usage and prompt flooding, the server enforces hard limits:
|
|
30
|
+
|
|
31
|
+
- Maximum file size for reads
|
|
32
|
+
- Maximum line count for files
|
|
33
|
+
- Maximum directory traversal depth
|
|
34
|
+
- Maximum search results
|
|
35
|
+
|
|
36
|
+
These limits are enforced **server-side** and cannot be overridden by clients.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
### `list_files`
|
|
43
|
+
|
|
44
|
+
Lists the structure of a directory within the project.
|
|
45
|
+
|
|
46
|
+
**Purpose**
|
|
47
|
+
|
|
48
|
+
- Provides orientation within the codebase
|
|
49
|
+
- Helps decide where to search or inspect next
|
|
50
|
+
|
|
51
|
+
**Input**
|
|
52
|
+
|
|
53
|
+
- `dir` (optional, project-relative path, defaults to project root)
|
|
54
|
+
|
|
55
|
+
**Output**
|
|
56
|
+
|
|
57
|
+
- Structured directory entries (files and directories)
|
|
58
|
+
- Nested up to a fixed maximum depth
|
|
59
|
+
|
|
60
|
+
**Notes**
|
|
61
|
+
|
|
62
|
+
- Common large or irrelevant directories are ignored (e.g. `node_modules`, `.git`, build outputs)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### `search_code`
|
|
67
|
+
|
|
68
|
+
Searches for a plain-text query across the project.
|
|
69
|
+
|
|
70
|
+
**Purpose**
|
|
71
|
+
|
|
72
|
+
- Quickly locate where a concept, identifier, or keyword appears
|
|
73
|
+
- Avoid reading unnecessary files
|
|
74
|
+
|
|
75
|
+
**Input**
|
|
76
|
+
|
|
77
|
+
- `query` (required string)
|
|
78
|
+
- `dir` (optional starting directory, project-relative)
|
|
79
|
+
|
|
80
|
+
**Output**
|
|
81
|
+
|
|
82
|
+
- Project-relative file path
|
|
83
|
+
- Line number
|
|
84
|
+
- Short line preview
|
|
85
|
+
|
|
86
|
+
**Constraints**
|
|
87
|
+
|
|
88
|
+
- Plain-text search (no regex)
|
|
89
|
+
- File size limits enforced
|
|
90
|
+
- Total and per-file match limits enforced
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
### `read_file`
|
|
95
|
+
|
|
96
|
+
Reads the contents of a single file.
|
|
97
|
+
|
|
98
|
+
**Purpose**
|
|
99
|
+
|
|
100
|
+
- Inspect specific implementation details after locating a file
|
|
101
|
+
|
|
102
|
+
**Input**
|
|
103
|
+
|
|
104
|
+
- `path` (project-relative file path)
|
|
105
|
+
|
|
106
|
+
**Constraints**
|
|
107
|
+
|
|
108
|
+
- Path must point to a file (directories rejected)
|
|
109
|
+
- File size and line limits enforced
|
|
110
|
+
- Binary or unreadable files are rejected
|
|
111
|
+
- Access to package manager manifests (`package.json`, `package-lock.json`) is denied
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Typical LLM Workflow
|
|
116
|
+
|
|
117
|
+
A typical interaction with this MCP server looks like:
|
|
118
|
+
|
|
119
|
+
1. Call `list_files` to understand the project structure
|
|
120
|
+
2. Call `search_code` to locate relevant files or identifiers
|
|
121
|
+
3. Call `read_file` to inspect specific code sections
|
|
122
|
+
|
|
123
|
+
The server intentionally avoids higher-level reasoning and only provides safe primitives that enable the LLM to reason effectively.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Running the Server
|
|
128
|
+
|
|
129
|
+
### Prerequisites
|
|
130
|
+
|
|
131
|
+
- Node.js 18+
|
|
132
|
+
- npm
|
|
133
|
+
|
|
134
|
+
### Install dependencies
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npm install
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Run the server (dev)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm run server:dev
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Running the Gemini Client
|
|
149
|
+
|
|
150
|
+
### Prerequisites
|
|
151
|
+
|
|
152
|
+
- Node.js 18+
|
|
153
|
+
- Gemini API key (`GEMINI_API_KEY` env or enter at prompt)
|
|
154
|
+
|
|
155
|
+
### Start the client
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm run client:dev
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
You will be prompted for:
|
|
162
|
+
|
|
163
|
+
- Gemini API key (or use `GEMINI_API_KEY`)
|
|
164
|
+
- Gemini model (choices):
|
|
165
|
+
- `gemini-2.5-flash-lite` (fast, generous free tier)
|
|
166
|
+
- `gemini-2.5-flash` (balanced, stricter free limits)
|
|
167
|
+
- `gemini-2.0-flash-lite` (legacy, stable)
|
|
168
|
+
|
|
169
|
+
### Client behavior
|
|
170
|
+
|
|
171
|
+
- Shows a figlet banner and colored prompts
|
|
172
|
+
- Lists available MCP tools
|
|
173
|
+
- Chat loop with tool-call handling
|
|
174
|
+
- Graceful handling of quota/rate-limit exhaustion (shows a note to retry or change model)
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## What the Server Provides (Tools)
|
|
179
|
+
|
|
180
|
+
The MCP server exposes three safe, deterministic tools:
|
|
181
|
+
|
|
182
|
+
- `list_files`: Explore directories within the sandboxed project root (ignores large/irrelevant folders and enforces max depth).
|
|
183
|
+
- `search_code`: Plain-text search with result caps and file size limits.
|
|
184
|
+
- `read_file`: Read a single file with size/line limits; rejects binaries and denies `package.json` / `package-lock.json`.
|
|
185
|
+
|
|
186
|
+
See the sections above for full inputs/outputs and constraints.
|
|
187
|
+
|
|
188
|
+
---
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { searchCode } from "../tools/searchCode.js";
|
|
2
|
+
function printUsage() {
|
|
3
|
+
console.log(`
|
|
4
|
+
Usage:
|
|
5
|
+
search <query> [directory]
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
search TODO
|
|
9
|
+
search "import fs" src
|
|
10
|
+
`);
|
|
11
|
+
}
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
if (args.length === 0) {
|
|
14
|
+
printUsage();
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const query = args[0];
|
|
18
|
+
const dir = args[1];
|
|
19
|
+
try {
|
|
20
|
+
const results = searchCode(query, dir);
|
|
21
|
+
if (results.length === 0) {
|
|
22
|
+
console.log("No matches found.");
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
for (const r of results) {
|
|
26
|
+
console.log(`${r.filePath}:${r.lineNumber} ${r.preview}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof Error) {
|
|
31
|
+
console.error("Error:", err.message);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.error("Unknown error");
|
|
35
|
+
}
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
package/build/client.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { config } from "dotenv";
|
|
3
|
+
config();
|
|
4
|
+
import { intro, outro, text, note, spinner, isCancel, select, } from "@clack/prompts";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { SchemaType, GoogleGenerativeAI, } from "@google/generative-ai";
|
|
8
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
|
+
import { logError, logModelText, logToolCall, logToolResult, renderLogo, } from "./ui/openTUI.js";
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const builtServerPath = path.resolve(__dirname, "server.js");
|
|
13
|
+
renderLogo();
|
|
14
|
+
intro("CodeFinder by TWAHaa");
|
|
15
|
+
note("MCP + Gemini Client");
|
|
16
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
17
|
+
console.error("This CLI requires a TTY. Please run in an interactive terminal.");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const transport = new StdioClientTransport({
|
|
21
|
+
command: process.execPath,
|
|
22
|
+
args: [builtServerPath],
|
|
23
|
+
});
|
|
24
|
+
const mcpClient = new Client({
|
|
25
|
+
name: "CodeFinder",
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
});
|
|
28
|
+
await mcpClient.connect(transport);
|
|
29
|
+
const apiKeyInput = await text({
|
|
30
|
+
message: "Enter Gemini API key (leave blank to use GEMINI_API_KEY env):",
|
|
31
|
+
placeholder: "AIza...",
|
|
32
|
+
});
|
|
33
|
+
if (isCancel(apiKeyInput)) {
|
|
34
|
+
outro("Cancelled.");
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
const geminiApiKey = (typeof apiKeyInput === "string" ? apiKeyInput.trim() : "") ||
|
|
38
|
+
process.env.GEMINI_API_KEY;
|
|
39
|
+
if (!geminiApiKey) {
|
|
40
|
+
console.error("A Gemini API key is required. Set env or enter it at prompt.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const tools = await mcpClient.listTools();
|
|
44
|
+
note(tools.tools.length
|
|
45
|
+
? `Tools: ${tools.tools.map((t) => t.name).join(", ")}`
|
|
46
|
+
: "No tools available.");
|
|
47
|
+
const geminiTools = tools.tools.map((tool) => {
|
|
48
|
+
const inputSchema = tool.inputSchema;
|
|
49
|
+
const properties = inputSchema &&
|
|
50
|
+
typeof inputSchema === "object" &&
|
|
51
|
+
"properties" in inputSchema
|
|
52
|
+
? (inputSchema.properties ??
|
|
53
|
+
{})
|
|
54
|
+
: {};
|
|
55
|
+
const required = inputSchema && typeof inputSchema === "object" && "required" in inputSchema
|
|
56
|
+
? inputSchema.required
|
|
57
|
+
: undefined;
|
|
58
|
+
return {
|
|
59
|
+
name: tool.name,
|
|
60
|
+
description: tool.description ?? "",
|
|
61
|
+
parameters: {
|
|
62
|
+
type: SchemaType.OBJECT,
|
|
63
|
+
properties,
|
|
64
|
+
required,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
const modelChoice = await select({
|
|
69
|
+
message: "Choose a Gemini model",
|
|
70
|
+
options: [
|
|
71
|
+
{
|
|
72
|
+
value: "gemini-2.5-flash-lite",
|
|
73
|
+
label: "Gemini 2.5 Flash-Lite (fast, generous free tier)",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
value: "gemini-2.5-flash",
|
|
77
|
+
label: "Gemini 2.5 Flash (balanced, stricter free limits)",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
value: "gemini-2.0-flash-lite",
|
|
81
|
+
label: "Gemini 2.0 Flash / Flash-Lite (legacy, stable)",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
initialValue: "gemini-2.5-flash-lite",
|
|
85
|
+
});
|
|
86
|
+
if (isCancel(modelChoice)) {
|
|
87
|
+
outro("Cancelled.");
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
const modelName = typeof modelChoice === "string" ? modelChoice : "gemini-2.5-flash-lite";
|
|
91
|
+
note(`Using model: ${modelName}`);
|
|
92
|
+
const genAI = new GoogleGenerativeAI(geminiApiKey);
|
|
93
|
+
const model = genAI.getGenerativeModel({
|
|
94
|
+
model: modelName,
|
|
95
|
+
tools: [
|
|
96
|
+
{
|
|
97
|
+
functionDeclarations: geminiTools,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
const chat = model.startChat({ history: [] });
|
|
102
|
+
function isResourceExhausted(err) {
|
|
103
|
+
const anyErr = err;
|
|
104
|
+
const msg = anyErr?.message?.toLowerCase?.() ?? "";
|
|
105
|
+
const statusText = anyErr?.statusText?.toLowerCase?.() ?? "";
|
|
106
|
+
return (anyErr?.status === 429 ||
|
|
107
|
+
statusText.includes("too many") ||
|
|
108
|
+
msg.includes("resource has been exhausted") ||
|
|
109
|
+
msg.includes("quota") ||
|
|
110
|
+
msg.includes("rate limit"));
|
|
111
|
+
}
|
|
112
|
+
async function handleTurn(userText) {
|
|
113
|
+
let result;
|
|
114
|
+
try {
|
|
115
|
+
result = await chat.sendMessage(userText);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (isResourceExhausted(err)) {
|
|
119
|
+
note("Resource/quota exhausted for this model. Try again later or pick another model.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
let response = result.response;
|
|
125
|
+
// Continue handling tool calls until the model stops requesting them
|
|
126
|
+
while (true) {
|
|
127
|
+
const call = response.functionCalls()?.[0];
|
|
128
|
+
if (!call) {
|
|
129
|
+
logModelText(response.text());
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const { name, args } = call;
|
|
133
|
+
const parsedArgs = args && typeof args === "object"
|
|
134
|
+
? args
|
|
135
|
+
: {};
|
|
136
|
+
logToolCall(name, parsedArgs);
|
|
137
|
+
const toolResult = await mcpClient.callTool({
|
|
138
|
+
name,
|
|
139
|
+
arguments: parsedArgs,
|
|
140
|
+
});
|
|
141
|
+
logToolResult(toolResult.content);
|
|
142
|
+
let followUp;
|
|
143
|
+
try {
|
|
144
|
+
followUp = await chat.sendMessage([
|
|
145
|
+
{
|
|
146
|
+
functionResponse: {
|
|
147
|
+
name,
|
|
148
|
+
response: { content: toolResult.content },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (isResourceExhausted(err)) {
|
|
155
|
+
note("Resource/quota exhausted while sending tool result. Try again later or pick another model.");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
response = followUp.response;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
console.log("Chat ready. Type your question (or 'exit' to quit).");
|
|
164
|
+
while (true) {
|
|
165
|
+
const queryInput = await text({
|
|
166
|
+
message: "You",
|
|
167
|
+
placeholder: "Ask me anything (type 'exit' to quit)",
|
|
168
|
+
});
|
|
169
|
+
if (isCancel(queryInput)) {
|
|
170
|
+
outro("Goodbye!");
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
const query = typeof queryInput === "string" ? queryInput.trim() : String(queryInput);
|
|
174
|
+
if (!query)
|
|
175
|
+
continue;
|
|
176
|
+
if (query.toLowerCase() === "exit") {
|
|
177
|
+
outro("Goodbye!");
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const spin = spinner();
|
|
182
|
+
spin.start("Sending to Gemini...");
|
|
183
|
+
await handleTurn(query);
|
|
184
|
+
spin.stop("Done.");
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
logError(err);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/build/server.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
import { searchCode } from "./tools/searchCode.js";
|
|
5
|
+
import { readFile } from "./tools/readFile.js";
|
|
6
|
+
import { listFile } from "./tools/listFile.js";
|
|
7
|
+
const server = new McpServer({
|
|
8
|
+
name: "CodeFinder",
|
|
9
|
+
version: "1.0.0",
|
|
10
|
+
}, {
|
|
11
|
+
capabilities: {
|
|
12
|
+
tools: {},
|
|
13
|
+
resources: {},
|
|
14
|
+
prompts: {},
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
server.registerTool("search_code", {
|
|
18
|
+
description: "Search for a text query inside a directory",
|
|
19
|
+
inputSchema: {
|
|
20
|
+
query: z.string().min(1).describe("Text to search for"),
|
|
21
|
+
dir: z.string().optional().describe("Relative directory path"),
|
|
22
|
+
},
|
|
23
|
+
}, async ({ query, dir }) => {
|
|
24
|
+
try {
|
|
25
|
+
const results = searchCode(query, dir);
|
|
26
|
+
return {
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "text",
|
|
30
|
+
text: JSON.stringify(results, null, 2),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: JSON.stringify({
|
|
41
|
+
error: err instanceof Error
|
|
42
|
+
? `Error: ${err.message}`
|
|
43
|
+
: "Unknown error",
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server.registerTool("list_files", {
|
|
51
|
+
description: "List the files in a directory",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
path: z.string().min(0).optional().describe("Relative path to the directory"),
|
|
54
|
+
},
|
|
55
|
+
}, async ({ path }) => {
|
|
56
|
+
try {
|
|
57
|
+
const files = listFile(path);
|
|
58
|
+
return {
|
|
59
|
+
content: [
|
|
60
|
+
{
|
|
61
|
+
type: "text",
|
|
62
|
+
text: JSON.stringify(files, null, 2),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text",
|
|
72
|
+
text: JSON.stringify({
|
|
73
|
+
error: err instanceof Error
|
|
74
|
+
? `Error: ${err.message}`
|
|
75
|
+
: "Unknown error",
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
server.registerTool("read_file", {
|
|
83
|
+
description: "Read the contents of a text file safely",
|
|
84
|
+
inputSchema: {
|
|
85
|
+
path: z.string().min(1).describe("Relative path to the file"),
|
|
86
|
+
},
|
|
87
|
+
}, async ({ path }) => {
|
|
88
|
+
try {
|
|
89
|
+
const content = readFile(path);
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: "text",
|
|
94
|
+
text: content,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "text",
|
|
104
|
+
text: JSON.stringify({
|
|
105
|
+
error: err instanceof Error
|
|
106
|
+
? `Error: ${err.message}`
|
|
107
|
+
: "Unknown error",
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
async function main() {
|
|
115
|
+
const transport = new StdioServerTransport();
|
|
116
|
+
await server.connect(transport);
|
|
117
|
+
console.error("MCP server running");
|
|
118
|
+
}
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
console.error("Failed to start MCP server:", err);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { resolveSafePath } from "../utils/pathGuard.js";
|
|
4
|
+
const IGNORE = new Set([
|
|
5
|
+
"node_modules",
|
|
6
|
+
".git",
|
|
7
|
+
"dist",
|
|
8
|
+
"build",
|
|
9
|
+
".next",
|
|
10
|
+
".env",
|
|
11
|
+
"package-lock.json",
|
|
12
|
+
"yarn.lock",
|
|
13
|
+
"pnpm-lock.yaml",
|
|
14
|
+
"package.json",
|
|
15
|
+
]);
|
|
16
|
+
const MAX_DEPTH = 2;
|
|
17
|
+
export function listFile(dir = ".") {
|
|
18
|
+
const safeDir = resolveSafePath(dir);
|
|
19
|
+
const stats = fs.statSync(safeDir);
|
|
20
|
+
if (!stats.isDirectory()) {
|
|
21
|
+
throw new Error("Path must be a directory");
|
|
22
|
+
}
|
|
23
|
+
return walkDirectory(safeDir, 0);
|
|
24
|
+
}
|
|
25
|
+
function walkDirectory(absoluteDir, depth) {
|
|
26
|
+
const entires = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
|
27
|
+
const result = [];
|
|
28
|
+
for (const entry of entires) {
|
|
29
|
+
if (IGNORE.has(entry.name))
|
|
30
|
+
continue;
|
|
31
|
+
const entryPath = path.join(absoluteDir, entry.name);
|
|
32
|
+
if (entry.isFile()) {
|
|
33
|
+
result.push({
|
|
34
|
+
name: entry.name,
|
|
35
|
+
type: "file",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
const dirEntry = {
|
|
40
|
+
name: entry.name,
|
|
41
|
+
type: "directory",
|
|
42
|
+
};
|
|
43
|
+
if (depth < MAX_DEPTH) {
|
|
44
|
+
dirEntry.children = walkDirectory(entryPath, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
result.push(dirEntry);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { resolveSafePath } from "../utils/pathGuard.js";
|
|
3
|
+
export function readFile(userPath) {
|
|
4
|
+
const safePath = resolveSafePath(userPath);
|
|
5
|
+
const stats = fs.statSync(safePath);
|
|
6
|
+
const MAX_SIZE = 200 * 1024;
|
|
7
|
+
const MAX_LINES = 1000;
|
|
8
|
+
if (!stats.isFile()) {
|
|
9
|
+
throw new Error("The path should specify a file not a directory");
|
|
10
|
+
}
|
|
11
|
+
if (stats.size > MAX_SIZE) {
|
|
12
|
+
throw new Error("File too big");
|
|
13
|
+
}
|
|
14
|
+
let content;
|
|
15
|
+
try {
|
|
16
|
+
content = fs.readFileSync(safePath, "utf-8");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error("File not readable as text");
|
|
20
|
+
}
|
|
21
|
+
const lines = content.split(/\r?\n/).length;
|
|
22
|
+
if (lines > MAX_LINES) {
|
|
23
|
+
throw new Error("Too many lines!");
|
|
24
|
+
}
|
|
25
|
+
return content;
|
|
26
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { resolveSafePath } from "../utils/pathGuard.js";
|
|
4
|
+
import { listFile } from "./listFile.js";
|
|
5
|
+
import { flattenFileTree } from "../utils/flattenFIleTrees.js";
|
|
6
|
+
function isEmpty(str) {
|
|
7
|
+
return !str || str.trim().length === 0;
|
|
8
|
+
}
|
|
9
|
+
export function searchCode(query, dir) {
|
|
10
|
+
if (isEmpty(query)) {
|
|
11
|
+
throw new Error("The query is empty");
|
|
12
|
+
}
|
|
13
|
+
const rootDir = resolveSafePath(dir ?? ".");
|
|
14
|
+
const stats = fs.statSync(rootDir);
|
|
15
|
+
if (!stats.isDirectory()) {
|
|
16
|
+
throw new Error("The given path is not a directory");
|
|
17
|
+
}
|
|
18
|
+
const tree = listFile(rootDir);
|
|
19
|
+
const files = flattenFileTree(tree, rootDir);
|
|
20
|
+
const results = [];
|
|
21
|
+
const q = query.trim().toLowerCase();
|
|
22
|
+
for (const filePath of files) {
|
|
23
|
+
const ext = path.extname(filePath);
|
|
24
|
+
if ([".png", ".jpg", ".jpeg", ".zip", ".exe", ".pdf"].includes(ext)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let content;
|
|
28
|
+
try {
|
|
29
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const lines = content.split("\n");
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
if (lines[i].toLowerCase().includes(q)) {
|
|
37
|
+
results.push({
|
|
38
|
+
filePath,
|
|
39
|
+
lineNumber: i + 1,
|
|
40
|
+
preview: lines[i].trim(),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import figlet from "figlet";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
const divider = chalk.gray("-".repeat(50));
|
|
4
|
+
export function renderLogo() {
|
|
5
|
+
const text = figlet.textSync("CodeFinder", {
|
|
6
|
+
font: "Standard",
|
|
7
|
+
horizontalLayout: "default",
|
|
8
|
+
verticalLayout: "default",
|
|
9
|
+
});
|
|
10
|
+
console.log("\n" + chalk.magenta(text));
|
|
11
|
+
console.log(chalk.whiteBright("by TWAHaa"));
|
|
12
|
+
console.log(divider);
|
|
13
|
+
}
|
|
14
|
+
export function renderHeader(title, subtitle) {
|
|
15
|
+
console.log("\n" + divider);
|
|
16
|
+
console.log(chalk.bold.white(title));
|
|
17
|
+
if (subtitle)
|
|
18
|
+
console.log(chalk.gray(subtitle));
|
|
19
|
+
console.log(divider);
|
|
20
|
+
}
|
|
21
|
+
export function renderTools(tools) {
|
|
22
|
+
if (!tools.length) {
|
|
23
|
+
console.log(chalk.yellow("No tools available."));
|
|
24
|
+
console.log(divider);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
console.log(chalk.cyan("Available tools:"));
|
|
28
|
+
for (const tool of tools) {
|
|
29
|
+
const desc = tool.description ? ` — ${tool.description}` : "";
|
|
30
|
+
console.log(`${chalk.green("•")} ${chalk.white(tool.name)}${chalk.gray(desc)}`);
|
|
31
|
+
}
|
|
32
|
+
console.log(divider);
|
|
33
|
+
}
|
|
34
|
+
export function logToolCall(name, args) {
|
|
35
|
+
console.log(divider);
|
|
36
|
+
console.log(`${chalk.blue("Tool call")} ${chalk.gray("→")} ${chalk.white(name)}`);
|
|
37
|
+
console.log(formatJSON(args));
|
|
38
|
+
console.log(divider);
|
|
39
|
+
}
|
|
40
|
+
export function logToolResult(result) {
|
|
41
|
+
console.log(chalk.blue("Tool result:"));
|
|
42
|
+
console.log(formatJSON(result));
|
|
43
|
+
console.log(divider);
|
|
44
|
+
}
|
|
45
|
+
export function logModelText(text) {
|
|
46
|
+
console.log(chalk.magenta("Gemini:"));
|
|
47
|
+
console.log(chalk.white(text.trim() || "(no text)"));
|
|
48
|
+
console.log(divider);
|
|
49
|
+
}
|
|
50
|
+
export function logError(err) {
|
|
51
|
+
console.error(chalk.red("Error:"), err);
|
|
52
|
+
console.log(divider);
|
|
53
|
+
}
|
|
54
|
+
function formatJSON(data) {
|
|
55
|
+
try {
|
|
56
|
+
return chalk.gray(JSON.stringify(data, null, 2));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return String(data);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function flattenFileTree(nodes, rootPath) {
|
|
2
|
+
let files = [];
|
|
3
|
+
for (const node of nodes) {
|
|
4
|
+
const fullPath = `${rootPath}/${node.name}`;
|
|
5
|
+
if (node.type === "file") {
|
|
6
|
+
files.push(fullPath);
|
|
7
|
+
}
|
|
8
|
+
else if (node.children) {
|
|
9
|
+
files.push(...flattenFileTree(node.children, fullPath));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return files;
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
const PROJECT_ROOT = process.cwd();
|
|
4
|
+
export function resolveSafePath(userPath) {
|
|
5
|
+
userPath = userPath.trim();
|
|
6
|
+
const candidatePath = path.resolve(PROJECT_ROOT, userPath);
|
|
7
|
+
let realCandidatePath;
|
|
8
|
+
try {
|
|
9
|
+
realCandidatePath = fs.realpathSync(candidatePath);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error("Path does not exist or cannot be accessed");
|
|
13
|
+
}
|
|
14
|
+
if (realCandidatePath !== PROJECT_ROOT && !realCandidatePath.startsWith(PROJECT_ROOT + path.sep)) {
|
|
15
|
+
throw new Error("Path escapes project root");
|
|
16
|
+
}
|
|
17
|
+
return realCandidatePath;
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@clack/prompts": "^0.7.0",
|
|
4
|
+
"@google/generative-ai": "^0.24.1",
|
|
5
|
+
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
6
|
+
"dotenv": "^17.2.3",
|
|
7
|
+
"chalk": "^5.3.0",
|
|
8
|
+
"figlet": "^1.9.4",
|
|
9
|
+
"zod": "^3.25.76"
|
|
10
|
+
},
|
|
11
|
+
"name": "@twahaa/codefinder",
|
|
12
|
+
"version": "1.0.0",
|
|
13
|
+
"description": "MCP server and interactive Gemini client for exploring and searching codebases.",
|
|
14
|
+
"main": "build/server.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./build/server.js",
|
|
17
|
+
"./client": "./build/client.js"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@modelcontextprotocol/inspector": "^0.18.0",
|
|
22
|
+
"@types/node": "^25.0.3",
|
|
23
|
+
"@types/figlet": "^1.5.8",
|
|
24
|
+
"tsx": "^4.21.0",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"clean": "rm -rf build",
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"prepare": "npm run build",
|
|
31
|
+
"client:dev": "tsx src/client.ts",
|
|
32
|
+
"search": "tsx src/cli/search.ts",
|
|
33
|
+
"server:test": "echo \"Error: no test specified\" && exit 1",
|
|
34
|
+
"server:build": "tsc",
|
|
35
|
+
"server:build:watch": "tsc --watch",
|
|
36
|
+
"server:dev": "tsx src/server.ts",
|
|
37
|
+
"server:inspect": "DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector npm run server:dev"
|
|
38
|
+
},
|
|
39
|
+
"bin": {
|
|
40
|
+
"codefinder": "build/client.js"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"build",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"keywords": [],
|
|
48
|
+
"author": "",
|
|
49
|
+
"license": "ISC"
|
|
50
|
+
}
|