Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ jobs:
# Check import sorting
isort --check-only --diff .
# Run flake8
flake8 jupyter_server_docs_mcp tests
flake8 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" --cov=jupyter_server_mcp --cov-report=xml

- name: Run integration tests
run: |
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
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
"""Jupyter Server MCP Extension with configurable tools."""

__version__ = "0.1.0"
from typing import Any, Dict, List

from .extension import MCPExtensionApp
from typing import Any, Dict, List

__version__ = "0.1.0"


def _jupyter_server_extension_points() -> List[Dict[str, Any]]: # pragma: no cover
def _jupyter_server_extension_points() -> List[Dict[str, Any]]:
# pragma: no cover
return [
{
"module": "jupyter_server_docs_mcp.extension",
"module": "jupyter_server_mcp.extension",
"app": MCPExtensionApp,
},
]

__all__ = ["MCPExtensionApp"]

__all__ = ["MCPExtensionApp"]
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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,126 +15,144 @@

class MCPExtensionApp(ExtensionApp):
"""The Jupyter Server MCP extension app."""

name = "jupyter_server_docs_mcp"
description = "Jupyter Server extension providing MCP server for tool registration"


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"
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

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:
raise ValueError(
f"Invalid tool specification '{tool_spec}'. "
f"Expected format: 'module_path:function_name'"
)

module_path, function_name = tool_spec.rsplit(":", 1)

try:
module = importlib.import_module(module_path)
function = getattr(module, function_name)
return function
except ImportError as e:
raise ImportError(f"Could not import module '{module_path}': {e}")
raise ImportError(
f"Could not import module '{module_path}': {e}"
)
except AttributeError as e:
raise AttributeError(f"Function '{function_name}' not found in module '{module_path}': {e}")

raise AttributeError(
f"Function '{function_name}' not found in module '{module_path}': {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)
self.mcp_server_instance.register_tool(function)
logger.info(f"✅ Registered tool: {tool_spec}")
except Exception as e:
logger.error(f"❌ Failed to register tool '{tool_spec}': {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")

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):
"""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():
Expand All @@ -144,8 +162,8 @@ async def stop_extension(self):
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