Skip to main content
Glama

Jupyter MCP Server

by datalayer
CLI.py14.5 kB
# Copyright (c) 2023-2024 Datalayer, Inc. # # BSD 3-Clause License """ Jupyter MCP Server CLI Layer """ import click import httpx import uvicorn from jupyter_mcp_server.log import logger from jupyter_mcp_server.models import DocumentRuntime from jupyter_mcp_server.config import get_config, set_config from jupyter_mcp_server.server_context import ServerContext # Import the server instance and helper functions from server layer from jupyter_mcp_server.server import ( mcp, __start_kernel, __auto_enroll_document, ) # Shared options decorator to reduce code duplication def _common_options(f): """Decorator that adds common start options to a command.""" options = [ click.option( "--provider", envvar="PROVIDER", type=click.Choice(["jupyter", "datalayer"]), default="jupyter", help="The provider to use for the document and runtime. Defaults to 'jupyter'.", ), click.option( "--jupyterlab", envvar="JUPYTERLAB", type=click.BOOL, default=True, help="Enable JupyterLab mode. Defaults to True.", ), click.option( "--runtime-url", envvar="RUNTIME_URL", type=click.STRING, default=None, help="The runtime URL to use. For the jupyter provider, this is the Jupyter server URL. For the datalayer provider, this is the Datalayer runtime URL.", ), click.option( "--runtime-id", envvar="RUNTIME_ID", type=click.STRING, default=None, help="The kernel ID to use. If not provided, a new kernel should be started.", ), click.option( "--runtime-token", envvar="RUNTIME_TOKEN", type=click.STRING, default=None, help="The runtime token to use for authentication with the provider. If not provided, the provider should accept anonymous requests.", ), click.option( "--document-url", envvar="DOCUMENT_URL", type=click.STRING, default=None, help="The document URL to use. For the jupyter provider, this is the Jupyter server URL. For the datalayer provider, this is the Datalayer document URL.", ), click.option( "--document-id", envvar="DOCUMENT_ID", type=click.STRING, default=None, help="The document id to use. For the jupyter provider, this is the notebook path. For the datalayer provider, this is the notebook path. Optional - if omitted, you can list and select notebooks interactively.", ), click.option( "--document-token", envvar="DOCUMENT_TOKEN", type=click.STRING, default=None, help="The document token to use for authentication with the provider. If not provided, the provider should accept anonymous requests.", ), click.option( "--jupyter-url", envvar="JUPYTER_URL", type=click.STRING, default=None, help="The Jupyter URL to use as default for both document and runtime URLs. If not provided, individual URL settings take precedence.", ), click.option( "--jupyter-token", envvar="JUPYTER_TOKEN", type=click.STRING, default=None, help="The Jupyter token to use as default for both document and runtime tokens. If not provided, individual token settings take precedence.", ) ] # Apply decorators in reverse order for option in reversed(options): f = option(f) return f def _resolve_url_and_token_variables( jupyter_url, jupyter_token, document_url, document_token, runtime_url, runtime_token, ) -> tuple[str, str | None, str, str | None]: """Resolve URL and token variables based on priority logic. Priority order: 1. Individual URL/token variables take precedence if set 2. JUPYTER_URL/JUPYTER_TOKEN used as fallback if individual variables are None 3. Keep original default values if neither individual nor merged variables are set Args: jupyter_url: The merged Jupyter URL variable jupyter_token: The merged Jupyter token variable document_url: The individual document URL (takes precedence if set) document_token: The individual document token (takes precedence if set) runtime_url: The individual runtime URL (takes precedence if set) runtime_token: The individual runtime token (takes precedence if set) Returns: Tuple of (resolved_document_url, resolved_document_token, resolved_runtime_url, resolved_runtime_token) """ # Resolve document_url if document_url is not None: resolved_document_url = document_url elif jupyter_url is not None: resolved_document_url = jupyter_url else: resolved_document_url = "http://localhost:8888" # Resolve runtime_url if runtime_url is not None: resolved_runtime_url = runtime_url elif jupyter_url is not None: resolved_runtime_url = jupyter_url else: resolved_runtime_url = "http://localhost:8888" # Resolve document_token resolved_document_token = document_token or jupyter_token # Resolve runtime_token resolved_runtime_token = runtime_token or jupyter_token return resolved_document_url, resolved_document_token, resolved_runtime_url, resolved_runtime_token def _do_start( transport: str, start_new_runtime: bool, runtime_url: str, runtime_id: str, runtime_token: str, document_url: str, document_id: str, document_token: str, port: int, provider: str, jupyterlab: bool, ): """Internal function to execute the start logic.""" # Log the received configuration for diagnostics # Note: set_config() will automatically normalize string "None" values logger.info( f"Start command received - runtime_url: {repr(runtime_url)}, " f"document_url: {repr(document_url)}, provider: {provider}, " f"transport: {transport}" ) # Set configuration using the singleton # String "None" values will be automatically normalized by set_config() config = set_config( transport=transport, provider=provider, runtime_url=runtime_url, start_new_runtime=start_new_runtime, runtime_id=runtime_id, runtime_token=runtime_token, document_url=document_url, document_id=document_id, document_token=document_token, port=port, jupyterlab=jupyterlab ) # Reset ServerContext to pick up new configuration ServerContext.reset() # Determine startup behavior based on configuration if config.document_id: # If document_id is provided, auto-enroll the notebook # Kernel creation depends on start_new_runtime and runtime_id flags try: import asyncio # Run the async enrollment in the event loop asyncio.run(__auto_enroll_document()) except Exception as e: logger.error(f"Failed to auto-enroll document '{config.document_id}': {e}") # Fallback to legacy kernel-only mode if enrollment fails if config.start_new_runtime or config.runtime_id: try: __start_kernel() except Exception as e2: logger.error(f"Failed to start kernel on startup: {e2}") elif config.start_new_runtime or config.runtime_id: # If no document_id but start_new_runtime/runtime_id is set, just create kernel # This is for backward compatibility - kernel without managed notebook try: __start_kernel() except Exception as e: logger.error(f"Failed to start kernel on startup: {e}") # else: No startup action - user must manually enroll notebooks or create kernels logger.info(f"Starting Jupyter MCP Server with transport: {transport}") if transport == "stdio": mcp.run(transport="stdio") elif transport == "streamable-http": uvicorn.run(mcp.streamable_http_app, host="0.0.0.0", port=port) # noqa: S104 else: raise Exception("Transport should be `stdio` or `streamable-http`.") @click.group(invoke_without_command=True) @_common_options @click.option( "--transport", envvar="TRANSPORT", type=click.Choice(["stdio", "streamable-http"]), default="stdio", help="The transport to use for the MCP server. Defaults to 'stdio'.", ) @click.option( "--start-new-runtime", envvar="START_NEW_RUNTIME", type=click.BOOL, default=True, help="Start a new runtime or use an existing one.", ) @click.option( "--port", envvar="PORT", type=click.INT, default=4040, help="The port to use for the Streamable HTTP transport. Ignored for stdio transport.", ) @click.pass_context def server( ctx, transport: str, start_new_runtime: bool, runtime_url: str, runtime_id: str, runtime_token: str, document_url: str, document_id: str, document_token: str, jupyter_url: str, jupyter_token: str, port: int, provider: str, jupyterlab: bool, ): """Manages Jupyter MCP Server. When invoked without subcommands, starts the MCP server directly. This allows for quick startup with: uvx jupyter-mcp-server Subcommands (start, connect, stop) are still available for advanced use cases. """ # If a subcommand is invoked, let it handle the execution if ctx.invoked_subcommand is not None: return # No subcommand provided - execute the default start behavior # Resolve URL and token variables based on priority logic resolved_document_url, resolved_document_token, resolved_runtime_url, resolved_runtime_token = _resolve_url_and_token_variables( jupyter_url=jupyter_url, jupyter_token=jupyter_token, document_url=document_url, document_token=document_token, runtime_url=runtime_url, runtime_token=runtime_token, ) _do_start( transport=transport, start_new_runtime=start_new_runtime, runtime_url=resolved_runtime_url, runtime_id=runtime_id, runtime_token=resolved_runtime_token, document_url=resolved_document_url, document_id=document_id, document_token=resolved_document_token, port=port, provider=provider, jupyterlab=jupyterlab, ) @server.command("connect") @_common_options @click.option( "--jupyter-mcp-server-url", envvar="JUPYTER_MCP_SERVER_URL", type=click.STRING, default="http://localhost:4040", help="The URL of the Jupyter MCP Server to connect to. Defaults to 'http://localhost:4040'.", ) def connect_command( jupyter_mcp_server_url: str, runtime_url: str, runtime_id: str, runtime_token: str, document_url: str, document_id: str, document_token: str, provider: str, jupyterlab: bool, ): """Command to connect a Jupyter MCP Server to a document and a runtime.""" # Set configuration using the singleton set_config( provider=provider, runtime_url=runtime_url, runtime_id=runtime_id, runtime_token=runtime_token, document_url=document_url, document_id=document_id, document_token=document_token, jupyterlab=jupyterlab ) config = get_config() document_runtime = DocumentRuntime( provider=config.provider, runtime_url=config.runtime_url, runtime_id=config.runtime_id, runtime_token=config.runtime_token, document_url=config.document_url, document_id=config.document_id, document_token=config.document_token, ) r = httpx.put( f"{jupyter_mcp_server_url}/api/connect", headers={ "Content-Type": "application/json", "Accept": "application/json", }, content=document_runtime.model_dump_json(), ) r.raise_for_status() @server.command("stop") @click.option( "--jupyter-mcp-server-url", envvar="JUPYTER_MCP_SERVER_URL", type=click.STRING, default="http://localhost:4040", help="The URL of the Jupyter MCP Server to stop. Defaults to 'http://localhost:4040'.", ) def stop_command(jupyter_mcp_server_url: str): r = httpx.delete( f"{jupyter_mcp_server_url}/api/stop", ) r.raise_for_status() @server.command("start") @_common_options @click.option( "--transport", envvar="TRANSPORT", type=click.Choice(["stdio", "streamable-http"]), default="stdio", help="The transport to use for the MCP server. Defaults to 'stdio'.", ) @click.option( "--start-new-runtime", envvar="START_NEW_RUNTIME", type=click.BOOL, default=True, help="Start a new runtime or use an existing one.", ) @click.option( "--port", envvar="PORT", type=click.INT, default=4040, help="The port to use for the Streamable HTTP transport. Ignored for stdio transport.", ) def start_command( transport: str, start_new_runtime: bool, runtime_url: str, runtime_id: str, runtime_token: str, document_url: str, document_id: str, document_token: str, jupyter_url: str, jupyter_token: str, port: int, provider: str, jupyterlab: bool, ): """Start the Jupyter MCP server with a transport.""" # Resolve URL and token variables based on priority logic resolved_document_url, resolved_document_token, resolved_runtime_url, resolved_runtime_token = _resolve_url_and_token_variables( jupyter_url=jupyter_url, jupyter_token=jupyter_token, document_url=document_url, document_token=document_token, runtime_url=runtime_url, runtime_token=runtime_token, ) _do_start( transport=transport, start_new_runtime=start_new_runtime, runtime_url=resolved_runtime_url, runtime_id=runtime_id, runtime_token=resolved_runtime_token, document_url=resolved_document_url, document_id=document_id, document_token=resolved_document_token, port=port, provider=provider, jupyterlab=jupyterlab, ) if __name__ == "__main__": """Start the Jupyter MCP Server.""" server()

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/datalayer/jupyter-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server