diff --git a/src/claude_agent_sdk/_internal/client.py b/src/claude_agent_sdk/_internal/client.py index 010025b9..b895b3c9 100644 --- a/src/claude_agent_sdk/_internal/client.py +++ b/src/claude_agent_sdk/_internal/client.py @@ -179,6 +179,7 @@ async def _process_query_inner( agents=agents_dict, exclude_dynamic_sections=exclude_dynamic_sections, skills=configured_options.skills, + tool_aliases=configured_options.tool_aliases, ) if configured_options.session_store is not None: diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 7a4f8a44..aee18fb4 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -84,6 +84,7 @@ def __init__( agents: dict[str, dict[str, Any]] | None = None, exclude_dynamic_sections: bool | None = None, skills: list[str] | Literal["all"] | None = None, + tool_aliases: dict[str, str] | None = None, ): """Initialize Query with transport and callbacks. @@ -99,6 +100,8 @@ def __init__( initialize (see ``SystemPromptPreset``) skills: Optional skill allowlist to send via initialize so the CLI can filter which skills are loaded into the system prompt + tool_aliases: Optional mapping of built-in tool names to MCP tool + names sent via initialize (see ``ClaudeAgentOptions.tool_aliases``) """ self._initialize_timeout = initialize_timeout self.transport = transport @@ -109,6 +112,7 @@ def __init__( self._agents = agents self._exclude_dynamic_sections = exclude_dynamic_sections self._skills = skills + self._tool_aliases = tool_aliases # Control protocol state self.pending_control_responses: dict[str, anyio.Event] = {} @@ -212,6 +216,8 @@ async def initialize(self) -> dict[str, Any] | None: # only send the field when it's an explicit list. if isinstance(self._skills, list): request["skills"] = self._skills + if self._tool_aliases: + request["toolAliases"] = self._tool_aliases # Use longer timeout for initialize since MCP servers may take time to start response = await self._send_control_request( diff --git a/src/claude_agent_sdk/client.py b/src/claude_agent_sdk/client.py index 3ddf4c9f..c5590737 100644 --- a/src/claude_agent_sdk/client.py +++ b/src/claude_agent_sdk/client.py @@ -234,6 +234,7 @@ async def _connect_inner( agents=agents_dict, exclude_dynamic_sections=exclude_dynamic_sections, skills=self.options.skills, + tool_aliases=self.options.tool_aliases, ) if self.options.session_store is not None: diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index ee925b35..f802ed10 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1894,6 +1894,17 @@ class ClaudeAgentOptions: ``{"type": "json_schema", "schema": {"type": "object", "properties": {...}}}``. """ + tool_aliases: dict[str, str] | None = None + """Map built-in tool names to MCP tool names. + + When set, Claude calls the specified MCP tool in place of the named + built-in. For example, ``{"Bash": "mcp__workspace__bash"}`` routes every + ``Bash`` call through the ``mcp__workspace__bash`` MCP tool instead. + + Sent via the initialization message at session start (not a CLI flag). + Matches the TypeScript SDK's ``toolAliases`` option. + """ + enable_file_checkpointing: bool = False """Enable file checkpointing to track file changes during the session. @@ -1962,6 +1973,7 @@ class SDKControlInitializeRequest(TypedDict): subtype: Literal["initialize"] hooks: dict[HookEvent, Any] | None agents: NotRequired[dict[str, dict[str, Any]]] + toolAliases: NotRequired[dict[str, str]] class SDKControlSetPermissionModeRequest(TypedDict): diff --git a/tests/test_client.py b/tests/test_client.py index 3d9c0293..7f476a12 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -364,3 +364,131 @@ async def read_messages(): receive_stream.receive_nowait() anyio.run(_test) + + +class TestToolAliasesPassthrough: + """Verify that tool_aliases in ClaudeAgentOptions reaches the Query constructor + in both the query() (InternalClient) and ClaudeSDKClient code paths.""" + + def _make_mock_query(self): + """Minimal Query mock sufficient for connect/disconnect.""" + mock_query = AsyncMock() + mock_query.start = AsyncMock() + mock_query.initialize = AsyncMock(return_value=None) + mock_query.close = AsyncMock() + mock_query.close_receive_stream = Mock() + mock_query._tg = None + mock_query.set_transcript_mirror_batcher = Mock() + + def _consume_coro(coro): + coro.close() + return Mock() + + mock_query.spawn_task = Mock(side_effect=_consume_coro) + + async def mock_receive(): + yield { + "type": "result", + "subtype": "success", + "duration_ms": 100, + "duration_api_ms": 80, + "is_error": False, + "num_turns": 1, + "session_id": "test", + } + + mock_query.receive_messages = mock_receive + return mock_query + + def _make_mock_transport(self): + mock_transport = AsyncMock() + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + return mock_transport + + def test_query_function_passes_tool_aliases_to_query(self): + """InternalClient passes tool_aliases through to the Query constructor.""" + + async def _test(): + with ( + patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class, + patch("claude_agent_sdk._internal.client.Query") as mock_query_class, + ): + mock_transport_class.return_value = self._make_mock_transport() + mock_query_class.return_value = self._make_mock_query() + + options = ClaudeAgentOptions( + tool_aliases={"Read": "mcp__fakefs__Read"} + ) + async for _ in query(prompt="test", options=options): + pass + + call_kwargs = mock_query_class.call_args.kwargs + assert call_kwargs["tool_aliases"] == {"Read": "mcp__fakefs__Read"} + + anyio.run(_test) + + def test_query_function_passes_none_tool_aliases_to_query(self): + """InternalClient passes tool_aliases=None when the option is unset.""" + + async def _test(): + with ( + patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class, + patch("claude_agent_sdk._internal.client.Query") as mock_query_class, + ): + mock_transport_class.return_value = self._make_mock_transport() + mock_query_class.return_value = self._make_mock_query() + + async for _ in query(prompt="test", options=ClaudeAgentOptions()): + pass + + call_kwargs = mock_query_class.call_args.kwargs + assert call_kwargs["tool_aliases"] is None + + anyio.run(_test) + + def test_claude_sdk_client_passes_tool_aliases_to_query(self): + """ClaudeSDKClient passes tool_aliases through to the Query constructor.""" + from claude_agent_sdk import ClaudeSDKClient + + async def _test(): + with patch("claude_agent_sdk._internal.query.Query") as mock_query_class: + mock_query_class.return_value = self._make_mock_query() + + options = ClaudeAgentOptions( + tool_aliases={"Bash": "mcp__workspace__bash"} + ) + async with ClaudeSDKClient( + options=options, transport=self._make_mock_transport() + ): + pass + + call_kwargs = mock_query_class.call_args.kwargs + assert call_kwargs["tool_aliases"] == {"Bash": "mcp__workspace__bash"} + + anyio.run(_test) + + def test_claude_sdk_client_passes_none_tool_aliases_to_query(self): + """ClaudeSDKClient passes tool_aliases=None when the option is unset.""" + from claude_agent_sdk import ClaudeSDKClient + + async def _test(): + with patch("claude_agent_sdk._internal.query.Query") as mock_query_class: + mock_query_class.return_value = self._make_mock_query() + + async with ClaudeSDKClient( + options=ClaudeAgentOptions(), transport=self._make_mock_transport() + ): + pass + + call_kwargs = mock_query_class.call_args.kwargs + assert call_kwargs["tool_aliases"] is None + + anyio.run(_test) diff --git a/tests/test_query.py b/tests/test_query.py index 16c088b1..02a56648 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -77,6 +77,20 @@ def test_initialize_omits_skills_for_none_and_all(): assert "skills" not in _capture_initialize_request(skills="all") +def test_initialize_sends_tool_aliases(): + """Query.initialize() includes toolAliases in the control request when set.""" + sent = _capture_initialize_request(tool_aliases={"Bash": "mcp__workspace__bash"}) + assert sent["subtype"] == "initialize" + assert sent["toolAliases"] == {"Bash": "mcp__workspace__bash"} + + +def test_initialize_omits_tool_aliases_when_unset(): + """toolAliases is absent from initialize when not configured or empty.""" + assert "toolAliases" not in _capture_initialize_request() + assert "toolAliases" not in _capture_initialize_request(tool_aliases=None) + assert "toolAliases" not in _capture_initialize_request(tool_aliases={}) + + def _make_mock_transport(messages, control_requests=None): """Create a mock transport that yields messages and optionally sends control requests.