diff --git a/src/acp-agent.ts b/src/acp-agent.ts index b89f9af8..88ffa248 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -54,6 +54,8 @@ import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/re import packageJson from "../package.json" with { type: "json" }; import { randomUUID } from "node:crypto"; +export const CLAUDE_CONFIG_DIR = process.env.CLAUDE ?? path.join(os.homedir(), ".claude"); + /** * Logger interface for customizing logging output */ diff --git a/src/mcp-server.ts b/src/mcp-server.ts index cf205df5..36f81d55 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,8 +1,22 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + BashInput, + BashOutputInput, + FileEditInput, + FileReadInput, + FileWriteInput, + KillShellInput, +} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js"; import { z } from "zod"; -import { ClaudeAcpAgent } from "./acp-agent.js"; -import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk"; +import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js"; +import { + ClientCapabilities, + ReadTextFileResponse, + TerminalOutputResponse, +} from "@agentclientprotocol/sdk"; import * as diff from "diff"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js"; @@ -40,6 +54,62 @@ export function createMcpServer( sessionId: string, clientCapabilities: ClientCapabilities | undefined, ): McpServer { + /** + * This checks if a given path is related to internal agent persistence and if the agent should be allowed to read/write from here. + * We let the agent do normal fs operations on these paths so that it can persist its state. + * However, we block access to settings files for security reasons. + */ + function internalPath(file_path: string) { + return ( + file_path.startsWith(CLAUDE_CONFIG_DIR) && + !file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "settings.json")) && + !file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "session-env")) + ); + } + + async function readTextFile(input: FileReadInput): Promise { + if (internalPath(input.file_path)) { + const content = await fs.readFile(input.file_path, "utf8"); + + // eslint-disable-next-line eqeqeq + if (input.offset != null || input.limit != null) { + const lines = content.split("\n"); + + // Apply offset and limit if provided + const offset = input.offset ?? 1; + const limit = input.limit ?? lines.length; + + // Extract the requested lines (offset is 1-based) + const startIndex = Math.max(0, offset - 1); + const endIndex = Math.min(lines.length, startIndex + limit); + const selectedLines = lines.slice(startIndex, endIndex); + + return { content: selectedLines.join("\n") }; + } else { + return { content }; + } + } + + return agent.readTextFile({ + sessionId, + path: input.file_path, + line: input.offset, + limit: input.limit, + }); + } + + async function writeTextFile(input: FileWriteInput): Promise { + if (internalPath(input.file_path)) { + await fs.writeFile(input.file_path, input.content, "utf8"); + } else { + await agent.writeTextFile({ + sessionId, + path: input.file_path, + content: input.content, + }); + } + } + // Create MCP server const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } }); @@ -87,7 +157,7 @@ Usage: idempotentHint: false, }, }, - async (input) => { + async (input: FileReadInput) => { try { const session = agent.sessions[sessionId]; if (!session) { @@ -101,12 +171,7 @@ Usage: }; } - const readResponse = await agent.readTextFile({ - sessionId, - path: input.file_path, - line: input.offset, - limit: input.limit, - }); + const readResponse = await readTextFile(input); if (typeof readResponse?.content !== "string") { throw new Error(`No file contents for ${input.file_path}.`); @@ -117,7 +182,7 @@ Usage: // Construct informative message about what was read let readInfo = ""; - if (input.offset > 1 || result.wasLimited) { + if ((input.offset && input.offset > 1) || result.wasLimited) { readInfo = "\n\n"; if (result.wasLimited) { @@ -185,7 +250,7 @@ Usage: idempotentHint: false, }, }, - async (input) => { + async (input: FileWriteInput) => { try { const session = agent.sessions[sessionId]; if (!session) { @@ -198,11 +263,7 @@ Usage: ], }; } - await agent.writeTextFile({ - sessionId, - path: input.file_path, - content: input.content, - }); + await writeTextFile(input); return { content: [], @@ -246,7 +307,7 @@ Usage: .boolean() .default(false) .optional() - .describe("Replace all occurences of old_string (default false)"), + .describe("Replace all occurrences of old_string (default false)"), }, annotations: { title: "Edit file", @@ -256,7 +317,7 @@ Usage: idempotentHint: false, }, }, - async (input) => { + async (input: FileEditInput) => { try { const session = agent.sessions[sessionId]; if (!session) { @@ -270,9 +331,8 @@ Usage: }; } - const readResponse = await agent.readTextFile({ - sessionId, - path: input.file_path, + const readResponse = await readTextFile({ + file_path: input.file_path, }); if (typeof readResponse?.content !== "string") { @@ -289,11 +349,7 @@ Usage: const patch = diff.createPatch(input.file_path, readResponse.content, newContent); - await agent.writeTextFile({ - sessionId, - path: input.file_path, - content: newContent, - }); + await writeTextFile({ file_path: input.file_path, content: newContent }); return { content: [ @@ -327,10 +383,7 @@ Usage: In sessions with ${toolNames.bash} always use it instead of Bash`, inputSchema: { command: z.string().describe("The command to execute"), - timeout: z - .number() - .default(2 * 60 * 1000) - .describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`), + timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`), description: z.string().optional() .describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples: Input: ls @@ -352,7 +405,7 @@ Output: Create directory 'foo'`), ), }, }, - async (input, extra) => { + async (input: BashInput, extra) => { const session = agent.sessions[sessionId]; if (!session) { return { @@ -406,7 +459,7 @@ Output: Create directory 'foo'`), const statusPromise = Promise.race([ handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })), abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })), - sleep(input.timeout).then(async () => { + sleep(input.timeout ?? 2 * 60 * 1000).then(async () => { if (agent.backgroundTerminals[handle.id]?.status === "started") { await handle.kill(); } @@ -475,23 +528,23 @@ Output: Create directory 'foo'`), { title: unqualifiedToolNames.bashOutput, description: `- Retrieves output from a running or completed background bash shell -- Takes a shell_id parameter identifying the shell +- Takes a bash_id parameter identifying the shell - Always returns only new output since the last check - Returns stdout and stderr output along with shell status - Use this tool when you need to monitor or check the output of a long-running shell In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`, inputSchema: { - shell_id: z + bash_id: z .string() .describe(`The id of the background bash command as returned by \`${toolNames.bash}\``), }, }, - async (input) => { - const bgTerm = agent.backgroundTerminals[input.shell_id]; + async (input: BashOutputInput) => { + const bgTerm = agent.backgroundTerminals[input.bash_id]; if (!bgTerm) { - throw new Error(`Unknown shell ${input.shell_id}`); + throw new Error(`Unknown shell ${input.bash_id}`); } if (bgTerm.status === "started") { @@ -542,7 +595,7 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`, .describe(`The id of the background bash command as returned by \`${toolNames.bash}\``), }, }, - async (input) => { + async (input: KillShellInput) => { const bgTerm = agent.backgroundTerminals[input.shell_id]; if (!bgTerm) {