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
15 changes: 15 additions & 0 deletions tests/aignostics/utils/TC-UTILS-MCP-01.feature
Original file line number Diff line number Diff line change
@@ -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"
98 changes: 97 additions & 1 deletion tests/aignostics/utils/mcp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
16 changes: 16 additions & 0 deletions tests/resources/mcp_dummy_plugin/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Dummy MCP plugin for integration testing of plugin auto-discovery."""

from ._mcp import mcp

__all__ = ["mcp"]
17 changes: 17 additions & 0 deletions tests/resources/mcp_dummy_plugin/src/mcp_dummy_plugin/_mcp.py
Original file line number Diff line number Diff line change
@@ -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)
Loading