|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import asyncio |
| 6 | +import subprocess |
| 7 | +import sys |
| 8 | +from pathlib import Path |
| 9 | +from typing import TYPE_CHECKING |
6 | 10 | from unittest.mock import patch |
7 | 11 |
|
8 | 12 | import pytest |
9 | | -from fastmcp import FastMCP |
| 13 | +from fastmcp import Client, FastMCP |
10 | 14 |
|
11 | 15 | from aignostics.utils import ( |
12 | 16 | MCP_SERVER_NAME, |
| 17 | + discover_plugin_packages, |
13 | 18 | mcp_create_server, |
14 | 19 | mcp_discover_servers, |
15 | 20 | mcp_list_tools, |
16 | 21 | ) |
| 22 | +from aignostics.utils._di import _implementation_cache |
| 23 | + |
| 24 | +if TYPE_CHECKING: |
| 25 | + from collections.abc import Iterator |
17 | 26 |
|
18 | 27 | # Patch targets |
19 | 28 | PATCH_LOCATE_IMPLEMENTATIONS = "aignostics.utils._mcp.locate_implementations" |
@@ -175,3 +184,78 @@ def test_mcp_list_tools_empty(record_property) -> None: |
175 | 184 | tools = mcp_list_tools() |
176 | 185 | # Should return empty list when no plugins have tools |
177 | 186 | assert tools == [] |
| 187 | + |
| 188 | + |
| 189 | +# ============================================================================= |
| 190 | +# E2E Plugin Auto-Discovery Tests |
| 191 | +# ============================================================================= |
| 192 | + |
| 193 | +DUMMY_PLUGIN_DIR = Path(__file__).resolve().parents[2] / "resources" / "mcp_dummy_plugin" |
| 194 | + |
| 195 | + |
| 196 | +def _clear_mcp_discovery_caches() -> None: |
| 197 | + """Invalidate DI and plugin caches so MCP discovery starts fresh.""" |
| 198 | + _implementation_cache.pop(FastMCP, None) |
| 199 | + discover_plugin_packages.cache_clear() |
| 200 | + |
| 201 | + |
| 202 | +@pytest.fixture(scope="session") |
| 203 | +def install_dummy_mcp_plugin() -> Iterator[None]: |
| 204 | + """Install the dummy MCP plugin in editable mode and make it importable. |
| 205 | +
|
| 206 | + Refreshes site-packages so the running interpreter sees the new package |
| 207 | + and its entry points without a process restart. |
| 208 | + """ |
| 209 | + import importlib |
| 210 | + import site |
| 211 | + |
| 212 | + subprocess.check_call( |
| 213 | + [sys.executable, "-m", "pip", "install", "-e", str(DUMMY_PLUGIN_DIR)], |
| 214 | + stdout=subprocess.DEVNULL, |
| 215 | + stderr=subprocess.PIPE, |
| 216 | + ) |
| 217 | + |
| 218 | + importlib.invalidate_caches() |
| 219 | + for sp in site.getsitepackages(): |
| 220 | + site.addsitedir(sp) |
| 221 | + |
| 222 | + yield |
| 223 | + |
| 224 | + subprocess.check_call( |
| 225 | + [sys.executable, "-m", "pip", "uninstall", "-y", "mcp-dummy-plugin"], |
| 226 | + stdout=subprocess.DEVNULL, |
| 227 | + stderr=subprocess.PIPE, |
| 228 | + ) |
| 229 | + |
| 230 | + |
| 231 | +@pytest.fixture |
| 232 | +def clear_mcp_caches() -> Iterator[None]: |
| 233 | + """Clear MCP discovery caches before and after the test.""" |
| 234 | + _clear_mcp_discovery_caches() |
| 235 | + yield |
| 236 | + _clear_mcp_discovery_caches() |
| 237 | + |
| 238 | + |
| 239 | +@pytest.mark.e2e |
| 240 | +@pytest.mark.timeout(timeout=60) |
| 241 | +def test_mcp_server_discovers_and_serves_plugin_tools( |
| 242 | + install_dummy_mcp_plugin, clear_mcp_caches, record_property |
| 243 | +) -> None: |
| 244 | + """Full E2E: entry point registration -> discovery -> mount -> client round-trip.""" |
| 245 | + record_property("tested-item-id", "TC-UTILS-MCP-01") |
| 246 | + |
| 247 | + server = mcp_create_server() |
| 248 | + tool_names = list(asyncio.run(server.get_tools()).keys()) |
| 249 | + |
| 250 | + assert "dummy_plugin_dummy_echo" in tool_names |
| 251 | + assert "dummy_plugin_dummy_add" in tool_names |
| 252 | + |
| 253 | + async def _call_tools() -> tuple[str, str]: |
| 254 | + async with Client(server) as client: |
| 255 | + echo_result = await client.call_tool("dummy_plugin_dummy_echo", {"message": "hello"}) |
| 256 | + add_result = await client.call_tool("dummy_plugin_dummy_add", {"a": 2, "b": 3}) |
| 257 | + return echo_result.content[0].text, add_result.content[0].text |
| 258 | + |
| 259 | + echo_text, add_text = asyncio.run(_call_tools()) |
| 260 | + assert echo_text == "hello" |
| 261 | + assert add_text == "5" |
0 commit comments