Skip to content

Claude Agent SDK

Available · in-process SDK MCP server · a bridge pairs it with its own can_use_tool gate (edits allowed) · pip install "agent-adapter[claude-agent]" (claude-agent-sdk, ~350 MB: it bundles the Claude Code CLI)

Functions

Function (agent_adapter.integrations.claude_agent) Returns
claude_sdk_bridge(registry, port, *, executor=None, name="agent_adapter") ClaudeSdkBridge(name, server, can_use_tool, run_id, hooks): a server, the gate its tools trust, and the hooks that end its tokens with the turn
claude_sdk_server(registry, *, executor=None, name="agent_adapter") a server without a gate: confirm tools are decided by the executor's port
can_use_tool(port, registry, *, server="agent_adapter") a permission-only callback: it issues no tokens, so a confirm tool is asked again by the executor
claude_sdk_tool(spec, *, executor=None), claude_sdk_tools(registry, …) SdkMcpTools (neutral JSON schema), without a gate
INTEGRATION get_integration("claude_agent"); bind_approval(port) asks about every tool, issues no tokens

Claude sees each tool as mcp__<server>__<tool>, where <server> is the bridge's name (use it as the mcp_servers key). Results are MCP text content; values that are not strings are JSON text.

Wiring

executor = Executor(registry)
port = CallbackApproval(reviewer)


async def chat(prompt: str) -> None:
    """One Claude Code session: a fresh bridge, so its run and tokens are this session's."""
    bridge = claude_sdk_bridge(registry, port, executor=executor)
    options = ClaudeAgentOptions(
        # The bridge types these as plain dicts, so the adapter imports without the SDK.
        mcp_servers={bridge.name: cast(McpSdkServerConfig, bridge.server)},  # mcp__<name>__<tool>
        can_use_tool=bridge.can_use_tool,  # its approvals are what the server's tools accept
        hooks=cast(dict[HookEvent, list[HookMatcher]], bridge.hooks),  # tokens end with the turn
    )
    try:
        async with ClaudeSDKClient(options=options) as client:
            await client.query(prompt)
            async for message in client.receive_response():
                print(message)
    finally:
        executor.end_run(bridge.run_id)  # the bridge's run (and its guards) ends here

One bridge = one run

Build a bridge per ClaudeSDKClient session, inside the function that runs it, never at module level. Everything using one bridge shares its run id (so once_per_run and RunLimits.max_calls count across all of it) and its approval tokens. Pass hooks=bridge.hooks, and call executor.end_run(bridge.run_id) when the session ends.

With hooks of your own, merge the lists per event, e.g. {ev: bridge.hooks.get(ev, []) + mine.get(ev, []) for ev in {*bridge.hooks, *mine}}; never {**bridge.hooks, **mine}, which drops the bridge's hooks for the events you also use.

Approval

The CLI asks can_use_tool before a tool runs, unless allowed_tools, settings allow rules, bypassPermissions or a PreToolUse hook already allow it.

sequenceDiagram
  participant C as Claude Code CLI
  participant G as bridge.can_use_tool
  participant P as ApprovalPort
  participant S as bridge.server
  participant Ex as Executor
  C->>G: mcp__agent_adapter__send_invoice, input, tool_use_id
  G->>P: request(tool="send_invoice", original arguments)
  alt approved (maybe edited)
    G->>G: one-use token (this bridge, 60 s)
    G-->>C: PermissionResultAllow(updated_input=edits)
    C->>S: tools/call send_invoice (final input)
    S->>Ex: run_spec(ctx.approval = token, call_id = tool_use_id)
  else denied / pending / no tool_use_id
    G-->>C: PermissionResultDeny(message)
  end
Case What happens
This server's tool, rule says yes the port is asked under the registry name with the original arguments
Approved edits come back as updated_input. The token belongs to this bridge only and is bound to the tool_use_id, mcp__<name>__<tool> and the final validated input. The bridge's server reads the tool_use_id Claude Code sends with tools/call (_meta["claudecode/toolUseId"]) and takes only that call's token; the executor does not ask again
Another call, even an identical one (another tool_use_id, or none) no token: the executor's own port decides
Approved but never executed the token is dropped at the end of the turn (Stop) or at the next prompt (UserPromptSubmit), after 60 s at the latest; a later identical call is asked again
Call reaches the tool without a token (allowed_tools, allow rules, permission modes, another gate, a used or expired token) the executor's own port decides (no port: denied)
Rule says no allowed without asking
Denied PermissionResultDeny with {"error": {"kind": "approval-denied", ...}}
Port raises ApprovalPending denied with approval required: <id>
Other tools (Bash, other MCP servers, a same-named tool on another server) the port is asked under their Claude name; edits pass through; never a token for our tools

Calls and errors

Item Behaviour
Call id the tool_use_id from tools/call _meta → ctx.call_id (bridge servers); claude_sdk_server / claude_sdk_tool get none from the SDK and use a fresh id
Run one run id per bridge, on every request and call; run guards (once_per_run, max_calls) count per bridge
ToolArgsInvalid, ToolError, ApprovalDenied, GuardrailBlocked, RunLimitExceeded is_error: true with {"error": {"kind", "message"}}
Other errors is_error: true with {"error": {"kind", "message"}} on both servers (bridge and claude_sdk_server); an error from outside the adapter shows only its type, never its message
Control flow (CancelledError, registered types) propagates from the handler

The bridge needs mcp 2.x (its server is a low-level mcp.server.lowlevel.Server) and refuses older releases, and claude-agent-sdk outside 0.2.150 to below 0.3, with IntegrationError. Tests run the bundled CLI offline against a scripted Messages API (ANTHROPIC_BASE_URL): stale and other-server approvals, and two identical calls in one turn, never share a token.