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
57 changes: 46 additions & 11 deletions docs/src/content/docs/agents/mcp-tool-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ await provider.disconnect()

## Transports

`MCPToolProvider` supports two transports.
`MCPToolProvider` supports three transports: `stdio`, `streamable-http`, and `sse`.

### stdio — spawn a local process

Expand Down Expand Up @@ -122,9 +122,44 @@ provider = await MCPToolProvider.create([
</TabItem>
</Tabs>

### SSE — connect to a remote server
### Streamable HTTP — connect to a remote server

Connect to an MCP server running over HTTP Server-Sent Events (SSE).
The current standard HTTP transport for remote MCP servers. Use this for any server you don't spawn locally, unless it only speaks the older SSE protocol.

<Tabs syncKey="runtime">
<TabItem label="TypeScript" icon="seti:typescript" color="blue">
```typescript
import { MCPToolProvider } from "agent-squad";

const provider = await MCPToolProvider.create([
{
type: "streamable-http",
url: "http://localhost:3000/mcp",
headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },
},
]);
```
Requires `@modelcontextprotocol/sdk` >= 1.10.
</TabItem>
<TabItem label="Python" icon="seti:python">
```python
from agent_squad.tools import MCPToolProvider, MCPServerConfig

provider = await MCPToolProvider.create([
MCPServerConfig(
type="streamable-http",
url="http://localhost:3000/mcp",
headers={"Authorization": f"Bearer {os.environ['MCP_TOKEN']}"},
)
])
```
Requires `mcp` >= 1.9 (already satisfied by a fresh `pip install "agent-squad[mcp]"`).
</TabItem>
</Tabs>

### SSE — connect to a legacy remote server

Connect to an MCP server running over HTTP Server-Sent Events (SSE). This is the older HTTP transport, deprecated by the MCP spec since 2025-03-26 — prefer `streamable-http` for servers that support it.

<Tabs syncKey="runtime">
<TabItem label="TypeScript" icon="seti:typescript" color="blue">
Expand Down Expand Up @@ -167,7 +202,7 @@ import { MCPToolProvider } from "agent-squad";
const provider = await MCPToolProvider.create([
{ type: "stdio", command: "uvx", args: ["filesystem-server"] },
{ type: "stdio", command: "uvx", args: ["database-server"] },
{ type: "sse", url: "https://api.example.com/mcp" },
{ type: "streamable-http", url: "https://api.example.com/mcp" },
]);
```
</TabItem>
Expand All @@ -178,7 +213,7 @@ from agent_squad.tools import MCPToolProvider, MCPServerConfig
provider = await MCPToolProvider.create([
MCPServerConfig(type="stdio", command="uvx", args=["filesystem-server"]),
MCPServerConfig(type="stdio", command="uvx", args=["database-server"]),
MCPServerConfig(type="sse", url="https://api.example.com/mcp"),
MCPServerConfig(type="streamable-http", url="https://api.example.com/mcp"),
])
```
</TabItem>
Expand Down Expand Up @@ -259,22 +294,22 @@ Available in the Python and TypeScript `MCPToolProvider`. See the [GroundedAgent
<TabItem label="TypeScript" icon="seti:typescript" color="blue">
| Field | Type | Required | Description |
|---|---|---|---|
| `type` | `"stdio" \| "sse"` | Yes | Transport type |
| `type` | `"stdio" \| "streamable-http" \| "sse"` | Yes | Transport type |
| `command` | `string` | stdio only | Executable to launch |
| `args` | `string[]` | No | Arguments for the command |
| `env` | `Record<string, string>` | No | Environment variables for the subprocess |
| `url` | `string` | sse only | Full URL of the SSE endpoint |
| `headers` | `Record<string, string>` | No | HTTP headers for the SSE connection |
| `url` | `string` | streamable-http / sse | Full URL of the server endpoint |
| `headers` | `Record<string, string>` | No | HTTP headers for the connection |
</TabItem>
<TabItem label="Python" icon="seti:python">
| Field | Type | Required | Description |
|---|---|---|---|
| `type` | `str` (`"stdio"` or `"sse"`) | Yes | Transport type |
| `type` | `str` (`"stdio"`, `"streamable-http"` or `"sse"`) | Yes | Transport type |
| `command` | `str` | stdio only | Executable to launch |
| `args` | `list[str]` | No | Arguments for the command |
| `env` | `dict[str, str]` | No | Environment variables for the subprocess |
| `url` | `str` | sse only | Full URL of the SSE endpoint |
| `headers` | `dict[str, str]` | No | HTTP headers for the SSE connection |
| `url` | `str` | streamable-http / sse | Full URL of the server endpoint |
| `headers` | `dict[str, str]` | No | HTTP headers for the connection |
</TabItem>
</Tabs>

Expand Down
38 changes: 29 additions & 9 deletions python/src/agent_squad/tools/mcp_tool_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

provider = await MCPToolProvider.create([
MCPServerConfig(type="stdio", command="uvx", args=["my-mcp-server"]),
MCPServerConfig(type="streamable-http", url="http://localhost:3000/mcp"),
MCPServerConfig(type="sse", url="http://localhost:3000/sse"),
])

Expand Down Expand Up @@ -42,6 +43,13 @@
"Install it with: pip install agent-squad[mcp]"
) from exc

# Guarded separately: the module only exists since mcp ~1.9, and older installs
# must keep working as long as they don't use the streamable-http transport.
try:
from mcp.client.streamable_http import streamablehttp_client
except ImportError:
streamablehttp_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 All @@ -57,18 +65,20 @@ 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``.
For Streamable HTTP (the current standard HTTP transport) set
``type="streamable-http"`` and provide ``url`` / ``headers``.
For the legacy HTTP+SSE transport set ``type="sse"`` and provide ``url`` / ``headers``.

Attributes:
type: Transport type — ``"stdio"`` or ``"sse"``.
type: Transport type — ``"stdio"``, ``"streamable-http"`` 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).
url: Server endpoint URL (streamable-http / sse).
headers: HTTP headers to send with the connection (streamable-http / sse).
"""

type: str # "stdio" or "sse"
type: str # "stdio", "streamable-http" or "sse"
command: Optional[str] = None
args: list[str] = field(default_factory=list)
env: Optional[dict[str, str]] = None
Expand Down Expand Up @@ -128,12 +138,12 @@ class MCPToolProvider(AgentTools):

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

Call :meth:`disconnect` when the provider is no longer needed to cleanly
shut down stdio child processes or SSE connections.
shut down stdio child processes or HTTP connections.

Args:
servers: List of :class:`MCPServerConfig` describing the MCP servers to
Expand Down Expand Up @@ -207,13 +217,23 @@ async def _ensure_connected(self) -> None:
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 {})
else:
raise ValueError(
f"Unsupported MCPServerConfig type: '{server_cfg.type}'. "
"Use 'stdio' or 'sse'."
"Use 'stdio', 'streamable-http' or 'sse'."
)

read, write = await cm.__aenter__()
# 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)
Expand Down
69 changes: 69 additions & 0 deletions python/src/tests/tools/test_mcp_tool_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,21 @@ def mock_mcp_modules():
mock_client_session_cls = MagicMock()
mock_stdio_client = MagicMock()
mock_sse_client = MagicMock()
mock_streamablehttp_client = MagicMock()
mock_stdio_params_cls = MagicMock()

with (
patch("agent_squad.tools.mcp_tool_provider.ClientSession", mock_client_session_cls),
patch("agent_squad.tools.mcp_tool_provider.stdio_client", mock_stdio_client),
patch("agent_squad.tools.mcp_tool_provider.sse_client", mock_sse_client),
patch("agent_squad.tools.mcp_tool_provider.streamablehttp_client", mock_streamablehttp_client),
patch("agent_squad.tools.mcp_tool_provider.StdioServerParameters", mock_stdio_params_cls),
):
yield {
"ClientSession": mock_client_session_cls,
"stdio_client": mock_stdio_client,
"sse_client": mock_sse_client,
"streamablehttp_client": mock_streamablehttp_client,
"StdioServerParameters": mock_stdio_params_cls,
}

Expand Down Expand Up @@ -404,6 +407,72 @@ async def test_lazy_connection_sse(mock_mcp_modules):
)


@pytest.mark.asyncio
async def test_lazy_connection_streamable_http(mock_mcp_modules):
from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig

tool = _make_mcp_tool("search", "Search")

read_mock = MagicMock()
write_mock = MagicMock()
get_session_id_mock = MagicMock()
fake_cm = AsyncMock()
# streamablehttp_client yields a 3-tuple, unlike stdio/sse
fake_cm.__aenter__ = AsyncMock(return_value=(read_mock, write_mock, get_session_id_mock))
fake_cm.__aexit__ = AsyncMock(return_value=False)

mock_mcp_modules["streamablehttp_client"].return_value = fake_cm

mock_session_instance = AsyncMock()
mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance)
mock_session_instance.__aexit__ = AsyncMock(return_value=False)
mock_session_instance.initialize = AsyncMock()
mock_session_instance.list_tools = AsyncMock(
return_value=_make_list_tools_result([tool])
)
mock_mcp_modules["ClientSession"].return_value = mock_session_instance

provider = MCPToolProvider(
[MCPServerConfig(
type="streamable-http",
url="http://localhost:9000/mcp",
headers={"x-api-key": "abc"},
)]
)

await provider._ensure_connected()

assert provider._connected
assert "search" in provider._tool_map
mock_mcp_modules["streamablehttp_client"].assert_called_once_with(
"http://localhost:9000/mcp", headers={"x-api-key": "abc"}
)


@pytest.mark.asyncio
async def test_streamable_http_missing_url(mock_mcp_modules):
from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig

provider = MCPToolProvider([MCPServerConfig(type="streamable-http")]) # no url

with pytest.raises(ValueError, match="url"):
await provider._ensure_connected()


@pytest.mark.asyncio
async def test_streamable_http_requires_recent_mcp(mock_mcp_modules):
"""When the installed mcp predates the streamable_http module, the error must say how to fix it."""
from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig

provider = MCPToolProvider(
[MCPServerConfig(type="streamable-http", url="http://localhost:9000/mcp")]
)

with patch("agent_squad.tools.mcp_tool_provider.streamablehttp_client", None):
with pytest.raises(ImportError, match="mcp>=1.9"):
await provider._ensure_connected()


@pytest.mark.asyncio
async def test_ensure_connected_idempotent(mock_mcp_modules):
"""Calling _ensure_connected twice should not reconnect."""
Expand Down
41 changes: 36 additions & 5 deletions typescript/src/tools/mcpToolProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,21 @@ function modelVisible(meta: any): boolean {
* Configuration for a single MCP server connection.
*/
export interface MCPServerConfig {
/** Transport type: stdio (spawn a local process) or sse (HTTP SSE endpoint) */
type: "stdio" | "sse";
/**
* Transport type: stdio (spawn a local process), streamable-http (the current
* standard HTTP transport), or sse (the legacy HTTP+SSE transport, kept for
* older servers).
*/
type: "stdio" | "sse" | "streamable-http";
/** stdio only: command to execute */
command?: string;
/** stdio only: arguments for the command */
args?: string[];
/** stdio only: environment variables for the child process */
env?: Record<string, string>;
/** sse only: full URL of the SSE endpoint */
/** streamable-http / sse: full URL of the server endpoint */
url?: string;
/** sse only: extra HTTP headers */
/** streamable-http / sse: extra HTTP headers */
headers?: Record<string, string>;
}

Expand All @@ -47,6 +51,7 @@ export interface MCPServerConfig {
* ```typescript
* const provider = await MCPToolProvider.create([
* { type: "stdio", command: "uvx", args: ["my-mcp-server"] },
* { type: "streamable-http", url: "http://localhost:3000/mcp" },
* { type: "sse", url: "http://localhost:3000/sse" },
* ]);
*
Expand Down Expand Up @@ -145,6 +150,15 @@ export class MCPToolProvider extends AgentTools {
SSEClientTransport = null;
}

let StreamableHTTPClientTransport: any;
try {
// @ts-ignore — optional peerDependency; module exists since SDK ~1.10
const streamableMod = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
StreamableHTTPClientTransport = streamableMod.StreamableHTTPClientTransport;
} catch {
StreamableHTTPClientTransport = null;
}

const allTools: AgentTool[] = [];

for (const serverConfig of this.servers) {
Expand Down Expand Up @@ -180,9 +194,26 @@ export class MCPToolProvider extends AgentTools {
transport = new SSEClientTransport(new URL(serverConfig.url), {
headers: serverConfig.headers ?? {},
});
} else if (serverConfig.type === "streamable-http") {
if (!StreamableHTTPClientTransport) {
throw new Error(
"StreamableHTTPClientTransport not available — upgrade @modelcontextprotocol/sdk (requires >=1.10)"
);
}
if (!serverConfig.url) {
throw new Error(
"MCPServerConfig with type 'streamable-http' requires a 'url' field"
);
}
transport = new StreamableHTTPClientTransport(
new URL(serverConfig.url),
serverConfig.headers
? { requestInit: { headers: serverConfig.headers } }
: undefined
);
} else {
throw new Error(
`Unsupported MCPServerConfig type: ${(serverConfig as any).type}`
`Unsupported MCPServerConfig type: ${(serverConfig as any).type}. Use 'stdio', 'streamable-http', or 'sse'.`
);
}

Expand Down
Loading
Loading