diff --git a/docs/src/content/docs/agents/mcp-tool-provider.mdx b/docs/src/content/docs/agents/mcp-tool-provider.mdx index eb583d05..816ddbfd 100644 --- a/docs/src/content/docs/agents/mcp-tool-provider.mdx +++ b/docs/src/content/docs/agents/mcp-tool-provider.mdx @@ -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 @@ -122,9 +122,44 @@ provider = await MCPToolProvider.create([ -### 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. + + + +```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. + + +```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]"`). + + + +### 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. @@ -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" }, ]); ``` @@ -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"), ]) ``` @@ -259,22 +294,22 @@ Available in the Python and TypeScript `MCPToolProvider`. See the [GroundedAgent | 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` | No | Environment variables for the subprocess | -| `url` | `string` | sse only | Full URL of the SSE endpoint | -| `headers` | `Record` | No | HTTP headers for the SSE connection | +| `url` | `string` | streamable-http / sse | Full URL of the server endpoint | +| `headers` | `Record` | No | HTTP headers for the connection | | 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 | diff --git a/python/src/agent_squad/tools/mcp_tool_provider.py b/python/src/agent_squad/tools/mcp_tool_provider.py index 8921ac84..57be6f9b 100644 --- a/python/src/agent_squad/tools/mcp_tool_provider.py +++ b/python/src/agent_squad/tools/mcp_tool_provider.py @@ -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"), ]) @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/python/src/tests/tools/test_mcp_tool_provider.py b/python/src/tests/tools/test_mcp_tool_provider.py index 8bb10c00..e3759dda 100644 --- a/python/src/tests/tools/test_mcp_tool_provider.py +++ b/python/src/tests/tools/test_mcp_tool_provider.py @@ -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, } @@ -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.""" diff --git a/typescript/src/tools/mcpToolProvider.ts b/typescript/src/tools/mcpToolProvider.ts index cc1bf98c..db96d012 100644 --- a/typescript/src/tools/mcpToolProvider.ts +++ b/typescript/src/tools/mcpToolProvider.ts @@ -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; - /** 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; } @@ -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" }, * ]); * @@ -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) { @@ -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'.` ); } diff --git a/typescript/tests/mcpToolProvider.test.ts b/typescript/tests/mcpToolProvider.test.ts index 22c34836..d36b9aa0 100644 --- a/typescript/tests/mcpToolProvider.test.ts +++ b/typescript/tests/mcpToolProvider.test.ts @@ -30,9 +30,17 @@ class MockSSETransport { constructor(public url: any, public opts: any) {} } +const streamableHttpInstances: MockStreamableHTTPTransport[] = []; +class MockStreamableHTTPTransport { + constructor(public url: any, public opts: any) { + streamableHttpInstances.push(this); + } +} + jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: MockClient }), { virtual: true }); jest.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ StdioClientTransport: MockStdioTransport }), { virtual: true }); jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ SSEClientTransport: MockSSETransport }), { virtual: true }); +jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ StreamableHTTPClientTransport: MockStreamableHTTPTransport }), { virtual: true }); // --------------------------------------------------------------------------- // Subject under test @@ -431,6 +439,46 @@ describe("MCPToolProvider", () => { expect(mockConnect).toHaveBeenCalled(); }); + // ------------------------------------------------------------------------- + // Streamable HTTP transport + // ------------------------------------------------------------------------- + + it("creates StreamableHTTPClientTransport with headers in requestInit", async () => { + streamableHttpInstances.length = 0; + const provider = new MCPToolProvider([ + { type: "streamable-http", url: "http://localhost:9000/mcp", headers: { "x-api-key": "abc" } }, + ]); + + await provider.ensureConnected(); + expect(mockConnect).toHaveBeenCalled(); + expect(streamableHttpInstances).toHaveLength(1); + expect(streamableHttpInstances[0].url.href).toBe("http://localhost:9000/mcp"); + expect(streamableHttpInstances[0].opts).toEqual({ + requestInit: { headers: { "x-api-key": "abc" } }, + }); + }); + + it("creates StreamableHTTPClientTransport without options when no headers given", async () => { + streamableHttpInstances.length = 0; + const provider = new MCPToolProvider([ + { type: "streamable-http", url: "http://localhost:9000/mcp" }, + ]); + + await provider.ensureConnected(); + expect(streamableHttpInstances).toHaveLength(1); + expect(streamableHttpInstances[0].opts).toBeUndefined(); + }); + + it("throws when streamable-http server config has no url", async () => { + const badProvider = new MCPToolProvider([ + { type: "streamable-http" } as MCPServerConfig, + ]); + + await expect(badProvider.ensureConnected()).rejects.toThrow( + "requires a 'url' field" + ); + }); + // ------------------------------------------------------------------------- // Config validation // -------------------------------------------------------------------------