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
3 changes: 3 additions & 0 deletions python/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ sql =
libsql-client>=0.3.1
strands-agents =
strands-agents>=0.1.6
mcp =
mcp>=1.0.0

all =
anthropic>=0.40.0
openai>=1.55.3
boto3>=1.36.18
libsql-client>=0.3.1
mcp>=1.0.0

[options.packages.find]
where = src
Expand Down
8 changes: 7 additions & 1 deletion python/src/agent_squad/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
from .shared import user_agent

user_agent.inject_user_agent()
user_agent.inject_user_agent()

try:
from .tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig
__all__ = ["MCPToolProvider", "MCPServerConfig"]
except ImportError:
pass
4 changes: 4 additions & 0 deletions python/src/agent_squad/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Optional tool providers for agent-squad."""
from .mcp_tool_provider import MCPToolProvider, MCPServerConfig

__all__ = ["MCPToolProvider", "MCPServerConfig"]
324 changes: 324 additions & 0 deletions python/src/agent_squad/tools/mcp_tool_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
"""
MCPToolProvider — drop-in AgentTools subclass that exposes MCP server tools.

Usage example::

from agent_squad.tools import MCPToolProvider, MCPServerConfig
from agent_squad.agents import BedrockLLMAgent, BedrockLLMAgentOptions

agent = BedrockLLMAgent(BedrockLLMAgentOptions(
name="my-agent",
description="An agent with MCP tools",
tool_config={
"tool": MCPToolProvider([
MCPServerConfig(type="stdio", command="uvx", args=["my-mcp-server"]),
MCPServerConfig(type="sse", url="http://localhost:3000/sse"),
])
}
))

Requires the ``mcp`` extra::

pip install agent-squad[mcp]
"""

from __future__ import annotations

try:
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.sse import sse_client
except ImportError as exc:
raise ImportError(
"MCPToolProvider requires the 'mcp' package. "
"Install it with: pip install agent-squad[mcp]"
) from exc

from dataclasses import dataclass, field
from typing import Any, Optional

from agent_squad.types import AgentProviderType, ConversationMessage, ParticipantRole
from agent_squad.utils.tool import AgentTools, AgentToolCallbacks


@dataclass
class MCPServerConfig:
"""Configuration for a single MCP server.

For stdio transport set ``type="stdio"`` and provide ``command`` / ``args`` / ``env``.
For SSE/HTTP transport set ``type="sse"`` and provide ``url`` / ``headers``.

Attributes:
type: Transport type — ``"stdio"`` or ``"sse"``.
command: Executable to launch (stdio only).
args: Command-line arguments (stdio only).
env: Environment variables to pass to the subprocess (stdio only).
url: SSE endpoint URL (sse only).
headers: HTTP headers to send with the SSE connection (sse only).
"""

type: str # "stdio" or "sse"
command: Optional[str] = None
args: list[str] = field(default_factory=list)
env: Optional[dict[str, str]] = None
url: Optional[str] = None
headers: Optional[dict[str, str]] = None


class MCPToolProvider(AgentTools):
"""AgentTools subclass that proxies tools from one or more MCP servers.

Pass an instance directly as the ``tool`` value inside ``tool_config`` for
any ``BedrockLLMAgent``, ``AnthropicAgent``, or agent that accepts
``AgentTools``::

tool_config={
"tool": MCPToolProvider([
MCPServerConfig(type="stdio", command="uvx", args=["my-server"]),
MCPServerConfig(type="sse", url="http://localhost:3000/sse"),
])
}

Connections are established lazily on the first call that requires tool
metadata or tool execution. All servers are initialised once and then
reused for the lifetime of the provider instance.

Args:
servers: List of :class:`MCPServerConfig` describing the MCP servers to
connect to.
callbacks: Optional :class:`~agent_squad.utils.tool.AgentToolCallbacks`
instance for lifecycle hooks.
"""

def __init__(
self,
servers: list[MCPServerConfig],
callbacks: Optional[AgentToolCallbacks] = None,
) -> None:
# Intentionally bypass AgentTools.__init__ — we have no static AgentTool list
self.tools: list[Any] = [] # unused; tools come from MCP at runtime
self.callbacks = callbacks or AgentToolCallbacks()

self._servers = servers
# Maps tool_name → (ClientSession, mcp_tool)
self._tool_map: dict[str, tuple[Any, Any]] = {}
self._connected = False
# Keep hold of context-manager stacks so we can exit cleanly if needed
self._cm_stack: list[Any] = []

# ------------------------------------------------------------------
# Internal connection management
# ------------------------------------------------------------------

async def _ensure_connected(self) -> None:
"""Lazily connect to all configured servers and cache their tools."""
if self._connected:
return

for server_cfg in self._servers:
if server_cfg.type == "stdio":
if not server_cfg.command:
raise ValueError("MCPServerConfig with type='stdio' requires a 'command'")
params = StdioServerParameters(
command=server_cfg.command,
args=server_cfg.args or [],
env=server_cfg.env,
)
cm = stdio_client(params)
elif server_cfg.type == "sse":
if not server_cfg.url:
raise ValueError("MCPServerConfig with type='sse' requires a 'url'")
cm = sse_client(server_cfg.url, headers=server_cfg.headers or {})
else:
raise ValueError(
f"Unsupported MCPServerConfig type: '{server_cfg.type}'. "
"Use 'stdio' or 'sse'."
)

read, write = await cm.__aenter__()
self._cm_stack.append(cm)

session = ClientSession(read, write)
await session.__aenter__()
await session.initialize()

tools_result = await session.list_tools()
for mcp_tool in tools_result.tools:
self._tool_map[mcp_tool.name] = (session, mcp_tool)

self._connected = True

# ------------------------------------------------------------------
# AgentTools interface
# ------------------------------------------------------------------

async def tool_handler(
self,
provider_type: str,
response: Any,
_conversation: list[dict[str, Any]],
agent_info: Optional[dict[str, Any]] = None,
) -> Any:
"""Execute tool calls found in *response* and return results.

