Skip to content
Draft
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
65 changes: 52 additions & 13 deletions adk/agenticlayer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async def load_agent(self, agent: LlmAgent, sub_agents: list[SubAgent], tools: l
"""

agents, agent_tools = await self.load_sub_agents(sub_agents)
mcp_tools = self.load_tools(tools)
mcp_tools, mcp_tool_descriptions = await self.load_tools(tools)
all_tools: list[ToolUnion] = agent_tools + mcp_tools

# The ADK currently only adds the agent as a function with the agent name to the instructions.
Expand All @@ -53,6 +53,15 @@ async def load_agent(self, agent: LlmAgent, sub_agents: list[SubAgent], tools: l
agent_tool_instructions += "\nYou can use them by calling the tool with the agent name.\n"
agent.instruction = f"{agent.instruction}{agent_tool_instructions}"

# Add MCP tool descriptions to instructions
if mcp_tool_descriptions:
mcp_tool_instructions = "\n\nFollowing MCP tools are available:\n"
mcp_tool_instructions += "\n".join(
[f"- '{name}': {description}" for name, description in mcp_tool_descriptions]
)
mcp_tool_instructions += "\nYou can use them by calling the tool with the tool name.\n"
agent.instruction = f"{agent.instruction}{mcp_tool_instructions}"

agent.sub_agents += agents
agent.tools += all_tools
return agent
Expand Down Expand Up @@ -87,24 +96,54 @@ async def load_sub_agents(self, sub_agents: list[SubAgent]) -> tuple[list[BaseAg

return agents, tools

def load_tools(self, mcp_tools: list[McpTool]) -> list[ToolUnion]:
async def load_tools(self, mcp_tools: list[McpTool]) -> tuple[list[ToolUnion], list[tuple[str, str]]]:
"""
Convert Tools into McpToolsets.
Convert Tools into McpToolsets and extract their descriptions.

This method creates McpToolset instances for runtime use and simultaneously
introspects them to extract tool descriptions.

:param mcp_tools: The tools to load
:return: A list of McpToolset tools
:return: Tuple of (toolsets for runtime, tool descriptions for instructions)
:raises ConnectionError: If any MCP server is unavailable or unreachable
"""

tools: list[ToolUnion] = []
for tool in mcp_tools:
logger.info(f"Loading tool {tool.model_dump_json()}")
tools.append(
McpToolset(
toolsets: list[ToolUnion] = []
tool_descriptions: list[tuple[str, str]] = []

for mcp_tool in mcp_tools:
logger.info(f"Loading tool {mcp_tool.model_dump_json()}")

try:
# Create McpToolset for runtime use
toolset = McpToolset(
connection_params=StreamableHTTPConnectionParams(
url=str(tool.url),
timeout=tool.timeout,
url=str(mcp_tool.url),
timeout=mcp_tool.timeout,
),
)
)

return tools
# Introspect the MCP server to get tool schemas with descriptions
# This queries the MCP server's tools/list endpoint
tools = await toolset.get_tools()

# Extract (name, description) for each tool
for tool in tools:
name = tool.name
description = tool.description
if description: # Only include tools with descriptions
tool_descriptions.append((name, description))
else:
logger.warning(f"Tool '{name}' from {mcp_tool.name} has no description")

# Add toolset to runtime list (keep it alive for agent execution)
toolsets.append(toolset)

except Exception as e:
logger.error(f"Failed to load MCP tool from {mcp_tool.url}: {e}")
raise ConnectionError(
f"Could not connect to MCP server '{mcp_tool.name}' at {mcp_tool.url}. "
f"Ensure the server is running and accessible."
) from e

return toolsets, tool_descriptions
77 changes: 64 additions & 13 deletions adk/tests/test_a2a_starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ def create_agent(
)


def create_mcp_tool_config(
name: str = "test_mcp_tool",
url: str = "http://mcp-server.local/mcp",
timeout: int = 30,
) -> McpTool:
"""Helper function to create McpTool configuration object."""
return McpTool(
name=name,
url=AnyHttpUrl(url),
timeout=timeout,
)


@pytest_asyncio.fixture
def app_factory() -> Any:
@contextlib.asynccontextmanager
Expand Down Expand Up @@ -214,20 +227,58 @@ async def test_sub_agent_unavailable_fails_startup(self, app_factory: Any) -> No
)

@pytest.mark.asyncio
async def test_tools(self, app_factory: Any) -> None:
"""Test that tools are integrated correctly."""

# When: Creating an agent with tools
tools = [
McpTool(name="tool_1", url=AnyHttpUrl("http://tool-1.local/mcp")),
McpTool(name="tool_2", url=AnyHttpUrl("http://tool-2.local/mcp")),
async def test_mcp_tool_multiple_from_single_server(self, app_factory: Any, monkeypatch: Any) -> None:
"""Test that multiple tools from a single MCP server are all added to agent instructions."""

# Given: Mock tools with names and descriptions
mock_tools = [
type("MockTool", (), {"name": "get_customer", "description": "Retrieves customer information"})(),
type("MockTool", (), {"name": "update_customer", "description": "Updates customer records"})(),
type("MockTool", (), {"name": "delete_customer", "description": "Deletes customer from database"})(),
]

# And: Mock McpToolset.get_tools to return our mock tools
async def mock_get_tools(self: Any, readonly_context: Any = None) -> list[Any]:
return mock_tools

monkeypatch.setattr("google.adk.tools.mcp_tool.mcp_toolset.McpToolset.get_tools", mock_get_tools)

# And: Agent and MCP tool configuration
agent = create_agent()
tools = [create_mcp_tool_config(name="customer_api", url="http://mcp-server.local/mcp")]

# When: Requesting the agent card endpoint
async with app_factory(agent=agent, tools=tools) as app:
client = TestClient(app)
response = client.get("/.well-known/agent-card.json")
# When: Creating app with MCP tool
async with app_factory(agent=agent, tools=tools) as _:
# Then: Instruction should be a string with all three tools
assert isinstance(agent.instruction, str), "Agent instruction should be a string"
assert "- 'get_customer': Retrieves customer information" in agent.instruction
assert "- 'update_customer': Updates customer records" in agent.instruction
assert "- 'delete_customer': Deletes customer from database" in agent.instruction

# Then: Agent card is returned
assert response.status_code == 200
# And: MCP tools section should be present
assert "\n\nFollowing MCP tools are available:\n" in agent.instruction

@pytest.mark.asyncio
async def test_mcp_tool_server_unavailable(self, app_factory: Any, monkeypatch: Any) -> None:
"""Test that unavailable MCP server causes app startup to fail with ConnectionError."""

# Given: Mock McpToolset.get_tools to raise an exception
async def mock_get_tools_error(self: Any, readonly_context: Any = None) -> Any:
raise ConnectionError("Connection refused")

monkeypatch.setattr("google.adk.tools.mcp_tool.mcp_toolset.McpToolset.get_tools", mock_get_tools_error)

# And: Agent and MCP tool configuration
agent = create_agent()
tools = [create_mcp_tool_config(name="unavailable_tool", url="http://unreachable-mcp.local/mcp")]

# Expect: App creation should fail with ConnectionError containing tool name, URL, and helpful message
with pytest.raises(ConnectionError) as exc_info:
async with app_factory(agent=agent, tools=tools):
pass

# Then: Error message should contain expected details
error_message = str(exc_info.value)
assert "Could not connect to MCP server 'unavailable_tool'" in error_message
assert "http://unreachable-mcp.local/mcp" in error_message
assert "Ensure the server is running and accessible" in error_message