|
3 | 3 |
|
4 | 4 | import pytest |
5 | 5 |
|
6 | | -from dspy.utils.mcp import convert_mcp_tool |
| 6 | +from dspy.utils.mcp import convert_mcp_tool, http_mcp_tools, stdio_mcp_tools |
7 | 7 |
|
8 | 8 | if importlib.util.find_spec("mcp") is None: |
9 | 9 | pytest.skip(reason="mcp is not installed", allow_module_level=True) |
|
14 | 14 | async def test_convert_mcp_tool(): |
15 | 15 | from mcp import ClientSession, StdioServerParameters |
16 | 16 | from mcp.client.stdio import stdio_client |
| 17 | + |
17 | 18 | server_params = StdioServerParameters( |
18 | 19 | command="python", |
19 | 20 | args=["tests/utils/resources/mcp_server.py"], |
@@ -96,3 +97,148 @@ async def test_convert_mcp_tool(): |
96 | 97 | assert current_datetime_tool.arg_types == {} |
97 | 98 | assert current_datetime_tool.arg_desc == {} |
98 | 99 | assert await current_datetime_tool.acall() == "2025-07-23T09:10:10.0+00:00" |
| 100 | + |
| 101 | + |
| 102 | +def _find_nested_exception(eg, exc_type): |
| 103 | + """Walk a (possibly nested) ExceptionGroup to find an exception of the given type.""" |
| 104 | + if isinstance(eg, exc_type): |
| 105 | + return eg |
| 106 | + if isinstance(eg, BaseException) and hasattr(eg, "exceptions"): |
| 107 | + for exc in eg.exceptions: |
| 108 | + found = _find_nested_exception(exc, exc_type) |
| 109 | + if found: |
| 110 | + return found |
| 111 | + return None |
| 112 | + |
| 113 | + |
| 114 | +@pytest.fixture() |
| 115 | +def server_params(): |
| 116 | + from mcp import StdioServerParameters |
| 117 | + |
| 118 | + return StdioServerParameters( |
| 119 | + command="python", |
| 120 | + args=["tests/utils/resources/mcp_server.py"], |
| 121 | + env=None, |
| 122 | + ) |
| 123 | + |
| 124 | + |
| 125 | +@pytest.mark.asyncio |
| 126 | +@pytest.mark.extra |
| 127 | +async def test_stdio_mcp_tools(server_params): |
| 128 | + # Basic usage: all tools available and callable |
| 129 | + async with stdio_mcp_tools(server_params) as tools: |
| 130 | + names = [t.name for t in tools] |
| 131 | + assert "add" in names |
| 132 | + assert "hello" in names |
| 133 | + assert await tools[0].acall(a=1, b=2) == "3" |
| 134 | + |
| 135 | + # include_tools: only the specified tool is returned |
| 136 | + async with stdio_mcp_tools(server_params, include_tools=["add"]) as tools: |
| 137 | + assert len(tools) == 1 |
| 138 | + assert tools[0].name == "add" |
| 139 | + |
| 140 | + # exclude_tools: the specified tool is filtered out |
| 141 | + async with stdio_mcp_tools(server_params, exclude_tools=["wrong_tool"]) as tools: |
| 142 | + names = [t.name for t in tools] |
| 143 | + assert "wrong_tool" not in names |
| 144 | + assert "add" in names |
| 145 | + |
| 146 | + # include_tools and exclude_tools together raises ValueError |
| 147 | + with pytest.raises(ValueError, match="Cannot specify both include_tools and exclude_tools"): |
| 148 | + async with stdio_mcp_tools(server_params, include_tools=["add"], exclude_tools=["hello"]) as _: |
| 149 | + pass |
| 150 | + |
| 151 | + # timeout: session works with a timeout set |
| 152 | + async with stdio_mcp_tools(server_params, timeout=10) as tools: |
| 153 | + assert len(tools) > 0 |
| 154 | + |
| 155 | + # include_tools with unknown name raises ValueError |
| 156 | + try: |
| 157 | + async with stdio_mcp_tools(server_params, include_tools=["nonexistent"]) as _: |
| 158 | + pass |
| 159 | + pytest.fail("Expected ValueError") |
| 160 | + except BaseException as eg: |
| 161 | + err = _find_nested_exception(eg, ValueError) |
| 162 | + assert err and "Tool names not found on MCP server" in str(err) |
| 163 | + |
| 164 | + # exclude_tools with unknown name raises ValueError |
| 165 | + try: |
| 166 | + async with stdio_mcp_tools(server_params, exclude_tools=["nonexistent"]) as _: |
| 167 | + pass |
| 168 | + pytest.fail("Expected ValueError") |
| 169 | + except BaseException as eg: |
| 170 | + err = _find_nested_exception(eg, ValueError) |
| 171 | + assert err and "Tool names not found on MCP server" in str(err) |
| 172 | + |
| 173 | + |
| 174 | +@pytest.fixture() |
| 175 | +def mcp_proxy_url(): |
| 176 | + """Start mcp-proxy as a subprocess, yield the URL, and tear down on exit.""" |
| 177 | + import socket |
| 178 | + import subprocess |
| 179 | + import sys |
| 180 | + import time |
| 181 | + |
| 182 | + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: |
| 183 | + s.bind(("", 0)) |
| 184 | + port = s.getsockname()[1] |
| 185 | + |
| 186 | + url = f"http://127.0.0.1:{port}/mcp" |
| 187 | + proc = subprocess.Popen( |
| 188 | + ["mcp-proxy", "--port", str(port), "--", sys.executable, "tests/utils/resources/mcp_server.py"], |
| 189 | + stdout=subprocess.PIPE, |
| 190 | + stderr=subprocess.PIPE, |
| 191 | + ) |
| 192 | + try: |
| 193 | + for _ in range(50): |
| 194 | + try: |
| 195 | + with socket.create_connection(("127.0.0.1", port), timeout=0.1): |
| 196 | + break |
| 197 | + except OSError: |
| 198 | + time.sleep(0.1) |
| 199 | + else: |
| 200 | + raise TimeoutError(f"mcp-proxy did not start on port {port}") |
| 201 | + yield url |
| 202 | + finally: |
| 203 | + proc.terminate() |
| 204 | + proc.wait(timeout=5) |
| 205 | + |
| 206 | + |
| 207 | +@pytest.mark.asyncio |
| 208 | +@pytest.mark.extra |
| 209 | +async def test_http_mcp_tools(mcp_proxy_url): |
| 210 | + # Basic usage: all tools available and callable |
| 211 | + async with http_mcp_tools(mcp_proxy_url) as tools: |
| 212 | + names = [t.name for t in tools] |
| 213 | + assert "add" in names |
| 214 | + assert "hello" in names |
| 215 | + assert await tools[0].acall(a=1, b=2) == "3" |
| 216 | + |
| 217 | + # include_tools: only the specified tool is returned |
| 218 | + async with http_mcp_tools(mcp_proxy_url, include_tools=["add"]) as tools: |
| 219 | + assert len(tools) == 1 |
| 220 | + assert tools[0].name == "add" |
| 221 | + |
| 222 | + # exclude_tools: the specified tool is filtered out |
| 223 | + async with http_mcp_tools(mcp_proxy_url, exclude_tools=["wrong_tool"]) as tools: |
| 224 | + names = [t.name for t in tools] |
| 225 | + assert "wrong_tool" not in names |
| 226 | + assert "add" in names |
| 227 | + |
| 228 | + # include_tools with unknown name raises ValueError |
| 229 | + try: |
| 230 | + async with http_mcp_tools(mcp_proxy_url, include_tools=["nonexistent"]) as _: |
| 231 | + pass |
| 232 | + pytest.fail("Expected ValueError") |
| 233 | + except BaseException as eg: |
| 234 | + err = _find_nested_exception(eg, ValueError) |
| 235 | + assert err and "Tool names not found on MCP server" in str(err) |
| 236 | + |
| 237 | + # exclude_tools with unknown name raises ValueError |
| 238 | + try: |
| 239 | + async with http_mcp_tools(mcp_proxy_url, exclude_tools=["nonexistent"]) as _: |
| 240 | + pass |
| 241 | + pytest.fail("Expected ValueError") |
| 242 | + except BaseException as eg: |
| 243 | + err = _find_nested_exception(eg, ValueError) |
| 244 | + assert err and "Tool names not found on MCP server" in str(err) |
0 commit comments