Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions python/packages/claude/agent_framework_claude/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload

Expand All @@ -29,6 +30,7 @@
normalize_messages,
normalize_tools,
)
from agent_framework._mcp import _expand_mcp_tools # pyright: ignore[reportPrivateUsage]
Comment thread
orangeCatDeveloper marked this conversation as resolved.
from agent_framework._telemetry import mark_feature_used
from agent_framework.exceptions import AgentException, AgentInvalidRequestException
from agent_framework.observability import AgentTelemetryLayer
Expand Down Expand Up @@ -385,6 +387,9 @@ def __init__(
# Separate built-in tools (strings) from custom tools (callables/FunctionTool)
self._builtin_tools: list[str] = []
self._custom_tools: list[ToolTypes] = []
self._mcp_exit_stack = AsyncExitStack()
self._mcp_lock = asyncio.Lock()
self._warned_about_injected_client_tools = False
self._normalize_tools(tools)

self._default_options = opts
Expand Down Expand Up @@ -455,6 +460,11 @@ async def stop(self) -> None:
with contextlib.suppress(Exception):
await self._client.disconnect()

async with self._mcp_lock:
with contextlib.suppress(Exception):
await self._mcp_exit_stack.aclose()
self._mcp_exit_stack = AsyncExitStack()

self._started = False

async def _connect_injected_client(self) -> None:
Expand Down Expand Up @@ -512,10 +522,26 @@ async def _acquire_client(self, session: AgentSession) -> tuple[ClaudeSDKClient,
"different conversation. Omit `client=` so each run gets its own isolated "
"client, or use a separate ClaudeAgent per session."
)
configured_tool_count = len(self._builtin_tools) + len(self._custom_tools)
if configured_tool_count and not self._warned_about_injected_client_tools:
self._warned_about_injected_client_tools = True
logger.warning(
"Ignoring %d tool(s) configured on this agent: an injected ClaudeSDKClient "
"carries its own options, so the framework cannot register tools on it. "
"Omit `client=` to let the agent build the client with these tools.",
configured_tool_count,
)
await self._connect_injected_client()
return self._client, False

opts = self._prepare_client_options(resume_session_id=self._get_chat_conversation_id(session))
# Runs are deliberately not serialized here, so guard expansion: two concurrent runs
# would otherwise each open their own connection to the same MCP server.
async with self._mcp_lock:
custom_tools = await _expand_mcp_tools(self._custom_tools, self._mcp_exit_stack)
opts = self._prepare_client_options(
resume_session_id=self._get_chat_conversation_id(session),
custom_tools=custom_tools,
)
client = ClaudeSDKClient(options=opts)
try:
await client.connect()
Expand Down Expand Up @@ -548,11 +574,16 @@ def _injected_session_matches(self, session: AgentSession) -> bool:
return bound.service_session_id == session.service_session_id
return bound.session_id == session.session_id

def _prepare_client_options(self, resume_session_id: str | None = None) -> SDKOptions:
def _prepare_client_options(
self,
resume_session_id: str | None = None,
custom_tools: Sequence[ToolTypes] | None = None,
) -> SDKOptions:
"""Prepare SDK options for client initialization.

Args:
resume_session_id: Optional session ID to resume.
custom_tools: Tools to expose, already expanded. Defaults to the configured tools.

Returns:
SDKOptions instance configured for the client.
Expand Down Expand Up @@ -587,9 +618,8 @@ def _prepare_client_options(self, resume_session_id: str | None = None) -> SDKOp
opts["tools"] = self._builtin_tools

# Prepare custom tools (FunctionTool instances)
custom_tools_server, custom_tool_names = (
self._prepare_tools(self._custom_tools) if self._custom_tools else (None, [])
)
tools_to_expose = self._custom_tools if custom_tools is None else custom_tools
custom_tools_server, custom_tool_names = self._prepare_tools(tools_to_expose) if tools_to_expose else (None, [])

# MCP servers - merge user-provided servers with custom tools server
mcp_servers = dict(self._mcp_servers) if self._mcp_servers else {}
Expand Down
101 changes: 100 additions & 1 deletion python/packages/claude/tests/test_claude_agent.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.

import asyncio
import logging
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from agent_framework import AgentResponseUpdate, AgentSession, Content, Message, tool
from agent_framework._mcp import MCPTool
from agent_framework._settings import load_settings
from agent_framework.exceptions import AgentInvalidRequestException

Expand Down Expand Up @@ -601,7 +604,7 @@ async def test_acquire_client_forwards_resume_id(self) -> None:
_, owns_client = await agent._acquire_client(session) # type: ignore[reportPrivateUsage]

assert owns_client is True
prepare.assert_called_once_with(resume_session_id="provider-session-1")
assert prepare.call_args.kwargs["resume_session_id"] == "provider-session-1"

async def test_acquire_client_reuses_injected_client_for_same_session(self) -> None:
"""An injected client is reused across runs of one session and never owned by the run."""
Expand Down Expand Up @@ -1472,3 +1475,99 @@ async def test_telemetry_uses_correct_provider_name(self, monkeypatch: pytest.Mo

call_kwargs = mock_get_span.call_args[1]
assert call_kwargs["attributes"]["gen_ai.provider.name"] == "anthropic.claude"


class _StubMCPTool(MCPTool):
"""MCPTool whose connection is faked, so tool expansion can be tested without a server."""

def __init__(self, name: str, functions: list[Any]) -> None:
super().__init__(name=name)
self._stub_functions = functions
self.connect_calls = 0

async def connect(self, *, reset: bool = False) -> None: # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
self.connect_calls += 1
await asyncio.sleep(0)
self.is_connected = True
self._functions = self._stub_functions

def get_mcp_client(self) -> Any:
raise AssertionError("stub must not open a transport")


class TestClaudeAgentMCPTools:
"""Tests for exposing MCP server tools to the Claude SDK."""

async def test_mcp_tools_are_expanded_when_the_client_is_acquired(self) -> None:
"""An MCPTool must reach the SDK as the tools it exposes, not be dropped."""

@tool
def remote_search(query: str) -> str:
"""Search."""
return query

mock_client = MagicMock()
mock_client.connect = AsyncMock()
mcp_tool = _StubMCPTool(name="server", functions=[remote_search])
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client) as sdk_client:
agent = ClaudeAgent(tools=[mcp_tool])
await agent._acquire_client(agent.create_session()) # type: ignore[reportPrivateUsage]

options = sdk_client.call_args.kwargs["options"]
assert options.allowed_tools == [f"mcp__{TOOLS_MCP_SERVER_NAME}__remote_search"]
assert TOOLS_MCP_SERVER_NAME in options.mcp_servers

# The configured server itself is kept, so a later run can reconnect after stop().
assert agent._custom_tools == [mcp_tool] # type: ignore[reportPrivateUsage]

await agent.stop()

def test_unexpanded_mcp_tool_is_not_an_sdk_tool(self) -> None:
"""Without expansion the SDK sees nothing, which is what this feature fixes."""
agent = ClaudeAgent(tools=[_StubMCPTool(name="server", functions=[])])

server, tool_names = agent._prepare_tools(agent._custom_tools) # type: ignore[reportPrivateUsage]

assert server is None
assert tool_names == []

async def test_injected_client_warns_that_configured_tools_are_ignored(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""An injected client carries its own options, so configured tools cannot be registered."""
injected = MagicMock()
injected.connect = AsyncMock()
agent = ClaudeAgent(client=injected, tools=[_StubMCPTool(name="server", functions=[])])

with caplog.at_level(logging.WARNING, logger="agent_framework"):
await agent._acquire_client(agent.create_session()) # type: ignore[reportPrivateUsage]

assert "cannot register tools" in caplog.text

async def test_injected_client_warning_counts_builtin_tools(self, caplog: pytest.LogCaptureFixture) -> None:
"""Built-in tools are configured through the same options, so they are ignored too."""
injected = MagicMock()
injected.connect = AsyncMock()
agent = ClaudeAgent(client=injected, tools=["Read", "Write"])

with caplog.at_level(logging.WARNING, logger="agent_framework"):
await agent._acquire_client(agent.create_session()) # type: ignore[reportPrivateUsage]

assert "Ignoring 2 tool(s)" in caplog.text

async def test_concurrent_runs_share_one_mcp_connection(self) -> None:
"""Runs are not serialized, so two of them must not each connect to the same server."""
mcp_tool = _StubMCPTool(name="server", functions=[])
mock_client = MagicMock()
mock_client.connect = AsyncMock()

with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
agent = ClaudeAgent(tools=[mcp_tool])
await asyncio.gather(
agent._acquire_client(agent.create_session()), # type: ignore[reportPrivateUsage]
agent._acquire_client(agent.create_session()), # type: ignore[reportPrivateUsage]
)

assert mcp_tool.connect_calls == 1

await agent.stop()
47 changes: 46 additions & 1 deletion python/packages/core/agent_framework/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
experimental,
)
from ._telemetry import FeatureIndex, mark_feature_used
from ._tools import FunctionTool
from ._tools import (
FunctionTool,
_append_unique_tools, # pyright: ignore[reportPrivateUsage]
)
from ._types import (
ChatOptions,
Content,
Expand Down Expand Up @@ -2725,6 +2728,48 @@ async def __aexit__(
# region: MCP Plugin Implementations


async def _expand_mcp_tools( # pyright: ignore[reportUnusedFunction]
tools: Sequence[Any], exit_stack: AsyncExitStack
) -> list[Any]:
"""Return a tool list with every MCPTool replaced by the remote tools it exposes.

An MCPTool is a server connection rather than a callable tool, so SDKs that only accept
function tools cannot consume one directly. ``ChatAgent`` already expands them this way;
provider agents that talk to an external SDK need the same step.

The input is never modified: callers keep their configured MCPTool instances so a later
run can reconnect after the agent is stopped.

Args:
tools: The tools to expand; entries that are not MCPTool instances are kept as-is.
exit_stack: Stack that owns the lifetime of connections opened here.

Returns:
A new list with every MCPTool replaced by its exposed functions.

Raises:
ToolException: If a server uses progressive disclosure, whose loader tools only work
inside a framework function-calling run.
ValueError: If two tools share a name.
"""
duplicate_message = "Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool."
expanded: list[Any] = []
for tool in tools:
if not isinstance(tool, MCPTool):
_append_unique_tools(expanded, [tool], duplicate_error_message=duplicate_message)
continue
if tool.use_progressive_disclosure:
raise ToolException(
f"MCP server '{tool.name}' uses progressive disclosure, which exposes loader tools "
"that only work inside a framework function-calling run. Construct it without "
"`use_progressive_disclosure` to use it with this agent."
)
if not tool.is_connected:
await exit_stack.enter_async_context(tool)
_append_unique_tools(expanded, tool.functions, duplicate_error_message=duplicate_message)
return expanded


class MCPStdioTool(MCPTool):
"""MCP tool for connecting to stdio-based MCP servers.

Expand Down
Loading
Loading