Compare commits

...

22 Commits

Author SHA1 Message Date
Ben Brandt
4211f8cf05 Update to claude-agent-sdk 0.1.51 (#178)
Adds support for Opus 4.5
2025-11-24 23:24:34 +01:00
Ben Brandt
ac2119a23a More forgiving unreachable (#173)
Currently, if we get an unexpected message, we crash. While this is fine
in the world where we vendor CC (which we do by default) we also allow
users to override the executable for cases where their work needs a
wrapper.

This makes it so that we just log the event that we get, but don't crash
right away in case we can continue on.
2025-11-24 12:17:11 +00:00
Ben Brandt
427117dce6 Update to claude-agent-sdk 0.1.50 (#171) 2025-11-24 12:05:38 +00:00
Paul Nelson
b5a7e0f28d Fix read/edit tool error handling so upstream errors surface (#163)
Fixes #162:
- guard both tool callbacks so they return the host-provided error
instead of crashing on missing content
- skip writeTextFile in the edit tool when the initial read fails,
mirroring the read tool behavior
- add regression tests

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-24 11:37:18 +01:00
Ben Brandt
8ef0b4e4c6 Update to claude-agent-sdk 0.1.46 (#168) 2025-11-19 08:42:32 +00:00
Ben Brandt
959cd6c8e2 Disable experimental terminal auth support for now (#167)
This was causing issues on Windows in the current form, and also has
some other issues that need looking into. Will revisit later.
2025-11-19 08:37:59 +00:00
Ben Brandt
ef209d9d12 Don't special-case "default" model id (#166)
A model of id "default" only shows up sometimes in certain plans. So
rather than special-case this one, we just take the first one in the
list.
2025-11-18 10:51:20 +00:00
Ben Brandt
91b00b71e3 Report errors from "status" messages (#165)
The "status" message may say "success" but can actually have an error
which we weren't surfacing.

We should now be returning a more helpful message to the user.

Closes #161 and possibly #146
2025-11-18 10:30:30 +00:00
Ben Brandt
101a527732 Update to claude-agent-sdk 0.1.44 (#164) 2025-11-18 09:59:41 +00:00
Amos Wenger
59c5a0a8b8 Add experimental support for disabling built-in tools via _meta flag (#158)
Add ability to disable built-in tools (Read, Write, Edit, Bash, etc.)
when `_meta.disableBuiltInTools` is set to true. This allows clients to
rely solely on MCP tools without the built-in tool implementations.

---

This is just my take on #157 — not necessarily the one that makes sense
/cc @benbrandt

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 10:31:59 +00:00
Ben Brandt
5133e2dec7 Update to claude-agent-sdk 0.1.42 (#160) 2025-11-17 10:24:02 +00:00
Tim Visher
ed035da9d8 Add title field to tool permission requests (#159)
🤖 Co-authored-by: Claude (Anthropic AI Assistant)

## Problem

Tool approval dialogs in ACP clients (like Emacs
[agent-shell](https://github.com/xenodium/agent-shell)) were showing up
empty because the `toolCall` object in `session/request_permission`
requests was missing the `title` field.

This caused dialogs to appear with just buttons and no description of
what tool is being approved, making it impossible for users to make
informed decisions.

## Solution

Add the `title` field to both `requestPermission` calls (lines 553 and
612) by using the existing `toolInfoFromToolUse()` function which
already generates appropriate titles for all tool types.

## Before
```
  ╭─
      ⚠ Tool Permission ⚠
      
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## After
```
  ╭─
      ⚠ Tool Permission ⚠
      
      Write /path/to/file.txt
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## References

- This agent-shell.el issue shows (as has been shown above) what the
expected interaction would look like and what this changeset supports:
https://github.com/xenodium/agent-shell/issues/123
- The ACP protocol already supports the `title` field and agent-shell
displays it correctly when present (confirmed with OpenCode Agent which
does send titles)

## Testing

- **Manual testing**: Confirmed with agent-shell that permission dialogs
now show descriptive titles like "Write /path/to/file.txt"
- **Unit tests**: Added test coverage that verifies the `title` field is
present in permission request structures for multiple tool types (Write,
Bash, Read)
- **Regression testing**: Verified the new test fails when the `title`
field is omitted, confirming it catches the bug this PR fixes
- **Existing tests**: All 61 existing unit tests pass

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 11:19:22 +01:00
Ben Brandt
8df7f10058 Use better file path resolution for experimental auth support (#156) 2025-11-14 12:21:49 +01:00
Ben Brandt
6a0d0b5522 Fix incorrect stop reason after tool call rejection (#153)
We were mapping tool call rejections to actual "refusals" which isn't
quite right in ACP.

Also filters out more of these status messages that Claude is returning.
2025-11-13 11:19:48 +01:00
Ben Brandt
7e0d2c9506 Update to claude-agent-sdk 0.1.37 (#152) 2025-11-13 09:56:19 +00:00
Guillaume Sabran
2835f030cf Add metadata to tool calls, including structured tool output (#128)
Use the `_meta` field for tool calls to forward:
- the original tool name (ie `Bash` etc)
- the structured output of the tool call

The output is received by using the `PostToolUse` hook from the SDK. The
`tool_call_update` for the tool result is now only sent when both the
hook callback and the original tool result (plain text) have been
received.

Claude Code describes [in its documentation
](https://docs.claude.com/en/api/agent-sdk/typescript#tool-output-types)the
expected structure of the output for each tools. Unfortunately those are
not part of the SDK, and [empirically I've found that there was some
differences](58c3100a36/local-server/src/server/endpoints/sendMessage/acp/clients/claudeCode/types.ts (L265)).
Still I've found that having this structured output was useful for my
client to provide a richer UX. Maybe Zed will too.

I was not sure how to add tests for this change, given the current setup
2025-11-13 10:42:21 +01:00
Ben Brandt
2710e146f4 Use vendored claude cli for experimental login method (#143) 2025-11-05 11:36:02 +01:00
Richard Feldman
bf396fea67 Use terminal-auth from _meta if supported (#141) 2025-11-04 21:34:16 -05:00
Ben Brandt
84b5744a2f prep v0.10.0 (#140) 2025-11-04 11:30:01 +00:00
Ben Brandt
b213152a94 Migrate from permission MCP server to canUseTool callback (#139)
- **Migrate from permission MCP server to canUseTool callback**
- **Use SDK suggestions for permission updates**
2025-11-04 12:26:25 +01:00
Ben Brandt
cd5aabdd74 Update to claude-agent-sdk 0.1.30 (#138) 2025-11-04 10:38:20 +00:00
Bennet Bo Fenner
9e18a4e6fd Add docs for release process (#135) 2025-10-31 18:12:11 +01:00
11 changed files with 1200 additions and 1174 deletions

View File

@@ -1,5 +1,48 @@
# Changelog
## 0.10.8
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
## 0.10.7
- Fix read/edit tool error handling so upstream errors surface
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
## 0.10.6
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
## 0.10.5
- Better error messages at end of turn if there were any
- Add experimental support for disabling built-in tools via \_meta flag
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
## 0.10.4
- Fix tool call titles not appearing during approval in some cases
- Update to @anthropic-ai/claude-agent-sdk@v0.1.42
## 0.10.3
- Fix for experimental terminal auth support
## 0.10.2
- Fix incorrect stop reason for tool call refusals
## 0.10.1
- Add additional structured metadata to tool calls
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
## 0.10.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
## 0.9.0
- Support slash commands coming from MCP servers (Prompts)

24
docs/RELEASES.md Normal file
View File

@@ -0,0 +1,24 @@
## How to publish a new release
1. Create a new branch prep-vX.X.X
```sh
git checkout -b prep-vX.X.X
```
2. Bump the version in package.json
```sh
npm version vX.X.X
```
3. Add a new entry in CHANGELOG.md
4. Create a PR for `prev-vX.X.X` & merge it
5. Create tag and push it
```sh
git tag vX.X.X
git push origin vX.X.X
```
6. Paste CHANGELOG into release notes and publish the version on GitHub

1439
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.9.0",
"version": "0.10.8",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
@@ -52,29 +52,25 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.27",
"@anthropic-ai/claude-code": "2.0.27",
"@modelcontextprotocol/sdk": "1.20.2",
"@anthropic-ai/claude-agent-sdk": "0.1.51",
"@anthropic-ai/claude-code": "2.0.51",
"@modelcontextprotocol/sdk": "1.22.0",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.67.0",
"@types/express": "5.0.4",
"@types/minimist": "1.2.5",
"@types/node": "24.9.1",
"@anthropic-ai/sdk": "0.71.0",
"@types/node": "24.10.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@vitest/ui": "4.0.3",
"eslint": "9.38.0",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@vitest/ui": "4.0.13",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"globals": "16.5.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.3"
"vitest": "4.0.13"
}
}

View File

@@ -26,6 +26,7 @@ import {
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import {
CanUseTool,
McpServerConfig,
Options,
PermissionMode,
@@ -40,18 +41,14 @@ import * as os from "node:os";
import { v7 as uuidv7 } from "uuid";
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
import { SessionNotification } from "@agentclientprotocol/sdk";
import {
createMcpServer,
createPermissionMcpServer,
PERMISSION_TOOL_NAME,
toolNames,
} from "./mcp-server.js";
import { AddressInfo } from "node:net";
import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js";
import {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
ClaudePlanEntry,
registerHookCallback,
postToolUseHook,
} from "./tools.js";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
@@ -75,6 +72,16 @@ type BackgroundTerminal =
pendingOutput: TerminalOutputResponse;
};
/**
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
*/
export type ToolUpdateMeta = {
/* The name of the tool that was used in Claude Code. */
toolName: string;
/* The structured output provided by Claude Code. */
toolResponse?: unknown;
};
type ToolUseCache = {
[key: string]: {
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
@@ -84,8 +91,6 @@ type ToolUseCache = {
};
};
const DEFAULT_MODEL_ID = "default";
// Bypass Permissions doesn't work if we are a root/sudo user
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
@@ -109,6 +114,27 @@ export class ClaudeAcpAgent implements Agent {
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
// Default authMethod
const authMethod: any = {
description: "Run `claude /login` in the terminal",
name: "Log in with Claude Code",
id: "claude-login",
};
// If client supports terminal-auth capability, use that instead.
// if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
// const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
// authMethod._meta = {
// "terminal-auth": {
// command: "node",
// args: [cliPath, "/login"],
// label: "Claude Code Login",
// },
// };
// }
return {
protocolVersion: 1,
agentCapabilities: {
@@ -126,13 +152,7 @@ export class ClaudeAcpAgent implements Agent {
title: "Claude Code",
version: packageJson.version,
},
authMethods: [
{
description: "Run `claude /login` in the terminal",
name: "Log in with Claude Code",
id: "claude-login",
},
],
authMethods: [authMethod],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
@@ -170,23 +190,15 @@ export class ClaudeAcpAgent implements Agent {
}
}
const server = createMcpServer(this, sessionId, this.clientCapabilities);
mcpServers["acp"] = {
type: "sdk",
name: "acp",
instance: server,
};
// Ideally replace with `canUseTool`
const permissionServer = await createPermissionMcpServer(this, sessionId);
const address = permissionServer.address() as AddressInfo;
mcpServers["acpPermission"] = {
type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp",
headers: {
"x-acp-proxy-session-id": sessionId,
},
};
// Only add the acp MCP server if built-in tools are not disabled
if (!params._meta?.disableBuiltInTools) {
const server = createMcpServer(this, sessionId, this.clientCapabilities);
mcpServers["acp"] = {
type: "sdk",
name: "acp",
instance: server,
};
}
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
@@ -214,7 +226,7 @@ export class ClaudeAcpAgent implements Agent {
// But it doesn't work in root mode, so we only activate it if it will work.
allowDangerouslySkipPermissions: !IS_ROOT,
permissionMode,
permissionPromptToolName: PERMISSION_TOOL_NAME,
canUseTool: this.canUseTool(sessionId),
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
// here works to find zed's managed node version.
@@ -222,20 +234,60 @@ export class ClaudeAcpAgent implements Agent {
...(process.env.CLAUDE_CODE_EXECUTABLE && {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
hooks: {
PostToolUse: [
{
hooks: [postToolUseHook],
},
],
},
};
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
// Check if built-in tools should be disabled
const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
if (!disableBuiltInTools) {
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
}
} else {
// When built-in tools are disabled, explicitly disallow all of them
disallowedTools.push(
toolNames.read,
toolNames.write,
toolNames.edit,
toolNames.bash,
toolNames.bashOutput,
toolNames.killShell,
"Read",
"Write",
"Edit",
"Bash",
"BashOutput",
"KillShell",
"Glob",
"Grep",
"Task",
"TodoWrite",
"ExitPlanMode",
"WebSearch",
"WebFetch",
"AskUserQuestion",
"SlashCommand",
"Skill",
"NotebookEdit",
);
}
if (allowedTools.length > 0) {
@@ -335,12 +387,13 @@ export class ClaudeAcpAgent implements Agent {
case "init":
break;
case "compact_boundary":
break;
case "hook_response":
case "status":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
unreachable(message);
break;
}
break;
case "result": {
@@ -348,21 +401,39 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "cancelled" };
}
// todo!() how is rate-limiting handled?
switch (message.subtype) {
case "success": {
if (message.result.includes("Please run /login")) {
throw RequestError.authRequired();
}
if (message.is_error) {
throw RequestError.internalError(undefined, message.result);
}
return { stopReason: "end_turn" };
}
case "error_during_execution":
return { stopReason: "refusal" };
if (message.is_error) {
throw RequestError.internalError(
undefined,
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "end_turn" };
case "error_max_budget_usd":
case "error_max_turns":
case "error_max_structured_output_retries":
if (message.is_error) {
throw RequestError.internalError(
undefined,
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "max_turn_requests" };
default:
return { stopReason: "refusal" };
unreachable(message);
break;
}
break;
}
case "stream_event": {
for (const notification of streamEventToAcpNotifications(
@@ -370,6 +441,7 @@ export class ClaudeAcpAgent implements Agent {
params.sessionId,
this.toolUseCache,
this.fileContentCache,
this.client,
)) {
await this.client.sessionUpdate(notification);
}
@@ -399,7 +471,13 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
if (message.type === "user" && typeof message.message.content === "string") {
if (
message.type === "user" &&
(typeof message.message.content === "string" ||
(Array.isArray(message.message.content) &&
message.message.content.length === 1 &&
message.message.content[0].type === "text"))
) {
break;
}
@@ -426,13 +504,19 @@ export class ClaudeAcpAgent implements Agent {
params.sessionId,
this.toolUseCache,
this.fileContentCache,
this.client,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
case "tool_progress":
break;
case "auth_status":
break;
default:
unreachable(message);
break;
}
}
throw new Error("Session did not end in result");
@@ -491,14 +575,137 @@ export class ClaudeAcpAgent implements Agent {
this.fileContentCache[params.path] = params.content;
return response;
}
canUseTool(sessionId: string): CanUseTool {
return async (toolName, toolInput, { suggestions, toolUseID }) => {
const session = this.sessions[sessionId];
if (!session) {
return {
behavior: "deny",
message: "Session not found",
interrupt: true,
};
}
if (toolName === "ExitPlanMode") {
const response = await this.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
],
sessionId,
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
.title,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
) {
session.permissionMode = response.outcome.optionId;
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
},
});
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
],
};
} else {
return {
behavior: "deny",
message: "User rejected request to exit plan mode.",
interrupt: true,
};
}
}
if (
session.permissionMode === "bypassPermissions" ||
(session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))
) {
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
],
};
}
const response = await this.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Always Allow",
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
],
sessionId,
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
.title,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
// If Claude Code has suggestions, it will update their settings already
if (response.outcome.optionId === "allow_always") {
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{
type: "addRules",
rules: [{ toolName }],
behavior: "allow",
destination: "session",
},
],
};
}
return {
behavior: "allow",
updatedInput: toolInput,
};
} else {
return {
behavior: "deny",
message: "User refused permission to run tool",
interrupt: true,
};
}
};
}
}
async function getAvailableModels(query: Query): Promise<SessionModelState> {
const models = await query.supportedModels();
// Query doesn't give us access to the currently selected model, so we default to "default" if it is there,
// otherwise we just choose the first model in the list.
const currentModel = models.find((model) => model.value === DEFAULT_MODEL_ID) || models[0];
// Query doesn't give us access to the currently selected model, so we just choose the first model in the list.
const currentModel = models[0];
await query.setModel(currentModel.value);
const availableModels = models.map((model) => ({
@@ -647,6 +854,7 @@ export function toAcpNotifications(
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
): SessionNotification[] {
if (typeof content === "string") {
return [
@@ -712,6 +920,33 @@ export function toAcpNotifications(
};
}
} else {
// Register hook callback to receive the structured output from the hook
registerHookCallback(chunk.id, {
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
const toolUse = toolUseCache[toolUseId];
if (toolUse) {
const update: SessionNotification["update"] = {
_meta: {
claudeCode: {
toolResponse,
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
toolCallId: toolUseId,
sessionUpdate: "tool_call_update",
};
await client.sessionUpdate({
sessionId,
update,
});
} else {
console.error(
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
);
}
},
});
let rawInput;
try {
rawInput = JSON.parse(JSON.stringify(chunk.input));
@@ -719,6 +954,11 @@ export function toAcpNotifications(
// ignore if we can't turn it to JSON
}
update = {
_meta: {
claudeCode: {
toolName: chunk.name,
} satisfies ToolUpdateMeta,
},
toolCallId: chunk.id,
sessionUpdate: "tool_call",
rawInput,
@@ -730,6 +970,7 @@ export function toAcpNotifications(
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
@@ -746,6 +987,11 @@ export function toAcpNotifications(
if (toolUse.name !== "TodoWrite") {
update = {
_meta: {
claudeCode: {
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
@@ -766,6 +1012,7 @@ export function toAcpNotifications(
default:
unreachable(chunk);
break;
}
if (update) {
output.push({ sessionId, update });
@@ -780,6 +1027,7 @@ export function streamEventToAcpNotifications(
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
): SessionNotification[] {
const event = message.event;
switch (event.type) {
@@ -790,6 +1038,7 @@ export function streamEventToAcpNotifications(
sessionId,
toolUseCache,
fileContentCache,
client,
);
case "content_block_delta":
return toAcpNotifications(
@@ -798,6 +1047,7 @@ export function streamEventToAcpNotifications(
sessionId,
toolUseCache,
fileContentCache,
client,
);
// No content
case "message_start":
@@ -808,6 +1058,7 @@ export function streamEventToAcpNotifications(
default:
unreachable(event);
return [];
}
}

View File

@@ -4,6 +4,7 @@ export {
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
} from "./acp-agent.js";
export {
loadManagedSettings,
@@ -13,12 +14,7 @@ export {
Pushable,
unreachable,
} from "./utils.js";
export {
createMcpServer,
createPermissionMcpServer,
PERMISSION_TOOL_NAME,
toolNames,
} from "./mcp-server.js";
export { createMcpServer, toolNames } from "./mcp-server.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
// Export types

View File

@@ -1,15 +1,10 @@
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
import * as diff from "diff";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
export const SYSTEM_REMINDER = `
@@ -38,7 +33,7 @@ export const toolNames = {
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
};
const editToolNames = [toolNames.edit, toolNames.write];
export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write];
export function createMcpServer(
agent: ClaudeAcpAgent,
@@ -106,15 +101,19 @@ Usage:
};
}
const content = await agent.readTextFile({
const readResponse = await agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
const result = extractLinesWithByteLimit(readResponse.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
@@ -258,47 +257,62 @@ Usage:
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const readResponse = await agent.readTextFile({
sessionId,
path: input.file_path,
});
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: "The user has left the building",
text: patch,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Editing file failed: " + (error?.message ?? String(error)),
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: patch,
},
],
};
},
);
}
@@ -568,7 +582,8 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
return unreachable(bgTerm);
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
},
@@ -578,189 +593,6 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
return server;
}
const UNQUALIFIED_PERMISSION_TOOL_NAME = "permission";
const PERMISSION_SERVER_PREFIX = "mcp__acpPermission__";
export const PERMISSION_TOOL_NAME = PERMISSION_SERVER_PREFIX + UNQUALIFIED_PERMISSION_TOOL_NAME;
/**
* Separate permission tool. Needs to be an HTTP server for now, because Claude Code doesn't
* support permission tools via SDK servers.
* Ideally it moves to the `canUseTool` callback in the future.
*/
export function createPermissionMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
): Promise<Server> {
const server = new McpServer(
{ name: "acpPermission", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
const alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(
UNQUALIFIED_PERMISSION_TOOL_NAME,
{
title: "Permission Tool",
description: "Used to request tool permissions",
inputSchema: {
tool_name: z.string(),
input: z.any(),
tool_use_id: z.string().optional(),
},
},
async (input) => {
const result = await canUseTool(input);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
},
);
async function canUseTool(input: {
tool_use_id?: string;
tool_name: string;
input?: any;
}): Promise<PermissionResult> {
const session = agent.sessions[sessionId];
if (!session) {
return {
behavior: "deny",
message: "Session not found",
};
}
if (input.tool_name === "ExitPlanMode") {
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
],
sessionId,
toolCall: {
toolCallId: input.tool_use_id!,
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
) {
session.permissionMode = response.outcome.optionId;
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
},
});
return {
behavior: "allow",
updatedInput: input.input,
updatedPermissions: [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
],
};
} else {
return {
behavior: "deny",
message: "User rejected request to exit plan mode.",
};
}
}
if (
session.permissionMode === "bypassPermissions" ||
(session.permissionMode === "acceptEdits" && editToolNames.includes(input.tool_name)) ||
alwaysAllowedTools[input.tool_name]
) {
return {
behavior: "allow",
updatedInput: input.input,
};
}
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Always Allow",
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
],
sessionId,
toolCall: {
toolCallId: input.tool_use_id!,
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
return {
behavior: "allow",
updatedInput: input.input,
};
} else {
return {
behavior: "deny",
message: "User refused permission to run tool",
};
}
}
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
try {
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: `Internal server error: ${error}`,
},
id: null,
});
}
}
});
return new Promise((resolve, reject) => {
const listener = app.listen(0, "127.0.0.1", (error) => {
if (error) {
reject(error);
return;
}
resolve(listener);
});
});
}
function stripCommonPrefix(a: string, b: string): string {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AgentSideConnection,
AvailableCommand,
Client,
ClientSideConnection,
@@ -650,7 +651,14 @@ describe("tool conversions", () => {
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(
toAcpNotifications(received.message.content, received.message.role, "test", {}, {}),
toAcpNotifications(
received.message.content,
received.message.role,
"test",
{},
{},
{} as AgentSideConnection,
),
).toStrictEqual([
{
sessionId: "test",
@@ -833,3 +841,60 @@ describe("SDK behavior", () => {
10000,
);
});
describe("permission requests", () => {
it("should include title field in tool permission request structure", () => {
// Test various tool types to ensure title is correctly generated
const testCases = [
{
toolUse: {
type: "tool_use" as const,
id: "test-1",
name: "Write",
input: { file_path: "/test/file.txt", content: "test" },
},
expectedTitlePart: "/test/file.txt",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-2",
name: "Bash",
input: { command: "ls -la", description: "List files" },
},
expectedTitlePart: "`ls -la`",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-3",
name: "mcp__acp__Read",
input: { file_path: "/test/data.json" },
},
expectedTitlePart: "/test/data.json",
},
];
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
// Verify the structure that our fix creates for requestPermission
const requestStructure = {
toolCall: {
toolCallId: testCase.toolUse.id,
rawInput: testCase.toolUse.input,
title: toolInfo.title, // This is what commit 1785d86 adds
},
};
// Ensure the title field is present and populated
expect(requestStructure.toolCall.title).toBeDefined();
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
}
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { createMcpServer } from "../mcp-server.js";
const SESSION_ID = "session-id";
function getTool(server: any, name: string) {
const tools = server?._registeredTools;
if (!tools || !tools[name]) {
throw new Error(`Tool ${name} not registered`);
}
return tools[name];
}
function createAgent(overrides: Record<string, unknown> = {}) {
return {
sessions: {
[SESSION_ID]: {},
},
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
backgroundTerminals: {},
clientCapabilities: {},
client: {
sessionUpdate: vi.fn(),
createTerminal: vi.fn(),
requestPermission: vi.fn(),
},
...overrides,
};
}
describe("MCP server read-related tools", () => {
it("reports missing file contents instead of crashing the read tool", async () => {
const agent = createAgent();
agent.readTextFile.mockResolvedValue({});
const server = createMcpServer(agent as any, SESSION_ID, {
fs: { readTextFile: true },
});
const readTool = getTool(server, "Read");
const result = await readTool.callback({
file_path: "/tmp/file.txt",
offset: 1,
limit: 2000,
});
expect(result.content[0].text).toBe("Reading file failed: No file contents for /tmp/file.txt.");
});
it("reports missing contents inside the edit tool and skips writes", async () => {
const agent = createAgent();
agent.readTextFile.mockResolvedValue({});
const server = createMcpServer(agent as any, SESSION_ID, {
fs: { readTextFile: true, writeTextFile: true },
});
const editTool = getTool(server, "Edit");
const result = await editTool.callback({
file_path: "/tmp/file.txt",
old_string: "foo",
new_string: "bar",
replace_all: false,
});
expect(result.content[0].text).toBe("Editing file failed: No file contents for /tmp/file.txt.");
expect(agent.writeTextFile).not.toHaveBeenCalled();
});
});

View File

@@ -6,9 +6,11 @@ import {
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
interface ToolInfo {
title: string;
@@ -403,7 +405,8 @@ export function toolUpdateFromToolResult(
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam,
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
@@ -538,3 +541,50 @@ export function markdownEscape(text: string): string {
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
const toolUseCallbacks: {
[toolUseId: string]: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
};
} = {};
/* Setup callbacks that will be called when receiving hooks from Claude Code */
export const registerHookCallback = (
toolUseID: string,
{
onPostToolUseHook,
}: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
},
) => {
toolUseCallbacks[toolUseID] = {
onPostToolUseHook,
};
};
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const postToolUseHook: HookCallback = async (
input: any,
toolUseID: string | undefined,
): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse" && toolUseID) {
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
if (onPostToolUseHook) {
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
} else {
console.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
}
return { continue: true };
};

View File

@@ -75,14 +75,14 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
});
}
export function unreachable(value: never): never {
export function unreachable(value: never) {
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
throw new Error(`Unexpected case: ${valueAsString}`);
console.error(`Unexpected case: ${valueAsString}`);
}
export function sleep(time: number): Promise<void> {