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
48 changes: 7 additions & 41 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
pytest tests/ -v -m "integration"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Byte-compiled / optimized / DLL files
.claude
.vscode
demo/
__pycache__/
*.py[codz]
*$py.class
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"ServerApp": {
"jpserver_extensions": {
"jupyter_server_docs_mcp": true
"jupyter_server_mcp": true
}
}
}
17 changes: 0 additions & 17 deletions jupyter_server_docs_mcp/__init__.py

This file was deleted.

20 changes: 20 additions & 0 deletions jupyter_server_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -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")
self.log.info("MCP server stopped")
Loading
Loading