import "./arkConfig.ts"; import { createServer } from "node:net"; // this must be imported first import { FastMCP, type Tool } from "fastmcp"; import type { Agent } from "../agents/index.ts"; import { ghPullfrogMcpName } from "../external.ts"; import type { Mode } from "../modes.ts"; import type { PrepResult } from "../prep/index.ts"; import type { OctokitWithPlugins } from "../utils/github.ts"; import type { ResolvedPayload } from "../utils/payload.ts"; export type BackgroundProcess = { pid: number; outputPath: string; pidPath: string; }; export type StoredPushDest = { remoteName: string; remoteBranch: string; localBranch: string; }; export interface ToolState { // where we're allowed to push - base repo initially, fork URL for fork PRs // set by setupGit, updated by checkout_pr. always set before push validation. pushUrl?: string; // push destination set by checkout_pr - used as primary source in push_branch // because git config reads can fail in certain environments pushDest?: StoredPushDest; // issue or PR number (same number space in GitHub) issueNumber?: number; selectedMode?: string; // true while a subagent is running via the delegate tool — prevents recursive delegation delegationActive: boolean; backgroundProcesses: Map; review?: { id: number; nodeId: string; }; dependencyInstallation?: { status: "not_started" | "in_progress" | "completed" | "failed"; promise: Promise | undefined; results: PrepResult[] | undefined; }; // undefined = no comment yet, number = active comment, null = deliberately deleted progressCommentId: number | null | undefined; lastProgressBody?: string; wasUpdated?: boolean; output?: string; } interface InitToolStateParams { progressCommentId: string | undefined; } export function initToolState(params: InitToolStateParams): ToolState { const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN; const resolvedId = Number.isNaN(parsed) ? undefined : parsed; if (resolvedId) { log.info(`» using pre-created progress comment: ${resolvedId}`); } return { progressCommentId: resolvedId, delegationActive: false, backgroundProcesses: new Map(), }; } export interface ToolContext { repo: RunContextData["repo"]; payload: ResolvedPayload; octokit: OctokitWithPlugins; githubInstallationToken: string; gitToken: string; apiToken: string; agent: Agent; modes: Mode[]; postCheckoutScript: string | null; toolState: ToolState; runId: string; jobId: string | undefined; // set after MCP server starts — used by delegate tool to pass URL to subagents mcpServerUrl: string; tmpdir: string; } import { log } from "../utils/cli.ts"; import type { RunContextData } from "../utils/runContextData.ts"; import { BashTool, KillBackgroundTool } from "./bash.ts"; import { CheckoutPrTool } from "./checkout.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { CreateCommentTool, EditCommentTool, ReplyToReviewCommentTool, ReportProgressTool, } from "./comment.ts"; import { CommitInfoTool } from "./commitInfo.ts"; import { DelegateTool } from "./delegate.ts"; import { AwaitDependencyInstallationTool, StartDependencyInstallationTool, } from "./dependencies.ts"; import { FileDeleteTool, FileEditTool, FileReadTool, FileWriteTool, ListDirectoryTool, } from "./file.ts"; import { DeleteBranchTool, GitFetchTool, GitTool, PushBranchTool, PushTagsTool } from "./git.ts"; import { IssueTool } from "./issue.ts"; import { GetIssueCommentsTool } from "./issueComments.ts"; import { GetIssueEventsTool } from "./issueEvents.ts"; import { IssueInfoTool } from "./issueInfo.ts"; import { AddLabelsTool } from "./labels.ts"; import { SetOutputTool } from "./output.ts"; import { CreatePullRequestTool } from "./pr.ts"; import { PullRequestInfoTool } from "./prInfo.ts"; import { CreatePullRequestReviewTool } from "./review.ts"; import { GetReviewCommentsTool, ListPullRequestReviewsTool, ResolveReviewThreadTool, } from "./reviewComments.ts"; import { addTools } from "./shared.ts"; import { UploadFileTool } from "./upload.ts"; const mcpPortStart = 3764; const mcpPortAttempts = 100; const mcpHost = "127.0.0.1"; const mcpEndpoint = "/mcp"; function readEnvPort(): number | null { const rawPort = process.env.PULLFROG_MCP_PORT; if (!rawPort) return null; const parsed = Number.parseInt(rawPort, 10); if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { throw new Error(`invalid PULLFROG_MCP_PORT: ${rawPort}`); } return parsed; } function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = createServer(); server.unref(); server.once("error", () => resolve(false)); server.once("listening", () => { server.close(() => resolve(true)); }); server.listen(port, mcpHost); }); } function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } function isAddressInUse(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); return message.includes("eaddrinuse") || message.includes("address already in use"); } function buildTools(ctx: ToolContext): Tool[] { const tools: Tool[] = [ DelegateTool(ctx), StartDependencyInstallationTool(ctx), AwaitDependencyInstallationTool(ctx), CreateCommentTool(ctx), EditCommentTool(ctx), ReplyToReviewCommentTool(ctx), IssueTool(ctx), IssueInfoTool(ctx), GetIssueCommentsTool(ctx), GetIssueEventsTool(ctx), CreatePullRequestTool(ctx), CreatePullRequestReviewTool(ctx), PullRequestInfoTool(ctx), CommitInfoTool(ctx), CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), ResolveReviewThreadTool(ctx), GetCheckSuiteLogsTool(ctx), AddLabelsTool(ctx), PushBranchTool(ctx), GitTool(ctx), GitFetchTool(ctx), DeleteBranchTool(ctx), PushTagsTool(ctx), UploadFileTool(ctx), SetOutputTool(ctx), FileReadTool(ctx), FileWriteTool(ctx), FileEditTool(ctx), FileDeleteTool(ctx), ListDirectoryTool(ctx), ]; // only add BashTool when bash is "restricted" // - "enabled": native bash only (no MCP bash needed) // - "restricted": MCP bash only (native blocked, env filtered) // - "disabled": no bash at all if (ctx.payload.bash === "restricted") { tools.push(BashTool(ctx)); tools.push(KillBackgroundTool(ctx)); } tools.push(ReportProgressTool(ctx)); return tools; } type McpStartResult = { server: FastMCP; url: string; port: number; }; async function tryStartMcpServer(ctx: ToolContext, port: number): Promise { const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1", }); const tools = buildTools(ctx); addTools(ctx, server, tools); try { await server.start({ transportType: "httpStream", httpStream: { port, host: mcpHost, endpoint: mcpEndpoint, }, }); const url = `http://${mcpHost}:${port}${mcpEndpoint}`; return { server, url, port }; } catch (error) { if (!isAddressInUse(error)) { throw error; } try { await server.stop(); } catch { // ignore cleanup errors on failed start } return null; } } async function selectMcpPort(ctx: ToolContext): Promise { let lastError: unknown = null; const requestedPort = readEnvPort(); if (requestedPort !== null) { if (await isPortAvailable(requestedPort)) { const requestedResult = await tryStartMcpServer(ctx, requestedPort); if (requestedResult) { return requestedResult; } } } // randomize start offset to reduce collision chance in parallel runs const randomOffset = Math.floor(Math.random() * 50); for (let offset = 0; offset < mcpPortAttempts; offset++) { const port = mcpPortStart + randomOffset + offset; try { if (!(await isPortAvailable(port))) { continue; } const result = await tryStartMcpServer(ctx, port); if (result) { return result; } } catch (error) { lastError = error; if (!isAddressInUse(error)) { throw error; } } } const message = getErrorMessage(lastError); throw new Error( `could not find available mcp port starting at ${mcpPortStart} (last error: ${message})` ); } async function killBackgroundProcesses(toolState: ToolState): Promise { const backgroundProcesses = toolState.backgroundProcesses; if (backgroundProcesses.size === 0) return; for (const proc of backgroundProcesses.values()) { try { process.kill(-proc.pid, "SIGTERM"); } catch { // already dead } } await new Promise((resolve) => setTimeout(resolve, 200)); for (const proc of backgroundProcesses.values()) { try { process.kill(-proc.pid, "SIGKILL"); } catch { // already dead } } backgroundProcesses.clear(); } /** * Start the MCP HTTP server and return the URL and close function */ export async function startMcpHttpServer( ctx: ToolContext ): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise }> { const startResult = await selectMcpPort(ctx); return { url: startResult.url, [Symbol.asyncDispose]: async () => { await killBackgroundProcesses(ctx.toolState); await startResult.server.stop(); }, }; }