Compatible with Bedrock, Anthropic, and OpenAI response shapes.
"""
await self._ensure_connected()

if not response.content:
raise ValueError("No content blocks in response")

tool_results = []

for block in response.content:
tool_use_block = self._get_tool_use_block(provider_type, block)
if not tool_use_block:
continue

if provider_type == AgentProviderType.BEDROCK.value:
tool_name = tool_use_block.get("name")
tool_id = tool_use_block.get("toolUseId")
input_data = tool_use_block.get("input", {})
else:
# Anthropic object-style block
tool_name = tool_use_block.name
tool_id = tool_use_block.id
input_data = tool_use_block.input

await self.callbacks.on_tool_start(
tool_name, input_data, metadata={"agent_info": agent_info}
)

result = await self._call_mcp_tool(tool_name, input_data)

await self.callbacks.on_tool_end(
tool_name, input_data, result, metadata={"agent_info": agent_info}
)

if provider_type == AgentProviderType.BEDROCK.value:
tool_results.append(
{
"toolResult": {
"toolUseId": tool_id,
"content": [{"text": result}],
}
}
)
else:
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_id,
"content": result,
}
)

if provider_type == AgentProviderType.BEDROCK.value:
return ConversationMessage(
role=ParticipantRole.USER.value, content=tool_results
)
return {"role": ParticipantRole.USER.value, "content": tool_results}

async def _call_mcp_tool(self, tool_name: str, input_data: dict) -> str:
"""Call a tool on the appropriate MCP server and return a string result."""
if tool_name not in self._tool_map:
return f"Tool '{tool_name}' not found in any connected MCP server"

session, _ = self._tool_map[tool_name]
try:
call_result = await session.call_tool(tool_name, input_data)
except Exception as exc: # noqa: BLE001
return f"Error calling tool '{tool_name}': {exc}"

if getattr(call_result, "isError", False):
# Surface the error text back to the model so it can react
parts = [
getattr(item, "text", str(item))
for item in (call_result.content or [])
]
return f"Tool error: {' '.join(parts)}" if parts else "Tool returned an error"

# Collect text content from the result
parts = [
getattr(item, "text", str(item))
for item in (call_result.content or [])
]
return "\n".join(parts) if parts else ""

# ------------------------------------------------------------------
# Format conversion helpers
# ------------------------------------------------------------------

def to_bedrock_format(self) -> list[dict[str, Any]]:
"""Return MCP tools in the Bedrock ``toolSpec`` format.

.. note::
This is a *synchronous* method. If the provider has not yet been
connected you must call ``await provider._ensure_connected()``
before using this method, or use the agent's async flow which will
connect lazily via :meth:`tool_handler`.
"""
result = []
for tool_name, (_session, mcp_tool) in self._tool_map.items():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Populate MCP tools before formatting

When a fresh MCPToolProvider is passed to BedrockLLMAgent or AnthropicAgent as in the new example, the agents build the model request by calling to_bedrock_format()/to_claude_format() before tool_handler() ever runs. These formatters only iterate self._tool_map, which is empty until _ensure_connected() has been awaited, so the first request is sent with no MCP tools and the model never has a chance to call them. Please connect/list tools before formatting in the async agent path, or otherwise avoid returning an empty tool list on first use.

Useful? React with 👍 / 👎.

input_schema = (
mcp_tool.inputSchema
if isinstance(mcp_tool.inputSchema, dict)
else {"type": "object", "properties": {}}
)
result.append(
{
"toolSpec": {
"name": tool_name,
"description": mcp_tool.description or "",
"inputSchema": {"json": input_schema},
}
}
)
return result

def to_claude_format(self) -> list[dict[str, Any]]:
"""Return MCP tools in the Anthropic / Claude ``input_schema`` format."""
result = []
for tool_name, (_session, mcp_tool) in self._tool_map.items():
input_schema = (
mcp_tool.inputSchema
if isinstance(mcp_tool.inputSchema, dict)
else {"type": "object", "properties": {}}
)
result.append(
{
"name": tool_name,
"description": mcp_tool.description or "",
"input_schema": input_schema,
}
)
return result

def to_anthropic_format(self) -> list[dict[str, Any]]:
"""Alias for :meth:`to_claude_format` (same wire format)."""
return self.to_claude_format()

def to_openai_format(self) -> list[dict[str, Any]]:
"""Return MCP tools in the OpenAI function-calling format."""
result = []
for tool_name, (_session, mcp_tool) in self._tool_map.items():
input_schema = (
mcp_tool.inputSchema
if isinstance(mcp_tool.inputSchema, dict)
else {"type": "object", "properties": {}}
)
# Ensure required field is present for strict mode compatibility
parameters = {**input_schema}
if "additionalProperties" not in parameters:
parameters["additionalProperties"] = False
result.append(
{
"type": "function",
"function": {
"name": tool_name,
"description": mcp_tool.description or "",
"parameters": parameters,
},
}
)
return result
Empty file.
Loading
Loading