diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5a129ff..13ab724 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,50 +29,16 @@ jobs: - name: Run linting run: | - # Install linting tools - pip install flake8 black isort - # Check formatting - black --check --diff . - # Check import sorting - isort --check-only --diff . - # Run flake8 - flake8 jupyter_server_docs_mcp tests + # Install ruff + pip install ruff + # Check linting and formatting + ruff check jupyter_server_mcp tests + ruff format --check jupyter_server_mcp tests - name: Run unit tests run: | - pytest tests/ -v -m "not slow and not integration" --cov=jupyter_server_docs_mcp --cov-report=xml + pytest tests/ -v -m "not slow and not integration" - name: Run integration tests run: | - pytest tests/ -v -m "integration" - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml - fail_ci_if_error: true - - test-optional-deps: - runs-on: ubuntu-latest - strategy: - matrix: - extra: [fastmcp, jupyterlab, full] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python 3.11 - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install with ${{ matrix.extra }} dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[${{ matrix.extra }},test] - - - name: Run tests with optional dependencies - run: | - pytest tests/ -v --tb=short \ No newline at end of file + pytest tests/ -v -m "integration" \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7faf40..9d0f6b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # Byte-compiled / optimized / DLL files +.claude +.vscode +demo/ __pycache__/ *.py[codz] *$py.class diff --git a/README.md b/README.md index ffa1bd6..b07a908 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,12 @@ The MCP server will start automatically on `http://localhost:8080/mcp`. ### Core Components -#### MCPServer (`jupyter_server_docs_mcp.mcp_server.MCPServer`) +#### MCPServer (`jupyter_server_mcp.mcp_server.MCPServer`) A simplified LoggingConfigurable class that manages FastMCP integration: ```python -from jupyter_server_docs_mcp.mcp_server import MCPServer +from jupyter_server_mcp.mcp_server import MCPServer # Create server server = MCPServer(name="My Server", port=8080) @@ -106,7 +106,7 @@ await server.start_server() - `list_tools()` - Get list of registered tools - `start_server(host=None)` - Start the HTTP MCP server -#### MCPExtensionApp (`jupyter_server_docs_mcp.extension.MCPExtensionApp`) +#### MCPExtensionApp (`jupyter_server_mcp.extension.MCPExtensionApp`) Jupyter Server extension that manages the MCP server lifecycle: @@ -188,14 +188,14 @@ pip install -e ".[dev]" pytest tests/ -v # Run with coverage -pytest --cov=jupyter_server_docs_mcp tests/ +pytest --cov=jupyter_server_mcp tests/ ``` ### Project Structure ``` -jupyter_server_docs_mcp/ -├── jupyter_server_docs_mcp/ +jupyter_server_mcp/ +├── jupyter_server_mcp/ │ ├── __init__.py │ ├── mcp_server.py # Core MCP server implementation │ └── extension.py # Jupyter Server extension diff --git a/jupyter-config/jupyter_server_config.d/jupyter_server_docs_mcp.json b/jupyter-config/jupyter_server_config.d/jupyter_server_mcp.json similarity index 60% rename from jupyter-config/jupyter_server_config.d/jupyter_server_docs_mcp.json rename to jupyter-config/jupyter_server_config.d/jupyter_server_mcp.json index 7f060f0..8d15d25 100644 --- a/jupyter-config/jupyter_server_config.d/jupyter_server_docs_mcp.json +++ b/jupyter-config/jupyter_server_config.d/jupyter_server_mcp.json @@ -1,7 +1,7 @@ { "ServerApp": { "jpserver_extensions": { - "jupyter_server_docs_mcp": true + "jupyter_server_mcp": true } } } \ No newline at end of file diff --git a/jupyter_server_docs_mcp/__init__.py b/jupyter_server_docs_mcp/__init__.py deleted file mode 100644 index 1d1adeb..0000000 --- a/jupyter_server_docs_mcp/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Jupyter Server MCP Extension with configurable tools.""" - -__version__ = "0.1.0" - -from .extension import MCPExtensionApp -from typing import Any, Dict, List - - -def _jupyter_server_extension_points() -> List[Dict[str, Any]]: # pragma: no cover - return [ - { - "module": "jupyter_server_docs_mcp.extension", - "app": MCPExtensionApp, - }, - ] - -__all__ = ["MCPExtensionApp"] \ No newline at end of file diff --git a/jupyter_server_mcp/__init__.py b/jupyter_server_mcp/__init__.py new file mode 100644 index 0000000..9d74965 --- /dev/null +++ b/jupyter_server_mcp/__init__.py @@ -0,0 +1,20 @@ +"""Jupyter Server MCP Extension with configurable tools.""" + +from typing import Any + +from .extension import MCPExtensionApp + +__version__ = "0.1.0" + + +def _jupyter_server_extension_points() -> list[dict[str, Any]]: + # pragma: no cover + return [ + { + "module": "jupyter_server_mcp.extension", + "app": MCPExtensionApp, + }, + ] + + +__all__ = ["MCPExtensionApp"] diff --git a/jupyter_server_docs_mcp/extension.py b/jupyter_server_mcp/extension.py similarity index 64% rename from jupyter_server_docs_mcp/extension.py rename to jupyter_server_mcp/extension.py index dd0127e..2f80505 100644 --- a/jupyter_server_docs_mcp/extension.py +++ b/jupyter_server_mcp/extension.py @@ -1,12 +1,12 @@ """Jupyter Server extension for managing MCP server.""" import asyncio +import contextlib import importlib import logging -from typing import Optional from jupyter_server.extension.application import ExtensionApp -from traitlets import Int, Unicode, List +from traitlets import Int, List, Unicode from .mcp_server import MCPServer @@ -15,66 +15,73 @@ class MCPExtensionApp(ExtensionApp): """The Jupyter Server MCP extension app.""" - - name = "jupyter_server_docs_mcp" + + name = "jupyter_server_mcp" description = "Jupyter Server extension providing MCP server for tool registration" - + # Configurable traits - mcp_port = Int( - default_value=3001, - help="Port for the MCP server to listen on" - ).tag(config=True) - + mcp_port = Int(default_value=3001, help="Port for the MCP server to listen on").tag( + config=True + ) + mcp_name = Unicode( - default_value="Jupyter MCP Server", - help="Name for the MCP server" + default_value="Jupyter MCP Server", help="Name for the MCP server" ).tag(config=True) - + mcp_tools = List( trait=Unicode(), default_value=[], - help="List of tools to register with the MCP server. " - "Format: 'module_path:function_name' (e.g., 'os:getcwd', 'math:sqrt')" + help=( + "List of tools to register with the MCP server. " + "Format: 'module_path:function_name' " + "(e.g., 'os:getcwd', 'math:sqrt')" + ), ).tag(config=True) - - mcp_server_instance: Optional[object] = None - mcp_server_task: Optional[asyncio.Task] = None - + + mcp_server_instance: object | None = None + mcp_server_task: asyncio.Task | None = None + def _load_function_from_string(self, tool_spec: str): """Load a function from a string specification. - + Args: - tool_spec: Function specification in format 'module_path:function_name' - + tool_spec: Function specification in format + 'module_path:function_name' + Returns: The loaded function object - + Raises: ValueError: If tool_spec format is invalid ImportError: If module cannot be imported AttributeError: If function not found in module """ - if ':' not in tool_spec: - raise ValueError(f"Invalid tool specification '{tool_spec}'. Expected format: 'module_path:function_name'") - - module_path, function_name = tool_spec.rsplit(':', 1) - + if ":" not in tool_spec: + msg = ( + f"Invalid tool specification '{tool_spec}'. " + f"Expected format: 'module_path:function_name'" + ) + raise ValueError(msg) + + module_path, function_name = tool_spec.rsplit(":", 1) + try: module = importlib.import_module(module_path) - function = getattr(module, function_name) - return function + return getattr(module, function_name) except ImportError as e: - raise ImportError(f"Could not import module '{module_path}': {e}") + msg = f"Could not import module '{module_path}': {e}" + raise ImportError(msg) from e except AttributeError as e: - raise AttributeError(f"Function '{function_name}' not found in module '{module_path}': {e}") - + msg = f"Function '{function_name}' not found in module '{module_path}': {e}" + raise AttributeError(msg) from e + def _register_configured_tools(self): """Register tools specified in the mcp_tools configuration.""" if not self.mcp_tools: return - + logger.info(f"Registering {len(self.mcp_tools)} configured tools") - + for tool_spec in self.mcp_tools: try: function = self._load_function_from_string(tool_spec) @@ -83,69 +90,66 @@ def _register_configured_tools(self): except Exception as e: logger.error(f"❌ Failed to register tool '{tool_spec}': {e}") continue - + def initialize(self): """Initialize the extension.""" super().initialize() # serverapp will be available as self.serverapp after parent initialization - + def initialize_handlers(self): """Initialize the handlers for the extension.""" # No HTTP handlers needed - MCP server runs on separate port - pass - + def initialize_settings(self): - """Initialize settings for the extension.""" + """Initialize settings for the extension.""" # Configuration is handled by traitlets - pass - + async def start_extension(self): """Start the extension - called after Jupyter Server starts.""" try: - self.log.info(f"Starting MCP server '{self.mcp_name}' on port {self.mcp_port}") - + self.log.info( + f"Starting MCP server '{self.mcp_name}' on port {self.mcp_port}" + ) + self.mcp_server_instance = MCPServer( - parent=self, - name=self.mcp_name, - port=self.mcp_port + parent=self, name=self.mcp_name, port=self.mcp_port ) - + # Register configured tools self._register_configured_tools() - + # Start the MCP server in a background task self.mcp_server_task = asyncio.create_task( self.mcp_server_instance.start_server() ) - + # Give the server a moment to start await asyncio.sleep(0.5) - + self.log.info(f"✅ MCP server started on port {self.mcp_port}") if self.mcp_tools: - self.log.info(f"Registered {len(self.mcp_server_instance._registered_tools)} tools from configuration") + registered_count = len(self.mcp_server_instance._registered_tools) + self.log.info(f"Registered {registered_count} tools from configuration") else: self.log.info("Use mcp_server_instance.register_tool() to add tools") - + except Exception as e: self.log.error(f"Failed to start MCP server: {e}") raise - - async def _start_jupyter_server_extension(self, serverapp): + + async def _start_jupyter_server_extension(self, serverapp): # noqa: ARG002 """Start the extension - called after Jupyter Server starts.""" await self.start_extension() - + async def stop_extension(self): """Stop the extension - called when Jupyter Server shuts down.""" if self.mcp_server_task and not self.mcp_server_task.done(): self.log.info("Stopping MCP server") self.mcp_server_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await self.mcp_server_task - except asyncio.CancelledError: - pass - + # Always clean up self.mcp_server_task = None self.mcp_server_instance = None - self.log.info("MCP server stopped") \ No newline at end of file + self.log.info("MCP server stopped") diff --git a/jupyter_server_docs_mcp/mcp_server.py b/jupyter_server_mcp/mcp_server.py similarity index 60% rename from jupyter_server_docs_mcp/mcp_server.py rename to jupyter_server_mcp/mcp_server.py index 41cc281..4f015e1 100644 --- a/jupyter_server_docs_mcp/mcp_server.py +++ b/jupyter_server_mcp/mcp_server.py @@ -1,11 +1,12 @@ """Simple MCP server for registering Python functions as tools.""" import logging -from typing import Any, Callable, Dict, List, Optional, Union -from inspect import signature, iscoroutinefunction +from collections.abc import Callable +from inspect import iscoroutinefunction +from typing import Any from fastmcp import FastMCP -from traitlets import Int, Unicode, Bool, Union as TraitUnion, TraitError +from traitlets import Bool, Int, Unicode from traitlets.config.configurable import LoggingConfigurable logger = logging.getLogger(__name__) @@ -13,70 +14,77 @@ class MCPServer(LoggingConfigurable): """Simple MCP server that allows registering Python functions as tools.""" - + # Configurable traits name = Unicode( - default_value="Jupyter MCP Server", - help="Name for the MCP server" - ).tag(config=True) - - port = Int( - default_value=3001, - help="Port for the MCP server to listen on" + default_value="Jupyter MCP Server", help="Name for the MCP server" ).tag(config=True) - + + port = Int(default_value=3001, help="Port for the MCP server to listen on").tag( + config=True + ) + host = Unicode( - default_value="localhost", - help="Host for the MCP server to listen on" + default_value="localhost", help="Host for the MCP server to listen on" ).tag(config=True) - + enable_debug_logging = Bool( - default_value=False, - help="Enable debug logging for MCP operations" + default_value=False, help="Enable debug logging for MCP operations" ).tag(config=True) def __init__(self, **kwargs): """Initialize the MCP server. - + Args: **kwargs: Configuration parameters """ super().__init__(**kwargs) - + # Initialize FastMCP and tools registry self.mcp = FastMCP(self.name) self._registered_tools = {} - self.log.info(f"Initialized MCP server '{self.name}' on {self.host}:{self.port}") - - def register_tool(self, func: Callable, name: Optional[str] = None, description: Optional[str] = None): + self.log.info( + f"Initialized MCP server '{self.name}' on {self.host}:{self.port}" + ) + + def register_tool( + self, + func: Callable, + name: str | None = None, + description: str | None = None, + ): """Register a Python function as an MCP tool. - + Args: func: Python function to register name: Optional tool name (defaults to function name) - description: Optional tool description (defaults to function docstring) + description: Optional tool description (defaults to function + docstring) """ tool_name = name or func.__name__ tool_description = description or func.__doc__ or f"Tool: {tool_name}" - + self.log.info(f"Registering tool: {tool_name}") if self.enable_debug_logging: - self.log.debug(f"Tool details - Name: {tool_name}, Description: {tool_description}, Async: {iscoroutinefunction(func)}") - + self.log.debug( + f"Tool details - Name: {tool_name}, " + f"Description: {tool_description}, Async: {iscoroutinefunction(func)}" + ) + # Register with FastMCP self.mcp.tool(func) - + # Keep track for listing self._registered_tools[tool_name] = { "name": tool_name, "description": tool_description, "function": func, - "is_async": iscoroutinefunction(func) + "is_async": iscoroutinefunction(func), } - - def register_tools(self, tools: Union[List[Callable], Dict[str, Callable]]): + + def register_tools(self, tools: list[Callable] | dict[str, Callable]): """Register multiple Python functions as MCP tools. - + Args: tools: List of functions or dict mapping names to functions """ @@ -87,31 +95,31 @@ def register_tools(self, tools: Union[List[Callable], Dict[str, Callable]]): for name, func in tools.items(): self.register_tool(func, name=name) else: - raise ValueError("tools must be a list of functions or dict mapping names to functions") - - def list_tools(self) -> List[Dict[str, Any]]: + msg = "tools must be a list of functions or dict mapping names to functions" + raise ValueError(msg) + + def list_tools(self) -> list[dict[str, Any]]: """List all registered tools.""" return [ - { - "name": tool["name"], - "description": tool["description"] - } + {"name": tool["name"], "description": tool["description"]} for tool in self._registered_tools.values() ] - - def get_tool_info(self, tool_name: str) -> Optional[Dict[str, Any]]: + + def get_tool_info(self, tool_name: str) -> dict[str, Any] | None: """Get information about a specific tool.""" return self._registered_tools.get(tool_name) - - async def start_server(self, host: Optional[str] = None): + + async def start_server(self, host: str | None = None): """Start the MCP server on the specified host and port.""" server_host = host or self.host - + self.log.info(f"Starting MCP server '{self.name}' on {server_host}:{self.port}") self.log.info(f"Registered tools: {list(self._registered_tools.keys())}") - + if self.enable_debug_logging: - self.log.debug(f"Server configuration - Host: {server_host}, Port: {self.port}") - + self.log.debug( + f"Server configuration - Host: {server_host}, Port: {self.port}" + ) + # Start FastMCP server with HTTP transport await self.mcp.run_http_async(host=server_host, port=self.port) diff --git a/pyproject.toml b/pyproject.toml index d1033f6..30005da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.5"] build-backend = "hatchling.build" [project] -name = "jupyter_server_docs_mcp" +name = "jupyter_server_mcp" authors = [{name = "Jupyter Developer", email = "jupyter@example.com"}] dynamic = ["version"] readme = "README.md" @@ -24,28 +24,62 @@ dependencies = [ ] [project.optional-dependencies] -jupyterlab = ["jupyterlab-commands-toolkit"] -jupyter_ai = ["jupyter_ai_tools>=0.2.0"] test = [ "pytest>=7.0", - "pytest-asyncio>=0.21.0", + "pytest-asyncio>=0.21.0", "pytest-jupyter[server]>=0.6", "pytest-tornasync>=0.6.0", "pytest-mock>=3.10.0" ] -full = [ - "jupyterlab-commands-toolkit", - "jupyter_ai_tools>=0.2.0" +dev = [ + "ruff>=0.1.0" ] [project.license] file="LICENSE" [project.entry-points."jupyter_server.extension_points"] -jupyter_server_docs_mcp = "jupyter_server_docs_mcp.extension:MCPExtensionApp" +jupyter_server_mcp = "jupyter_server_mcp.extension:MCPExtensionApp" [tool.hatch.version] -path = "jupyter_server_docs_mcp/__init__.py" +path = "jupyter_server_mcp/__init__.py" [tool.hatch.build.targets.wheel.shared-data] -"jupyter-config" = "etc/jupyter" \ No newline at end of file +"jupyter-config" = "etc/jupyter" + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +extend-select = [ + "B", # flake8-bugbear + "I", # isort + "ARG", # flake8-unused-arguments + "C4", # flake8-comprehensions + "EM", # flake8-errmsg + "ICN", # flake8-import-conventions + "ISC", # flake8-implicit-str-concat + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "RET", # flake8-return + "RUF", # Ruff-specific + "SIM", # flake8-simplify + "T20", # flake8-print + "UP", # pyupgrade + "YTT", # flake8-2020 + "EXE", # flake8-executable +] +ignore = [ + "PLR", # Design related pylint codes + "ISC001", # Conflicts with formatter +] + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["T20"] \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index cc27bf1..982789b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for jupyter-server-docs-mcp package.""" \ No newline at end of file +"""Tests for jupyter-server-docs-mcp package.""" diff --git a/tests/conftest.py b/tests/conftest.py index 274a778..decc866 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,14 @@ """Pytest configuration and fixtures for jupyter-server-docs-mcp tests.""" import asyncio -import os import tempfile from pathlib import Path -from typing import Dict, Any import pytest -from jupyter_server.extension.application import ExtensionApp -from jupyter_server.serverapp import ServerApp -from tornado.testing import AsyncHTTPTestCase +from tornado.web import Application -from jupyter_server_docs_mcp.extension import MCPExtensionApp -from jupyter_server_docs_mcp.mcp_server import MCPServer +from jupyter_server_mcp.extension import MCPExtensionApp +from jupyter_server_mcp.mcp_server import MCPServer @pytest.fixture(scope="session") @@ -30,11 +26,6 @@ def temp_dir(): yield Path(tmpdir) - - - - - @pytest.fixture def mcp_extension(): """Create an MCP extension instance for testing.""" @@ -43,32 +34,25 @@ def mcp_extension(): return extension - - - - - - @pytest.fixture def mock_serverapp(): """Create a mock Jupyter Server app for testing.""" - from tornado.web import Application - + class MockServerApp: def __init__(self): self.log = MockLogger() self.web_app = Application() # Add required web_app attribute - + class MockLogger: def info(self, msg): print(f"INFO: {msg}") - + def error(self, msg): print(f"ERROR: {msg}") - + def warning(self, msg): print(f"WARNING: {msg}") - + return MockServerApp() @@ -81,16 +65,10 @@ def mcp_server_simple(): # Pytest configuration def pytest_configure(config): """Configure pytest with custom markers.""" - config.addinivalue_line( - "markers", "asyncio: mark test as async" - ) - config.addinivalue_line( - "markers", "slow: mark test as slow running" - ) - config.addinivalue_line( - "markers", "integration: mark test as integration test" - ) + config.addinivalue_line("markers", "asyncio: mark test as async") + config.addinivalue_line("markers", "slow: mark test as slow running") + config.addinivalue_line("markers", "integration: mark test as integration test") # Auto-use asyncio for async tests -pytest_plugins = ['pytest_asyncio'] \ No newline at end of file +pytest_plugins = ["pytest_asyncio"] diff --git a/tests/test_extension.py b/tests/test_extension.py index 9c8a479..ba48676 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,44 +1,46 @@ """Test Jupyter Server extension functionality.""" -import pytest import asyncio -from unittest.mock import Mock, patch, AsyncMock -from jupyter_server_docs_mcp.extension import MCPExtensionApp +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from jupyter_server_mcp.extension import MCPExtensionApp class TestMCPExtensionApp: """Test MCPExtensionApp functionality.""" - + def test_extension_creation(self): """Test creating extension with defaults.""" extension = MCPExtensionApp() - - assert extension.name == "jupyter_server_docs_mcp" + + assert extension.name == "jupyter_server_mcp" assert extension.mcp_port == 3001 assert extension.mcp_name == "Jupyter MCP Server" - + def test_extension_trait_configuration(self): """Test configuring extension via traits.""" extension = MCPExtensionApp() - + # Test port configuration extension.mcp_port = 3010 assert extension.mcp_port == 3010 - + # Test name configuration extension.mcp_name = "My Custom Server" assert extension.mcp_name == "My Custom Server" - + # Test tools configuration extension.mcp_tools = ["os:getcwd", "math:sqrt"] assert extension.mcp_tools == ["os:getcwd", "math:sqrt"] - + def test_initialize_handlers(self): """Test handler initialization (should be no-op).""" extension = MCPExtensionApp() # Should not raise any errors extension.initialize_handlers() - + def test_initialize_settings(self): """Test settings initialization (should be no-op).""" extension = MCPExtensionApp() @@ -48,146 +50,147 @@ def test_initialize_settings(self): class TestMCPExtensionLifecycle: """Test extension lifecycle methods.""" - + @pytest.mark.asyncio async def test_start_extension_success(self): """Test successful extension startup.""" extension = MCPExtensionApp() extension.mcp_port = 3098 extension.mcp_name = "Test Server" - + # Mock the MCP server creation to avoid actual server startup - with patch('jupyter_server_docs_mcp.extension.MCPServer') as mock_mcp_class: + with patch("jupyter_server_mcp.extension.MCPServer") as mock_mcp_class: mock_server = Mock() mock_server.start_server = AsyncMock() mock_mcp_class.return_value = mock_server - + await extension.start_extension() - + # Verify server was created and started mock_mcp_class.assert_called_once_with( parent=extension, name=extension.mcp_name, - port=extension.mcp_port + port=extension.mcp_port, ) mock_server.start_server.assert_called_once() - + # Verify extension state assert extension.mcp_server_instance == mock_server assert extension.mcp_server_task is not None - + @pytest.mark.asyncio async def test_start_extension_failure(self): """Test extension startup failure handling.""" extension = MCPExtensionApp() - + # Mock server creation to raise an exception - with patch('jupyter_server_docs_mcp.extension.MCPServer') as mock_mcp_class: + with patch("jupyter_server_mcp.extension.MCPServer") as mock_mcp_class: mock_mcp_class.side_effect = Exception("Server creation failed") - + with pytest.raises(Exception, match="Server creation failed"): await extension.start_extension() - + @pytest.mark.asyncio async def test_stop_extension_with_running_server(self): """Test stopping extension with running server.""" extension = MCPExtensionApp() - + # Create a real asyncio task that can be cancelled async def dummy_task(): await asyncio.sleep(10) # Long running task - + task = asyncio.create_task(dummy_task()) - + # Set up extension state extension.mcp_server_task = task extension.mcp_server_instance = Mock() - + await extension.stop_extension() - + # Verify cleanup assert task.cancelled() assert extension.mcp_server_task is None assert extension.mcp_server_instance is None - + @pytest.mark.asyncio async def test_stop_extension_no_server(self): """Test stopping extension when no server is running.""" extension = MCPExtensionApp() - + # No server running extension.mcp_server_task = None extension.mcp_server_instance = None - + # Should not raise any errors await extension.stop_extension() - + @pytest.mark.asyncio async def test_stop_extension_completed_task(self): """Test stopping extension with completed task.""" extension = MCPExtensionApp() - + # Create a mock completed task mock_task = Mock() mock_task.done.return_value = True - + extension.mcp_server_task = mock_task extension.mcp_server_instance = Mock() - + await extension.stop_extension() - + # Should not try to cancel completed task mock_task.cancel.assert_not_called() - + @pytest.mark.asyncio async def test_full_lifecycle(self): """Test complete start -> stop lifecycle.""" extension = MCPExtensionApp() extension.mcp_port = 3099 extension.mcp_name = "Lifecycle Test Server" - + # Mock the MCP server - with patch('jupyter_server_docs_mcp.extension.MCPServer') as mock_mcp_class: + with patch("jupyter_server_mcp.extension.MCPServer") as mock_mcp_class: mock_server = Mock() mock_server.start_server = AsyncMock() mock_mcp_class.return_value = mock_server - + # Start extension await extension.start_extension() - + # Verify started assert extension.mcp_server_instance is not None assert extension.mcp_server_task is not None - + # The task should be created by start_extension original_task = extension.mcp_server_task - - # Stop extension + + # Stop extension await extension.stop_extension() - + # Verify stopped assert extension.mcp_server_instance is None assert extension.mcp_server_task is None - # Task should be either cancelled or done (in mock scenarios, it might finish before cancellation) + # Task should be either cancelled or done (in mock scenarios, + # it might finish before cancellation) assert original_task.cancelled() or original_task.done() class TestExtensionIntegration: """Integration tests for extension.""" - + @pytest.mark.integration def test_extension_with_real_configuration(self): """Test extension with realistic configuration.""" extension = MCPExtensionApp() - + # Configure like a real deployment extension.mcp_port = 3020 extension.mcp_name = "Production MCP Server" - + # Should initialize without errors extension.initialize_handlers() extension.initialize_settings() - + # Configuration should be preserved assert extension.mcp_port == 3020 assert extension.mcp_name == "Production MCP Server" @@ -195,102 +198,104 @@ def test_extension_with_real_configuration(self): class TestToolLoading: """Test tool loading functionality.""" - + def test_load_function_from_string_valid(self): """Test loading valid functions from string specs.""" extension = MCPExtensionApp() - + # Test loading os.getcwd func = extension._load_function_from_string("os:getcwd") assert callable(func) assert func.__name__ == "getcwd" - + # Test loading math.sqrt func = extension._load_function_from_string("math:sqrt") assert callable(func) assert func.__name__ == "sqrt" - + def test_load_function_from_string_invalid_format(self): """Test loading functions with invalid format.""" extension = MCPExtensionApp() - + with pytest.raises(ValueError, match="Invalid tool specification"): extension._load_function_from_string("invalid_format") - + with pytest.raises(ValueError, match="Invalid tool specification"): extension._load_function_from_string("no_colon_here") - + def test_load_function_from_string_invalid_module(self): """Test loading functions from non-existent modules.""" extension = MCPExtensionApp() - + with pytest.raises(ImportError, match="Could not import module"): extension._load_function_from_string("nonexistent_module:some_func") - + def test_load_function_from_string_invalid_function(self): """Test loading non-existent functions from valid modules.""" extension = MCPExtensionApp() - - with pytest.raises(AttributeError, match="Function.*not found"): + + with pytest.raises(AttributeError, match=r"Function.*not found"): extension._load_function_from_string("os:nonexistent_function") - + def test_load_function_with_nested_module(self): """Test loading functions from nested modules.""" extension = MCPExtensionApp() - - # Test loading from json.dumps + + # Test loading from json.dumps func = extension._load_function_from_string("json:dumps") assert callable(func) assert func.__name__ == "dumps" - + def test_register_configured_tools_empty(self): """Test registering tools when mcp_tools is empty.""" extension = MCPExtensionApp() extension.mcp_server_instance = Mock() extension.mcp_tools = [] - + # Should not call register_tool extension._register_configured_tools() extension.mcp_server_instance.register_tool.assert_not_called() - + def test_register_configured_tools_valid(self): """Test registering valid configured tools.""" extension = MCPExtensionApp() extension.mcp_server_instance = Mock() extension.mcp_tools = ["os:getcwd", "math:sqrt"] - + # Capture log output - import logging - with patch('jupyter_server_docs_mcp.extension.logger') as mock_logger: + with patch("jupyter_server_mcp.extension.logger") as mock_logger: extension._register_configured_tools() - + # Should register both tools assert extension.mcp_server_instance.register_tool.call_count == 2 - + # Check log messages mock_logger.info.assert_any_call("Registering 2 configured tools") mock_logger.info.assert_any_call("✅ Registered tool: os:getcwd") mock_logger.info.assert_any_call("✅ Registered tool: math:sqrt") - + def test_register_configured_tools_with_errors(self): """Test registering tools when some fail to load.""" extension = MCPExtensionApp() extension.mcp_server_instance = Mock() extension.mcp_tools = ["os:getcwd", "invalid:function", "math:sqrt"] - - with patch('jupyter_server_docs_mcp.extension.logger') as mock_logger: + + with patch("jupyter_server_mcp.extension.logger") as mock_logger: extension._register_configured_tools() - + # Should register 2 valid tools (os:getcwd and math:sqrt) assert extension.mcp_server_instance.register_tool.call_count == 2 - + # Check error logging - mock_logger.error.assert_any_call("❌ Failed to register tool 'invalid:function': Could not import module 'invalid': No module named 'invalid'") + mock_logger.error.assert_any_call( + "❌ Failed to register tool 'invalid:function': " + "Could not import module 'invalid': No module named 'invalid'" + ) class TestExtensionWithTools: """Test extension lifecycle with configured tools.""" - + @pytest.mark.asyncio async def test_start_extension_with_tools(self): """Test extension startup with configured tools.""" @@ -298,39 +303,40 @@ async def test_start_extension_with_tools(self): extension.mcp_port = 3089 extension.mcp_name = "Test Server With Tools" extension.mcp_tools = ["os:getcwd", "math:sqrt"] - - with patch('jupyter_server_docs_mcp.extension.MCPServer') as mock_mcp_class: + + with patch("jupyter_server_mcp.extension.MCPServer") as mock_mcp_class: mock_server = Mock() mock_server.start_server = AsyncMock() - mock_server._registered_tools = {"getcwd": {}, "sqrt": {}} # Mock registered tools + mock_server._registered_tools = { + "getcwd": {}, + "sqrt": {}, + } # Mock registered tools mock_mcp_class.return_value = mock_server - + await extension.start_extension() - + # Verify server creation mock_mcp_class.assert_called_once_with( - parent=extension, - name="Test Server With Tools", - port=3089 + parent=extension, name="Test Server With Tools", port=3089 ) - + # Verify tools were registered assert mock_server.register_tool.call_count == 2 - - @pytest.mark.asyncio + + @pytest.mark.asyncio async def test_start_extension_no_tools(self): """Test extension startup with no configured tools.""" extension = MCPExtensionApp() extension.mcp_port = 3088 extension.mcp_tools = [] - - with patch('jupyter_server_docs_mcp.extension.MCPServer') as mock_mcp_class: + + with patch("jupyter_server_mcp.extension.MCPServer") as mock_mcp_class: mock_server = Mock() mock_server.start_server = AsyncMock() mock_server._registered_tools = {} mock_mcp_class.return_value = mock_server - + await extension.start_extension() - + # Should not register any tools - mock_server.register_tool.assert_not_called() \ No newline at end of file + mock_server.register_tool.assert_not_called() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index a849098..204d67e 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,9 +1,10 @@ """Test the simplified MCP server functionality.""" -import pytest import asyncio -from unittest.mock import Mock, patch, AsyncMock -from jupyter_server_docs_mcp.mcp_server import MCPServer + +import pytest + +from jupyter_server_mcp.mcp_server import MCPServer def simple_function(x: int, y: int) -> int: @@ -19,7 +20,7 @@ async def async_function(name: str) -> str: def function_with_docstring(message: str) -> str: """Print a message to stdout. - + This is a more detailed description of what this function does. """ print(message) @@ -28,141 +29,140 @@ def function_with_docstring(message: str) -> str: class TestMCPServer: """Test MCPServer functionality.""" - + def test_server_creation(self): """Test basic server creation.""" server = MCPServer() - + assert server.name == "Jupyter MCP Server" assert server.port == 3001 assert server.host == "localhost" - assert server.enable_debug_logging == False + assert server.enable_debug_logging is False assert server.mcp is not None assert len(server._registered_tools) == 0 - + def test_server_creation_with_params(self): """Test server creation with custom parameters.""" server = MCPServer(name="Test Server", port=3050, host="0.0.0.0") - - assert server.name == "Test Server" + + assert server.name == "Test Server" assert server.port == 3050 assert server.host == "0.0.0.0" assert server.mcp is not None - + def test_register_single_tool(self): """Test registering a single tool.""" server = MCPServer() - + server.register_tool(simple_function) - + # Check tool was registered assert len(server._registered_tools) == 1 assert "simple_function" in server._registered_tools - + tool_info = server._registered_tools["simple_function"] assert tool_info["name"] == "simple_function" assert tool_info["description"] == "Add two numbers." assert tool_info["function"] == simple_function - assert tool_info["is_async"] == False - + assert tool_info["is_async"] is False + def test_register_tool_with_custom_name(self): """Test registering a tool with custom name.""" server = MCPServer() - + server.register_tool(simple_function, name="add_numbers") - + assert "add_numbers" in server._registered_tools assert "simple_function" not in server._registered_tools - + tool_info = server._registered_tools["add_numbers"] assert tool_info["name"] == "add_numbers" - + def test_register_tool_with_custom_description(self): - """Test registering a tool with custom description.""" + """Test registering a tool with custom description.""" server = MCPServer() - + server.register_tool(simple_function, description="Custom description") - + tool_info = server._registered_tools["simple_function"] assert tool_info["description"] == "Custom description" - + def test_register_async_tool(self): """Test registering an async tool.""" server = MCPServer() - + server.register_tool(async_function) - + tool_info = server._registered_tools["async_function"] - assert tool_info["is_async"] == True - + assert tool_info["is_async"] is True + def test_register_tools_as_list(self): """Test registering multiple tools as a list.""" server = MCPServer() - + server.register_tools([simple_function, async_function]) - + assert len(server._registered_tools) == 2 assert "simple_function" in server._registered_tools assert "async_function" in server._registered_tools - + def test_register_tools_as_dict(self): """Test registering multiple tools as a dict.""" server = MCPServer() - - tools = { - "add": simple_function, - "greet": async_function - } + + tools = {"add": simple_function, "greet": async_function} server.register_tools(tools) - + assert len(server._registered_tools) == 2 assert "add" in server._registered_tools assert "greet" in server._registered_tools - + assert server._registered_tools["add"]["function"] == simple_function assert server._registered_tools["greet"]["function"] == async_function - + def test_register_tools_invalid_type(self): """Test registering tools with invalid type.""" server = MCPServer() - - with pytest.raises(ValueError, match="tools must be a list of functions or dict"): + + with pytest.raises( + ValueError, match="tools must be a list of functions or dict" + ): server.register_tools("invalid") - + def test_list_tools(self): """Test listing registered tools.""" server = MCPServer() - + # Initially empty assert server.list_tools() == [] - + # After registering tools server.register_tool(simple_function) server.register_tool(function_with_docstring) - + tools = server.list_tools() assert len(tools) == 2 - + tool_names = [t["name"] for t in tools] assert "simple_function" in tool_names assert "function_with_docstring" in tool_names - + # Check structure for tool in tools: assert "name" in tool assert "description" in tool - + def test_get_tool_info(self): """Test getting tool information.""" server = MCPServer() - + # Non-existent tool assert server.get_tool_info("nonexistent") is None - + # Existing tool server.register_tool(simple_function) info = server.get_tool_info("simple_function") - + assert info is not None assert info["name"] == "simple_function" assert info["function"] == simple_function @@ -170,46 +170,46 @@ def test_get_tool_info(self): class TestMCPServerDirect: """Test direct MCPServer instantiation.""" - + def test_create_server_defaults(self): """Test creating server with defaults.""" server = MCPServer() - + assert isinstance(server, MCPServer) assert server.name == "Jupyter MCP Server" assert server.port == 3001 - + def test_create_server_with_params(self): """Test creating server with custom parameters.""" server = MCPServer(name="Custom Server", port=3055) - + assert server.name == "Custom Server" assert server.port == 3055 class TestMCPServerIntegration: """Integration tests for MCP server.""" - + @pytest.mark.integration def test_server_with_multiple_tools(self): """Test server with multiple different types of tools.""" server = MCPServer(name="Integration Test Server") - + # Register various types of tools server.register_tool(simple_function) server.register_tool(async_function) server.register_tool(function_with_docstring, name="printer") - + # Verify all registered assert len(server._registered_tools) == 3 tools = server.list_tools() tool_names = [t["name"] for t in tools] - + assert "simple_function" in tool_names assert "async_function" in tool_names assert "printer" in tool_names - + # Verify async detection - assert server._registered_tools["simple_function"]["is_async"] == False - assert server._registered_tools["async_function"]["is_async"] == True - assert server._registered_tools["printer"]["is_async"] == False \ No newline at end of file + assert server._registered_tools["simple_function"]["is_async"] is False + assert server._registered_tools["async_function"]["is_async"] is True + assert server._registered_tools["printer"]["is_async"] is False