219 lines
6.1 KiB
TypeScript
219 lines
6.1 KiB
TypeScript
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { execFileSync } from "node:child_process";
|
|
import { join, resolve, relative } from "node:path";
|
|
import type { Tool } from "ollama";
|
|
import {
|
|
getFileContents,
|
|
listDir as giteaListDir,
|
|
searchCode,
|
|
} from "./gitea.ts";
|
|
import type { PRContext } from "./types.ts";
|
|
|
|
const MAX_FILE_LINES = 200;
|
|
const MAX_GREP_RESULTS = 50;
|
|
|
|
export const TOOLS: Tool[] = [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "read_file",
|
|
description: "Read the contents of a file in the repository.",
|
|
parameters: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description: "Repo-relative path to the file",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "list_dir",
|
|
description: "List files and directories at a path in the repository.",
|
|
parameters: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: { type: "string", description: "Repo-relative directory path" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "find_symbol",
|
|
description:
|
|
"Search for a symbol, function, or pattern across the repository.",
|
|
parameters: {
|
|
type: "object",
|
|
required: ["symbol"],
|
|
properties: {
|
|
symbol: {
|
|
type: "string",
|
|
description: "Symbol name or regex pattern to search for",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
export class ToolServer {
|
|
private _callCount = 0;
|
|
private readonly pr: PRContext;
|
|
private readonly workspace: string | null;
|
|
private readonly maxToolCalls: number;
|
|
|
|
constructor(pr: PRContext, workspace: string | null, maxToolCalls: number) {
|
|
this.pr = pr;
|
|
this.workspace = workspace;
|
|
this.maxToolCalls = maxToolCalls;
|
|
}
|
|
|
|
get tools(): Tool[] {
|
|
return TOOLS;
|
|
}
|
|
|
|
get callCount(): number {
|
|
return this._callCount;
|
|
}
|
|
|
|
get atLimit(): boolean {
|
|
return this._callCount >= this.maxToolCalls;
|
|
}
|
|
|
|
resetCallCount(): void {
|
|
this._callCount = 0;
|
|
}
|
|
|
|
async execute(name: string, args: Record<string, unknown>): Promise<string> {
|
|
if (this._callCount >= this.maxToolCalls) {
|
|
return "[tool call limit reached — conclude your review now]";
|
|
}
|
|
this._callCount++;
|
|
|
|
try {
|
|
switch (name) {
|
|
case "read_file":
|
|
return await this.readFile(String(args["path"] ?? ""));
|
|
case "list_dir":
|
|
return await this.listDir(String(args["path"] ?? ""));
|
|
case "find_symbol":
|
|
return await this.findSymbol(String(args["symbol"] ?? ""));
|
|
default:
|
|
return `[unknown tool: ${name}]`;
|
|
}
|
|
} catch (err) {
|
|
return `[error: ${err instanceof Error ? err.message : String(err)}]`;
|
|
}
|
|
}
|
|
|
|
// Validates path is repo-relative with no traversal or absolute prefix.
|
|
private safePath(path: string): string | null {
|
|
if (!path) return null;
|
|
if (path.startsWith("/") || path.includes("..")) return null;
|
|
return path.replace(/\/+/g, "/").replace(/\/$/, "");
|
|
}
|
|
|
|
private async readFile(path: string): Promise<string> {
|
|
const safe = this.safePath(path);
|
|
if (!safe) return "[invalid path]";
|
|
|
|
if (this.workspace) {
|
|
const full = join(this.workspace, safe);
|
|
// Verify resolved path stays inside workspace
|
|
if (!resolve(full).startsWith(resolve(this.workspace)))
|
|
return "[invalid path]";
|
|
if (!existsSync(full)) return "[file not found]";
|
|
const content = readFileSync(full, "utf8");
|
|
const lines = content.split("\n");
|
|
const truncated = lines.slice(0, MAX_FILE_LINES).join("\n");
|
|
return lines.length > MAX_FILE_LINES
|
|
? `${truncated}\n... (truncated, ${lines.length - MAX_FILE_LINES} lines omitted)`
|
|
: truncated;
|
|
}
|
|
|
|
return await getFileContents(this.pr, safe);
|
|
}
|
|
|
|
private async listDir(path: string): Promise<string> {
|
|
const safe = this.safePath(path || ".");
|
|
if (!safe) return "[invalid path]";
|
|
|
|
if (this.workspace) {
|
|
const full = join(this.workspace, safe);
|
|
if (!resolve(full).startsWith(resolve(this.workspace)))
|
|
return "[invalid path]";
|
|
if (!existsSync(full)) return "[directory not found]";
|
|
const entries = readdirSync(full, { withFileTypes: true });
|
|
return entries
|
|
.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
|
|
.join("\n");
|
|
}
|
|
|
|
const entries = await giteaListDir(this.pr, safe);
|
|
return entries.join("\n");
|
|
}
|
|
|
|
private async findSymbol(symbol: string): Promise<string> {
|
|
if (!symbol) return "[no symbol provided]";
|
|
|
|
if (this.workspace) {
|
|
try {
|
|
// execFileSync avoids shell injection — symbol is passed as an argument, not interpolated
|
|
const out = execFileSync(
|
|
"grep",
|
|
[
|
|
"-r",
|
|
"-n",
|
|
"--include=*.ts",
|
|
"--include=*.tsx",
|
|
"--include=*.js",
|
|
"--include=*.jsx",
|
|
"--include=*.py",
|
|
"--include=*.go",
|
|
"--include=*.rs",
|
|
"--include=*.rb",
|
|
"--include=*.java",
|
|
"--include=*.kt",
|
|
symbol,
|
|
this.workspace,
|
|
],
|
|
{ encoding: "utf8", timeout: 5000, maxBuffer: 256 * 1024 },
|
|
);
|
|
const lines = out
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.slice(0, MAX_GREP_RESULTS);
|
|
// Strip workspace prefix so paths are repo-relative
|
|
return (
|
|
lines.map((l) => l.replace(this.workspace! + "/", "")).join("\n") ||
|
|
"[not found]"
|
|
);
|
|
} catch {
|
|
return "[not found]";
|
|
}
|
|
}
|
|
|
|
const results = await searchCode(this.pr, symbol);
|
|
return results.join("\n") || "[not found]";
|
|
}
|
|
}
|
|
|
|
export function createMcpServer(
|
|
pr: PRContext,
|
|
maxToolCalls: number,
|
|
): ToolServer {
|
|
console.log("Initializing tool server...");
|
|
const ws =
|
|
process.env.GITEA_WORKSPACE ?? process.env.GITHUB_WORKSPACE ?? null;
|
|
const workspace = ws && existsSync(ws) ? ws : null;
|
|
return new ToolServer(pr, workspace, maxToolCalls);
|
|
}
|