diff --git a/tests/aignostics/utils/TC-UTILS-MCP-01.feature b/tests/aignostics/utils/TC-UTILS-MCP-01.feature new file mode 100644 index 000000000..7b8f1f0f0 --- /dev/null +++ b/tests/aignostics/utils/TC-UTILS-MCP-01.feature @@ -0,0 +1,15 @@ +Feature: MCP Server Plugin Auto-Discovery + + The central MCP server discovers plugin tools registered via Python entry + points, mounts them with namespace isolation, and serves them to MCP clients. + + @tests:SPEC-UTILS-SERVICE + @tests:SWR-UTILS-1-1 + @id:TC-UTILS-MCP-01 + Scenario: Server discovers plugin tools via entry points and serves them to a client + Given a plugin package registers an entry point under "aignostics.plugins" + And the plugin exposes a FastMCP instance with tools "dummy_echo" and "dummy_add" + When the MCP server is created via mcp_create_server() + And a client connects and lists tools via client.list_tools() + Then the returned tool list includes "dummy_plugin_dummy_echo" and "dummy_plugin_dummy_add" + And calling "dummy_plugin_dummy_echo" with message "hello" returns "hello" diff --git a/tests/aignostics/utils/mcp_test.py b/tests/aignostics/utils/mcp_test.py index 365ea5fa3..0ad17674f 100644 --- a/tests/aignostics/utils/mcp_test.py +++ b/tests/aignostics/utils/mcp_test.py @@ -3,17 +3,26 @@ from __future__ import annotations import asyncio +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING from unittest.mock import patch import pytest -from fastmcp import FastMCP +from fastmcp import Client, FastMCP from aignostics.utils import ( MCP_SERVER_NAME, + discover_plugin_packages, mcp_create_server, mcp_discover_servers, mcp_list_tools, ) +from aignostics.utils._di import _implementation_cache + +if TYPE_CHECKING: + from collections.abc import Iterator # Patch targets PATCH_LOCATE_IMPLEMENTATIONS = "aignostics.utils._mcp.locate_implementations" @@ -175,3 +184,90 @@ def test_mcp_list_tools_empty(record_property) -> None: tools = mcp_list_tools() # Should return empty list when no plugins have tools assert tools == [] + + +# ============================================================================= +# Integration Plugin Auto-Discovery Tests +# ============================================================================= + +DUMMY_PLUGIN_DIR = Path(__file__).resolve().parents[2] / "resources" / "mcp_dummy_plugin" + + +def _clear_mcp_discovery_caches() -> None: + """Invalidate DI and plugin caches so MCP discovery starts fresh.""" + _implementation_cache.pop(FastMCP, None) + discover_plugin_packages.cache_clear() + + +@pytest.fixture(scope="session") +def install_dummy_mcp_plugin() -> Iterator[None]: + """Install the dummy MCP plugin in editable mode and make it importable. + + Refreshes site-packages so the running interpreter sees the new package + and its entry points without a process restart. + """ + import importlib + import site + + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--no-deps", + "-e", + str(DUMMY_PLUGIN_DIR), + ], + check=True, + capture_output=True, + text=True, + ) + + importlib.invalidate_caches() + for sp in site.getsitepackages(): + site.addsitedir(sp) + + yield + + subprocess.run( + [sys.executable, "-m", "pip", "uninstall", "-y", "mcp-dummy-plugin"], + check=True, + capture_output=True, + text=True, + ) + + +@pytest.fixture +def clear_mcp_caches() -> Iterator[None]: + """Clear MCP discovery caches before and after the test.""" + _clear_mcp_discovery_caches() + yield + _clear_mcp_discovery_caches() + + +@pytest.mark.integration +@pytest.mark.sequential +@pytest.mark.timeout(timeout=60) +def test_mcp_server_discovers_and_serves_plugin_tools( + install_dummy_mcp_plugin, clear_mcp_caches, record_property +) -> None: + """Integration: entry point registration -> discovery -> mount -> client round-trip.""" + record_property("tested-item-id", "TC-UTILS-MCP-01") + + server = mcp_create_server() + + async def _call_tools() -> tuple[list[str], str, str]: + async with Client(server) as client: + tools = await client.list_tools() + tool_names = [t.name for t in tools] + echo_result = await client.call_tool("dummy_plugin_dummy_echo", {"message": "hello"}) + add_result = await client.call_tool("dummy_plugin_dummy_add", {"a": 2, "b": 3}) + return tool_names, echo_result.content[0].text, add_result.content[0].text + + tool_names, echo_text, add_text = asyncio.run(_call_tools()) + + assert "dummy_plugin_dummy_echo" in tool_names + assert "dummy_plugin_dummy_add" in tool_names + assert echo_text == "hello" + assert add_text == "5" diff --git a/tests/resources/mcp_dummy_plugin/pyproject.toml b/tests/resources/mcp_dummy_plugin/pyproject.toml new file mode 100644 index 000000000..7e37c32b1 --- /dev/null +++ b/tests/resources/mcp_dummy_plugin/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mcp-dummy-plugin" +version = "0.0.1" +description = "Dummy MCP plugin for integration testing of plugin auto-discovery." +requires-python = ">=3.11" +dependencies = ["fastmcp>=2.0.0,<3"] + +[project.entry-points."aignostics.plugins"] +mcp_dummy_plugin = "mcp_dummy_plugin" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_dummy_plugin"] diff --git a/tests/resources/mcp_dummy_plugin/src/mcp_dummy_plugin/__init__.py b/tests/resources/mcp_dummy_plugin/src/mcp_dummy_plugin/__init__.py new file mode 100644 index 000000000..186fed3f5 --- /dev/null +++ b/tests/resources/mcp_dummy_plugin/src/mcp_dummy_plugin/__init__.py @@ -0,0 +1,5 @@ +"""Dummy MCP plugin for integration testing of plugin auto-discovery.""" + +from ._mcp import mcp + +__all__ = ["mcp"] diff --git a/tests/resources/mcp_dummy_plugin/src/mcp_dummy_plugin/_mcp.py b/tests/resources/mcp_dummy_plugin/src/mcp_dummy_plugin/_mcp.py new file mode 100644 index 000000000..c034c1cc1 --- /dev/null +++ b/tests/resources/mcp_dummy_plugin/src/mcp_dummy_plugin/_mcp.py @@ -0,0 +1,17 @@ +"""Dummy MCP tools for integration testing.""" + +from fastmcp import FastMCP + +mcp = FastMCP("dummy_plugin") + + +@mcp.tool +def dummy_echo(message: str) -> str: + """Echo the provided message back.""" + return message + + +@mcp.tool +def dummy_add(a: int, b: int) -> str: + """Add two integers and return the result as a string.""" + return str(a + b)