Skip to content
Merged
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
9 changes: 7 additions & 2 deletions docs/src/content/docs/agents/mcp-tool-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ npm install @modelcontextprotocol/sdk
```bash
pip install "agent-squad[mcp]"
```
The `mcp` extra is optional — it is not pulled in when you install the base package. It installs the MCP SDK 1.x (`mcp>=1.0.0,<2`); the SDK's 2.x line is a breaking release and is not supported yet (tracked in [#634](https://github.com/2FastLabs/agent-squad/issues/634)).
The `mcp` extra is optional — it is not pulled in when you install the base package. Both SDK majors are supported:

| Installed `mcp` version | MCP protocol versions | Notes |
|---|---|---|
| `mcp` >= 2.0 | 2026-07-28 **and** all legacy versions | auto-negotiated per server (`server/discover` probe with `initialize` fallback) |
| `mcp` 1.x | 2025-11-25 and older | the 1.x line will not receive 2026-07-28 support |
</TabItem>
</Tabs>

Expand Down Expand Up @@ -153,7 +158,7 @@ provider = await MCPToolProvider.create([
)
])
```
Requires `mcp` >= 1.9 (already satisfied by a fresh `pip install "agent-squad[mcp]"`).
Requires `mcp` >= 1.9 (already satisfied by a fresh `pip install "agent-squad[mcp]"`). On `mcp` 2.x, headers are delivered through the SDK's httpx client factory — same config, no code change.
</TabItem>
</Tabs>

Expand Down
4 changes: 2 additions & 2 deletions python/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ strands-agents =
dakera =
dakera>=0.12.8
mcp =
mcp>=1.0.0,<2
mcp>=1.0.0

all =
anthropic>=0.40.0
openai>=1.55.3
boto3>=1.36.18
libsql-client>=0.3.1
dakera>=0.12.8
mcp>=1.0.0,<2
mcp>=1.0.0

[options.packages.find]
where = src
Expand Down
185 changes: 138 additions & 47 deletions python/src/agent_squad/tools/mcp_tool_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@
except ImportError:
streamablehttp_client = None

# mcp 2.x detection: the first-class Client only exists there. When present, all
# connections go through it (mode="auto" probes server/discover for protocol
# 2026-07-28 and falls back to the legacy initialize handshake per server).
try:
from mcp import Client as _V2Client
except ImportError:
_V2Client = None

# v2 name of the streamable HTTP transport (also present in late 1.x).
try:
from mcp.client.streamable_http import streamable_http_client
except ImportError:
streamable_http_client = None

# Blessed httpx2/httpx client factory (headers, MCP-recommended timeouts).
try:
from mcp.shared._httpx_utils import create_mcp_http_client
except ImportError:
create_mcp_http_client = None

from pydantic import AnyUrl # mcp depends on pydantic, so it's available whenever the import above succeeds

from dataclasses import dataclass, field
Expand Down Expand Up @@ -86,6 +106,23 @@ class MCPServerConfig:
headers: Optional[dict[str, str]] = None


_UNSET = object()


def _field(obj: Any, *names: str, default: Any = None) -> Any:
"""Read the first present attribute among ``names``.

mcp 1.x exposes camelCase attributes (``inputSchema``, ``isError``) while
2.x is strictly snake_case (``input_schema``, ``is_error``) with no alias
attribute access — both spellings must be tried.
"""
for name in names:
value = getattr(obj, name, _UNSET)
if value is not _UNSET:
return value
return default


def _meta_dict(mcp_tool: Any) -> Optional[dict[str, Any]]:
"""The tool's ``_meta`` (MCP Apps UI metadata); the SDK exposes it as ``.meta``."""
return getattr(mcp_tool, "meta", None) or getattr(mcp_tool, "_meta", None)
Expand Down Expand Up @@ -204,45 +241,13 @@ async def _ensure_connected(self) -> None:
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 {})
elif server_cfg.type == "streamable-http":
if streamablehttp_client is None:
raise ImportError(
"The streamable-http transport requires mcp>=1.9. "
"Upgrade it with: pip install -U 'mcp>=1.9,<2'"
)
if not server_cfg.url:
raise ValueError("MCPServerConfig with type='streamable-http' requires a 'url'")
cm = streamablehttp_client(server_cfg.url, headers=server_cfg.headers or {})
if _V2Client is not None:
session = await self._connect_v2(server_cfg)
else:
raise ValueError(
f"Unsupported MCPServerConfig type: '{server_cfg.type}'. "
"Use 'stdio', 'streamable-http' or 'sse'."
)

# stdio/sse yield (read, write); streamable-http yields (read, write, get_session_id)
read, write, *_ = await cm.__aenter__()
self._cm_stack.append(cm)
session = await self._connect_v1(server_cfg)

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

tools_result = await session.list_tools()
for mcp_tool in tools_result.tools:
tools = await self._list_all_tools(session)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Roll back v2 clients when tool discovery fails

When using mcp 2.x, _connect_v2 has already entered and stored the client before this discovery call. If list_tools() raises—for example because the server disconnects or returns an invalid response—create() propagates the exception without returning the provider, so the caller cannot invoke disconnect() and the entered client, transport, and any stdio subprocess remain live. Roll back all sessions and owned HTTP clients opened during connection when initialization or discovery fails.

Useful? React with 👍 / 👎.

for mcp_tool in tools:
meta = _meta_dict(mcp_tool)
self._tool_map[mcp_tool.name] = _MCPToolEntry(
session=session,
Expand All @@ -253,6 +258,85 @@ async def _ensure_connected(self) -> None:

self._connected = True

def _transport_cm(self, server_cfg: MCPServerConfig, v2: bool) -> Any:
"""The transport async context manager for a server config (shared by both majors)."""
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,
)
return stdio_client(params)
if server_cfg.type == "sse":
if not server_cfg.url:
raise ValueError("MCPServerConfig with type='sse' requires a 'url'")
return sse_client(server_cfg.url, headers=server_cfg.headers or {})
if server_cfg.type == "streamable-http":
if not server_cfg.url:
raise ValueError("MCPServerConfig with type='streamable-http' requires a 'url'")
if v2:
# v2 renamed the function and moved headers onto an httpx client.
if server_cfg.headers:
if create_mcp_http_client is not None:
http_client = create_mcp_http_client(headers=server_cfg.headers)
else:
# The factory lives in a private mcp module; fall back to a
# plain client (httpx2 is a hard dependency of mcp 2.x).
import httpx2

http_client = httpx2.AsyncClient(
headers=server_cfg.headers, follow_redirects=True
)
# v2 does not manage a caller-provided client; we own its lifecycle.
self._cm_stack.append(http_client)
return streamable_http_client(server_cfg.url, http_client=http_client)
return streamable_http_client(server_cfg.url)
if streamablehttp_client is None:
raise ImportError(
"The streamable-http transport requires mcp>=1.9. "
"Upgrade it with: pip install -U mcp"
)
return streamablehttp_client(server_cfg.url, headers=server_cfg.headers or {})
raise ValueError(
f"Unsupported MCPServerConfig type: '{server_cfg.type}'. "
"Use 'stdio', 'streamable-http' or 'sse'."
)

async def _connect_v1(self, server_cfg: MCPServerConfig) -> Any:
"""mcp 1.x: transport streams + ClientSession + legacy initialize handshake."""
cm = self._transport_cm(server_cfg, v2=False)
# stdio/sse yield (read, write); streamable-http yields (read, write, get_session_id)
read, write, *_ = await cm.__aenter__()
self._cm_stack.append(cm)

session = ClientSession(read, write)
await session.__aenter__()
self._sessions.append(session)
await session.initialize()
return session

async def _connect_v2(self, server_cfg: MCPServerConfig) -> Any:
"""mcp 2.x: one Client per server; mode="auto" negotiates the protocol era."""
client = _V2Client(self._transport_cm(server_cfg, v2=True), mode="auto")
await client.__aenter__()
self._sessions.append(client)
return client

async def _list_all_tools(self, session: Any) -> list[Any]:
"""All tools from a session, following pagination cursors when present."""
result = await session.list_tools()
tools = list(result.tools)
cursor = _field(result, "nextCursor", "next_cursor")
# v1 deliberately keeps its pre-existing single-call behavior (no behavior
# change for existing users); only the v2 path follows pagination cursors.
while cursor and _V2Client is not None and isinstance(session, _V2Client):
result = await session.list_tools(cursor=cursor)
tools.extend(result.tools)
cursor = _field(result, "nextCursor", "next_cursor")
return tools

async def disconnect(self) -> None:
"""Disconnect from all MCP servers and release resources.

Expand Down Expand Up @@ -373,11 +457,11 @@ async def _call_mcp_tool(self, tool_name: str, input_data: dict) -> ToolResult:
]
text = "\n".join(parts)

if getattr(call_result, "isError", False):
if _field(call_result, "isError", "is_error", default=False):
# Surface the error text back to the model so it can react.
return ToolResult(content=f"Tool error: {text}" if text else "Tool returned an error")

structured = getattr(call_result, "structuredContent", None) or {}
structured = _field(call_result, "structuredContent", "structured_content") or {}

ui: Optional[UIPayload] = None
if entry.ui:
Expand All @@ -402,14 +486,18 @@ async def _template_for(self, session: Any, resource_uri: str) -> Optional[tuple
if cache_key in self._template_cache:
return self._template_cache[cache_key]
try:
read_result = await session.read_resource(AnyUrl(resource_uri))
# v2's read_resource takes a plain str; v1's ClientSession wants AnyUrl.
if _V2Client is not None and isinstance(session, _V2Client):
read_result = await session.read_resource(resource_uri)
else:
read_result = await session.read_resource(AnyUrl(resource_uri))
except Exception: # noqa: BLE001
return None
contents = getattr(read_result, "contents", None) or []
if not contents:
return None
first = contents[0]
mime_type = getattr(first, "mimeType", None) or "text/html;profile=mcp-app"
mime_type = _field(first, "mimeType", "mime_type") or "text/html;profile=mcp-app"
body = getattr(first, "text", None)
if body is None:
blob = getattr(first, "blob", None)
Expand Down Expand Up @@ -441,9 +529,10 @@ def to_bedrock_format(self) -> list[dict[str, Any]]:
if not entry.model_visible:
continue # app-only tool: callable by the UI, never advertised to the model
mcp_tool = entry.tool
raw_schema = _field(mcp_tool, "inputSchema", "input_schema")
input_schema = (
mcp_tool.inputSchema
if isinstance(mcp_tool.inputSchema, dict)
raw_schema
if isinstance(raw_schema, dict)
else {"type": "object", "properties": {}}
)
result.append(
Expand All @@ -464,9 +553,10 @@ def to_claude_format(self) -> list[dict[str, Any]]:
if not entry.model_visible:
continue # app-only tool: callable by the UI, never advertised to the model
mcp_tool = entry.tool
raw_schema = _field(mcp_tool, "inputSchema", "input_schema")
input_schema = (
mcp_tool.inputSchema
if isinstance(mcp_tool.inputSchema, dict)
raw_schema
if isinstance(raw_schema, dict)
else {"type": "object", "properties": {}}
)
result.append(
Expand All @@ -489,9 +579,10 @@ def to_openai_format(self) -> list[dict[str, Any]]:
if not entry.model_visible:
continue # app-only tool: callable by the UI, never advertised to the model
mcp_tool = entry.tool
raw_schema = _field(mcp_tool, "inputSchema", "input_schema")
input_schema = (
mcp_tool.inputSchema
if isinstance(mcp_tool.inputSchema, dict)
raw_schema
if isinstance(raw_schema, dict)
else {"type": "object", "properties": {}}
)
# Ensure required field is present for strict mode compatibility
Expand Down
Loading
Loading