From 7092922ce928d5d03bf39a40aa9649ec6145f62d Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sun, 12 Oct 2025 11:17:58 +0200 Subject: [PATCH 1/6] deps: jupyter-mcp-tools 0.1.1 --- .../jupyter_extension/handlers.py | 187 ++++++++++++++---- jupyter_mcp_server/server.py | 66 +++++++ pyproject.toml | 3 +- 3 files changed, 214 insertions(+), 42 deletions(-) diff --git a/jupyter_mcp_server/jupyter_extension/handlers.py b/jupyter_mcp_server/jupyter_extension/handlers.py index b9f01453..e5f41b98 100644 --- a/jupyter_mcp_server/jupyter_extension/handlers.py +++ b/jupyter_mcp_server/jupyter_extension/handlers.py @@ -107,17 +107,17 @@ async def post(self): } logger.info(f"Sending initialize response: {response}") elif method == "tools/list": - # List available tools from FastMCP + # List available tools from FastMCP and jupyter_mcp_tools from jupyter_mcp_server.server import mcp - logger.info("Calling mcp.list_tools()...") + logger.info("Listing tools from FastMCP and jupyter_mcp_tools...") try: - # Use FastMCP's list_tools method - returns list of Tool objects + # Get FastMCP tools tools_list = await mcp.list_tools() logger.info(f"Got {len(tools_list)} tools from FastMCP") - # Convert to MCP protocol format + # Convert FastMCP tools to MCP protocol format tools = [] for tool in tools_list: tools.append({ @@ -126,7 +126,64 @@ async def post(self): "inputSchema": tool.inputSchema }) - logger.info(f"Converted {len(tools)} tools to MCP format") + # Get tools from jupyter_mcp_tools extension + try: + from jupyter_mcp_tools import get_tools + + # Get the server's base URL dynamically + # The server is running on the port configured in settings + port = self.settings.get('port', 4040) # Default to 4040 if not set + base_url = f"http://localhost:{port}" + + # Get token from settings if available + token = self.settings.get('token', None) + + logger.info(f"Querying jupyter_mcp_tools at {base_url}") + + jupyter_tools_data = await get_tools( + base_url=base_url, + token=token, + query="console_create", + enabled_only=True, + wait_timeout=5 # Short timeout + ) + + logger.info(f"Got {len(jupyter_tools_data)} tools from jupyter_mcp_tools extension") + + # Validate that exactly one tool was returned + if len(jupyter_tools_data) != 1: + logger.warning( + f"Expected exactly 1 tool matching 'console_create', " + f"but got {len(jupyter_tools_data)} tools" + ) + else: + # Convert jupyter_mcp_tools format to MCP format and add to tools list + for tool_data in jupyter_tools_data: + tool_dict = { + "name": tool_data.get('id', ''), + "description": tool_data.get('caption', tool_data.get('label', '')), + } + + # Convert parameters to inputSchema + params = tool_data.get('parameters', {}) + if params and isinstance(params, dict): + tool_dict["inputSchema"] = params + else: + tool_dict["inputSchema"] = { + "type": "object", + "properties": {}, + "description": tool_data.get('usage', '') + } + + tools.append(tool_dict) + + logger.info(f"Added {len(jupyter_tools_data)} tool(s) from jupyter_mcp_tools") + + except Exception as jupyter_error: + # Log but don't fail - just return FastMCP tools + logger.warning(f"Could not fetch tools from jupyter_mcp_tools: {jupyter_error}") + + logger.info(f"Returning total of {len(tools)} tools") response = { "jsonrpc": "2.0", @@ -152,47 +209,95 @@ async def post(self): tool_name = params.get("name") tool_arguments = params.get("arguments", {}) - logger.info(f"Calling tool: {tool_name} with args: {tool_arguments}") + logger.info(f"Calling tool: {tool_name}") try: - # Use FastMCP's call_tool method - result = await mcp.call_tool(tool_name, tool_arguments) - - # Handle tuple results from FastMCP - if isinstance(result, tuple) and len(result) >= 1: - # FastMCP returns (content_list, metadata_dict) - content_list = result[0] - if isinstance(content_list, list): - # Serialize TextContent objects to dicts - serialized_content = [] - for item in content_list: - if hasattr(item, 'model_dump'): - serialized_content.append(item.model_dump()) - elif hasattr(item, 'dict'): - serialized_content.append(item.dict()) - elif isinstance(item, dict): - serialized_content.append(item) + # Check if this is a jupyter_mcp_tools tool (e.g., console_create) + if tool_name == "console_create": + # Route to jupyter_mcp_tools extension via HTTP execute endpoint + logger.info(f"Routing {tool_name} to jupyter_mcp_tools extension") + + # Get server configuration + port = self.settings.get('port', 4040) + base_url = f"http://localhost:{port}" + token = self.settings.get('token', None) + + # Use the MCPToolsClient to execute the tool + from jupyter_mcp_tools.client import MCPToolsClient + + try: + async with MCPToolsClient(base_url=base_url, token=token) as client: + execution_result = await client.execute_tool( + tool_id=tool_name, + parameters=tool_arguments + ) + + if execution_result.get('success'): + result_data = execution_result.get('result', {}) + result_text = str(result_data) if result_data else "Tool executed successfully" + result_dict = { + "content": [{ + "type": "text", + "text": result_text + }] + } else: - serialized_content.append({"type": "text", "text": str(item)}) - result_dict = {"content": serialized_content} - else: - result_dict = {"content": [{"type": "text", "text": str(result)}]} - # Convert result to dict - it's a CallToolResult with content list - elif hasattr(result, 'model_dump'): - result_dict = result.model_dump() - elif hasattr(result, 'dict'): - result_dict = result.dict() - elif hasattr(result, 'content'): - # Extract content directly if it has a content attribute - result_dict = {"content": result.content} + error_msg = execution_result.get('error', 'Unknown error') + result_dict = { + "content": [{ + "type": "text", + "text": f"Error executing tool: {error_msg}" + }], + "isError": True + } + except Exception as exec_error: + logger.error(f"Error executing {tool_name}: {exec_error}") + result_dict = { + "content": [{ + "type": "text", + "text": f"Failed to execute tool: {str(exec_error)}" + }], + "isError": True + } else: - # Last resort: check if it's already a string - if isinstance(result, str): - result_dict = {"content": [{"type": "text", "text": result}]} + # Use FastMCP's call_tool method for regular tools + result = await mcp.call_tool(tool_name, tool_arguments) + + # Handle tuple results from FastMCP + if isinstance(result, tuple) and len(result) >= 1: + # FastMCP returns (content_list, metadata_dict) + content_list = result[0] + if isinstance(content_list, list): + # Serialize TextContent objects to dicts + serialized_content = [] + for item in content_list: + if hasattr(item, 'model_dump'): + serialized_content.append(item.model_dump()) + elif hasattr(item, 'dict'): + serialized_content.append(item.dict()) + elif isinstance(item, dict): + serialized_content.append(item) + else: + serialized_content.append({"type": "text", "text": str(item)}) + result_dict = {"content": serialized_content} + else: + result_dict = {"content": [{"type": "text", "text": str(result)}]} + # Convert result to dict - it's a CallToolResult with content list + elif hasattr(result, 'model_dump'): + result_dict = result.model_dump() + elif hasattr(result, 'dict'): + result_dict = result.dict() + elif hasattr(result, 'content'): + # Extract content directly if it has a content attribute + result_dict = {"content": result.content} else: - # If it's some other type, try to serialize it - result_dict = {"content": [{"type": "text", "text": str(result)}]} - logger.warning(f"Used fallback str() conversion for type {type(result)}") + # Last resort: check if it's already a string + if isinstance(result, str): + result_dict = {"content": [{"type": "text", "text": result}]} + else: + # If it's some other type, try to serialize it + result_dict = {"content": [{"type": "text", "text": str(result)}]} + logger.warning(f"Used fallback str() conversion for type {type(result)}") logger.info(f"Converted result to dict") diff --git a/jupyter_mcp_server/server.py b/jupyter_mcp_server/server.py index ece943ca..3269dd56 100644 --- a/jupyter_mcp_server/server.py +++ b/jupyter_mcp_server/server.py @@ -550,9 +550,75 @@ async def get_registered_tools(): This function is used by the Jupyter extension to dynamically expose the tool registry without hardcoding tool names and parameters. + For JUPYTER_SERVER mode, it queries the jupyter-mcp-tools extension. + For MCP_SERVER mode, it uses the local FastMCP registry. + Returns: list: List of tool dictionaries with name, description, and inputSchema """ + context = ServerContext.get_instance() + mode = context._mode + + # For JUPYTER_SERVER mode, query jupyter-mcp-tools extension + if mode == ServerMode.JUPYTER_SERVER: + try: + from jupyter_mcp_tools import get_tools + + # Query for a specific tool as the first implementation + # Get the base_url and token from context + # For now, use localhost with assumption the extension is running + base_url = "http://localhost:8888" # TODO: Get from context + token = None # TODO: Get from context if available + + tools_data = await get_tools( + base_url=base_url, + token=token, + query="console_create", # Start with just one tool + enabled_only=True + ) + + # Validate that exactly one tool was returned + if len(tools_data) != 1: + raise ValueError( + f"Expected exactly 1 tool matching 'console_create', " + f"but got {len(tools_data)} tools" + ) + + # Convert jupyter-mcp-tools format to MCP format + tools = [] + for tool_data in tools_data: + tool_dict = { + "name": tool_data.get('id', ''), # Use command ID as name + "description": tool_data.get('caption', tool_data.get('label', '')), + } + + # Convert parameters to inputSchema + params = tool_data.get('parameters', {}) + if params and isinstance(params, dict): + tool_dict["inputSchema"] = params + if 'properties' in params: + tool_dict["parameters"] = list(params['properties'].keys()) + else: + tool_dict["parameters"] = [] + else: + tool_dict["parameters"] = [] + tool_dict["inputSchema"] = { + "type": "object", + "properties": {}, + "description": tool_data.get('usage', '') + } + + tools.append(tool_dict) + + logger.info(f"Retrieved {len(tools)} tool(s) from jupyter-mcp-tools extension") + return tools + + except Exception as e: + logger.error(f"Error querying jupyter-mcp-tools extension: {e}", exc_info=True) + # Re-raise the exception since we require exactly 1 tool + raise + + # For MCP_SERVER mode, use local FastMCP registry # Use FastMCP's list_tools method which returns Tool objects tools_list = await mcp.list_tools() diff --git a/pyproject.toml b/pyproject.toml index 17c01733..6f01c5c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,10 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "jupyter-server-nbmodel", "jupyter-kernel-client>=0.7.3", + "jupyter-mcp-tools>=0.1.1", "jupyter-nbmodel-client>=0.14.2", + "jupyter-server-nbmodel", "jupyter-server-api", "jupyter_server>=1.6,<3", "tornado>=6.1", From 16fa34e4c22d355018ce315b3fc0d959cea9b34b Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 13 Oct 2025 11:48:14 +0200 Subject: [PATCH 2/6] chore: depend on jupyter-mcp-tools 0.1.2 --- .../jupyter_extension/handlers.py | 146 +++++++++++------- jupyter_mcp_server/server.py | 108 ++++++++++--- pyproject.toml | 2 +- 3 files changed, 177 insertions(+), 79 deletions(-) diff --git a/jupyter_mcp_server/jupyter_extension/handlers.py b/jupyter_mcp_server/jupyter_extension/handlers.py index e5f41b98..3eb80142 100644 --- a/jupyter_mcp_server/jupyter_extension/handlers.py +++ b/jupyter_mcp_server/jupyter_extension/handlers.py @@ -34,6 +34,9 @@ class MCPSSEHandler(RequestHandler): The MCP protocol uses SSE for streaming responses from the server to the client. """ + # Cache of jupyter_mcp_tools tool names for routing decisions + _jupyter_tool_names = set() + def check_xsrf_cookie(self): """Disable XSRF checking for MCP protocol requests.""" pass @@ -113,76 +116,103 @@ async def post(self): logger.info("Listing tools from FastMCP and jupyter_mcp_tools...") try: - # Get FastMCP tools + # Get FastMCP tools first tools_list = await mcp.list_tools() logger.info(f"Got {len(tools_list)} tools from FastMCP") - # Convert FastMCP tools to MCP protocol format - tools = [] - for tool in tools_list: - tools.append({ - "name": tool.name, - "description": tool.description, - "inputSchema": tool.inputSchema - }) + # Map FastMCP tool names to their jupyter-mcp-tools equivalents + fastmcp_to_jupyter_mapping = { + "insert_execute_code_cell": "notebook_append-execute", + # Add more mappings as needed + } - # Get tools from jupyter_mcp_tools extension + # Track jupyter_mcp_tools tool names to check for duplicates + jupyter_tool_names = set() + + # Get tools from jupyter_mcp_tools extension first to identify duplicates + jupyter_tools_data = [] try: from jupyter_mcp_tools import get_tools - # Get the server's base URL dynamically - # The server is running on the port configured in settings - port = self.settings.get('port', 4040) # Default to 4040 if not set - base_url = f"http://localhost:{port}" - - # Get token from settings if available - token = self.settings.get('token', None) + # Get the server's base URL dynamically from ServerApp + context = get_server_context() + if context.serverapp is not None: + base_url = context.serverapp.connection_url + token = context.serverapp.token + logger.info(f"Using Jupyter ServerApp connection URL: {base_url}") + else: + # Fallback to hardcoded localhost (should not happen in JUPYTER_SERVER mode) + port = self.settings.get('port', 8888) + base_url = f"http://localhost:{port}" + token = self.settings.get('token', None) + logger.warning(f"ServerApp not available, using fallback: {base_url}") logger.info(f"Querying jupyter_mcp_tools at {base_url}") + # Get ALL tools from jupyter_mcp_tools (no query filter) jupyter_tools_data = await get_tools( base_url=base_url, token=token, - query="console_create", + query=None, # Get all tools enabled_only=True, wait_timeout=5 # Short timeout ) logger.info(f"Got {len(jupyter_tools_data)} tools from jupyter_mcp_tools extension") - # Validate that exactly one tool was returned - if len(jupyter_tools_data) != 1: - logger.warning( - f"Expected exactly 1 tool matching 'console_create', " - f"but got {len(jupyter_tools_data)} tools" - ) - else: - # Convert jupyter_mcp_tools format to MCP format and add to tools list - for tool_data in jupyter_tools_data: - tool_dict = { - "name": tool_data.get('id', ''), - "description": tool_data.get('caption', tool_data.get('label', '')), - } - - # Convert parameters to inputSchema - params = tool_data.get('parameters', {}) - if params and isinstance(params, dict): - tool_dict["inputSchema"] = params - else: - tool_dict["inputSchema"] = { - "type": "object", - "properties": {}, - "description": tool_data.get('usage', '') - } - - tools.append(tool_dict) - - logger.info(f"Added {len(jupyter_tools_data)} tool(s) from jupyter_mcp_tools") + # Build set of jupyter tool names and cache it for routing decisions + jupyter_tool_names = {tool_data.get('id', '') for tool_data in jupyter_tools_data} + MCPSSEHandler._jupyter_tool_names = jupyter_tool_names + logger.info(f"Cached {len(jupyter_tool_names)} jupyter_mcp_tools names for routing: {jupyter_tool_names}") except Exception as jupyter_error: # Log but don't fail - just return FastMCP tools logger.warning(f"Could not fetch tools from jupyter_mcp_tools: {jupyter_error}") + # Convert FastMCP tools to MCP protocol format, excluding duplicates + tools = [] + for tool in tools_list: + # Check if this FastMCP tool has a jupyter-mcp-tools equivalent + jupyter_equivalent = fastmcp_to_jupyter_mapping.get(tool.name) + + if jupyter_equivalent and jupyter_equivalent in jupyter_tool_names: + logger.info(f"Skipping FastMCP tool '{tool.name}' - equivalent '{jupyter_equivalent}' available from jupyter-mcp-tools") + continue + + tools.append({ + "name": tool.name, + "description": tool.description, + "inputSchema": tool.inputSchema + }) + + # Now add jupyter_mcp_tools + for tool_data in jupyter_tools_data: + # Only include MCP protocol fields (exclude internal fields like commandId) + tool_dict = { + "name": tool_data.get('id', ''), + "description": tool_data.get('caption', tool_data.get('label', '')), + } + + # Convert parameters to inputSchema + # The parameters field contains the JSON Schema for the tool's arguments + params = tool_data.get('parameters', {}) + if params and isinstance(params, dict) and params.get('properties'): + # Tool has parameters - use them as inputSchema + tool_dict["inputSchema"] = params + logger.debug(f"Tool {tool_dict['name']} has parameters: {list(params.get('properties', {}).keys())}") + else: + # Tool has no parameters - use empty schema + tool_dict["inputSchema"] = { + "type": "object", + "properties": {}, + "description": tool_data.get('usage', '') + } + + tools.append(tool_dict) + + logger.info(f"Added {len(jupyter_tools_data)} tool(s) from jupyter_mcp_tools") + + logger.info(f"Returning total of {len(tools)} tools") response = { @@ -212,15 +242,24 @@ async def post(self): logger.info(f"Calling tool: {tool_name}") try: - # Check if this is a jupyter_mcp_tools tool (e.g., console_create) - if tool_name == "console_create": + # Check if this is a jupyter_mcp_tools tool + # Use the cached set of jupyter tool names from tools/list + if tool_name in MCPSSEHandler._jupyter_tool_names: # Route to jupyter_mcp_tools extension via HTTP execute endpoint - logger.info(f"Routing {tool_name} to jupyter_mcp_tools extension") + logger.info(f"Routing {tool_name} to jupyter_mcp_tools extension (recognized from cache)") - # Get server configuration - port = self.settings.get('port', 4040) - base_url = f"http://localhost:{port}" - token = self.settings.get('token', None) + # Get server configuration from ServerApp + context = get_server_context() + if context.serverapp is not None: + base_url = context.serverapp.connection_url + token = context.serverapp.token + logger.info(f"Using Jupyter ServerApp connection URL: {base_url}") + else: + # Fallback to hardcoded localhost (should not happen in JUPYTER_SERVER mode) + port = self.settings.get('port', 8888) + base_url = f"http://localhost:{port}" + token = self.settings.get('token', None) + logger.warning(f"ServerApp not available, using fallback: {base_url}") # Use the MCPToolsClient to execute the tool from jupyter_mcp_tools.client import MCPToolsClient @@ -261,6 +300,7 @@ async def post(self): } else: # Use FastMCP's call_tool method for regular tools + logger.info(f"Routing {tool_name} to FastMCP (not in jupyter_mcp_tools cache)") result = await mcp.call_tool(tool_name, tool_arguments) # Handle tuple results from FastMCP diff --git a/jupyter_mcp_server/server.py b/jupyter_mcp_server/server.py index 3269dd56..bb2cd24d 100644 --- a/jupyter_mcp_server/server.py +++ b/jupyter_mcp_server/server.py @@ -559,48 +559,61 @@ async def get_registered_tools(): context = ServerContext.get_instance() mode = context._mode - # For JUPYTER_SERVER mode, query jupyter-mcp-tools extension + # For JUPYTER_SERVER mode, expose BOTH FastMCP tools AND jupyter-mcp-tools if mode == ServerMode.JUPYTER_SERVER: + all_tools = [] + jupyter_tool_names = set() + + # First, get tools from jupyter-mcp-tools extension try: from jupyter_mcp_tools import get_tools - # Query for a specific tool as the first implementation - # Get the base_url and token from context - # For now, use localhost with assumption the extension is running - base_url = "http://localhost:8888" # TODO: Get from context - token = None # TODO: Get from context if available + # Get the base_url and token from server context + # In JUPYTER_SERVER mode, we should use the actual serverapp URL, not hardcoded localhost + if server_context.serverapp is not None: + # Use the actual Jupyter server connection URL + base_url = server_context.serverapp.connection_url + token = server_context.serverapp.token + logger.info(f"Using Jupyter ServerApp connection URL: {base_url}") + else: + # Fallback to configuration (for remote scenarios) + config = get_config() + base_url = config.runtime_url if config.runtime_url else "http://localhost:8888" + token = config.runtime_token + logger.info(f"Using config runtime URL: {base_url}") + + logger.info(f"Querying jupyter-mcp-tools at {base_url}") tools_data = await get_tools( base_url=base_url, token=token, - query="console_create", # Start with just one tool + query=None, # Get all tools enabled_only=True ) - # Validate that exactly one tool was returned - if len(tools_data) != 1: - raise ValueError( - f"Expected exactly 1 tool matching 'console_create', " - f"but got {len(tools_data)} tools" - ) + logger.info(f"Retrieved {len(tools_data)} tools from jupyter-mcp-tools extension") # Convert jupyter-mcp-tools format to MCP format - tools = [] for tool_data in tools_data: + tool_name = tool_data.get('id', '') + jupyter_tool_names.add(tool_name) + + # Only include MCP protocol fields (exclude internal fields like commandId) tool_dict = { - "name": tool_data.get('id', ''), # Use command ID as name + "name": tool_name, "description": tool_data.get('caption', tool_data.get('label', '')), } # Convert parameters to inputSchema + # The parameters field contains the JSON Schema for the tool's arguments params = tool_data.get('parameters', {}) - if params and isinstance(params, dict): + if params and isinstance(params, dict) and params.get('properties'): + # Tool has parameters - use them as inputSchema tool_dict["inputSchema"] = params - if 'properties' in params: - tool_dict["parameters"] = list(params['properties'].keys()) - else: - tool_dict["parameters"] = [] + tool_dict["parameters"] = list(params['properties'].keys()) + logger.debug(f"Tool {tool_dict['name']} has parameters: {tool_dict['parameters']}") else: + # Tool has no parameters - use empty schema tool_dict["parameters"] = [] tool_dict["inputSchema"] = { "type": "object", @@ -608,15 +621,60 @@ async def get_registered_tools(): "description": tool_data.get('usage', '') } - tools.append(tool_dict) + all_tools.append(tool_dict) - logger.info(f"Retrieved {len(tools)} tool(s) from jupyter-mcp-tools extension") - return tools + logger.info(f"Converted {len(all_tools)} tool(s) from jupyter-mcp-tools with parameter schemas") except Exception as e: logger.error(f"Error querying jupyter-mcp-tools extension: {e}", exc_info=True) - # Re-raise the exception since we require exactly 1 tool - raise + # Continue to add FastMCP tools even if jupyter-mcp-tools fails + + # Second, add FastMCP tools (excluding duplicates) + # Map FastMCP tool names to their jupyter-mcp-tools equivalents + fastmcp_to_jupyter_mapping = { + "insert_execute_code_cell": "notebook_append-execute", + # Add more mappings as needed + } + + try: + tools_list = await mcp.list_tools() + logger.info(f"Retrieved {len(tools_list)} tools from FastMCP registry") + + for tool in tools_list: + # Check if this FastMCP tool has a jupyter-mcp-tools equivalent + jupyter_equivalent = fastmcp_to_jupyter_mapping.get(tool.name) + + if jupyter_equivalent and jupyter_equivalent in jupyter_tool_names: + logger.info(f"Skipping FastMCP tool '{tool.name}' - equivalent '{jupyter_equivalent}' available from jupyter-mcp-tools") + continue + + # Add FastMCP tool + tool_dict = { + "name": tool.name, + "description": tool.description, + } + + # Extract parameter names from inputSchema + if hasattr(tool, 'inputSchema') and tool.inputSchema: + input_schema = tool.inputSchema + if 'properties' in input_schema: + tool_dict["parameters"] = list(input_schema['properties'].keys()) + else: + tool_dict["parameters"] = [] + + # Include full inputSchema for MCP protocol compatibility + tool_dict["inputSchema"] = input_schema + else: + tool_dict["parameters"] = [] + + all_tools.append(tool_dict) + + logger.info(f"Added {len(all_tools) - len(jupyter_tool_names)} FastMCP tool(s), total: {len(all_tools)}") + + except Exception as e: + logger.error(f"Error retrieving FastMCP tools: {e}", exc_info=True) + + return all_tools # For MCP_SERVER mode, use local FastMCP registry # Use FastMCP's list_tools method which returns Tool objects diff --git a/pyproject.toml b/pyproject.toml index 6f01c5c0..576170a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ ] dependencies = [ "jupyter-kernel-client>=0.7.3", - "jupyter-mcp-tools>=0.1.1", + "jupyter-mcp-tools>=0.1.2", "jupyter-nbmodel-client>=0.14.2", "jupyter-server-nbmodel", "jupyter-server-api", From 1e7357d2cdeb5326b50ba126a70df38fe184ec44 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 16 Oct 2025 06:21:22 +0200 Subject: [PATCH 3/6] docs: links --- docs/docusaurus.config.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 7faa25ee..eba49876 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -80,7 +80,7 @@ module.exports = { 'aria-label': 'YouTube', }, { - href: 'https://datalayer.io', + href: 'https://datalayer.ai', position: 'right', className: 'header-datalayer-io-link', 'aria-label': 'Datalayer', @@ -125,16 +125,16 @@ module.exports = { }, { label: 'Datalayer Docs', - href: 'https://docs.datalayer.ai', - }, - { - label: 'Datalayer Blog', - href: 'https://datalayer.blog', + href: 'https://docs.datalayer.app', }, { label: 'Datalayer Guide', href: 'https://datalayer.guide', }, + { + label: 'Datalayer Blog', + href: 'https://datalayer.blog', + }, ], }, ], From 579a7303a6d4e27e4af3122f9e97288217f30311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?El=C3=A9onore=20Charles?= Date: Fri, 17 Oct 2025 11:25:18 +0200 Subject: [PATCH 4/6] jupyterlab mode + automatic notebook opening --- README.md | 5 +- docs/docs/configure/index.mdx | 3 + docs/docs/deployment/jupyter/index.mdx | 6 + docs/docs/deployment/jupyter/stdio/index.mdx | 2 +- .../jupyter-extension/index.mdx | 2 +- .../streamable-http/standalone/index.mdx | 2 +- docs/docs/index.mdx | 1 + docs/docs/releases/index.mdx | 4 + jupyter_mcp_server/CLI.py | 19 +- jupyter_mcp_server/config.py | 5 + .../jupyter_extension/context.py | 21 ++- .../jupyter_extension/extension.py | 12 +- .../jupyter_extension/handlers.py | 75 +++++++- jupyter_mcp_server/server.py | 176 ++++++++++++------ jupyter_mcp_server/server_context.py | 8 +- jupyter_mcp_server/tools/use_notebook_tool.py | 59 +++++- 16 files changed, 316 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 8178d8ea..814159d4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Trust Score -> ๐Ÿšจ **Latest Release: v0.14.0**: **Multi-notebook support!** You can now seamlessly switch between multiple notebooks in a single session. [๐Ÿ“‹ Read more in the release notes](https://jupyter-mcp-server.datalayer.tech/releases) +> ๐Ÿšจ **Latest Release: v17.0**: **JupyterLab Mode Integration!** Enhanced UI integration with automatic notebook opening and selective tool exposure for improved performance. [๐Ÿ“‹ Read more in the release notes](https://jupyter-mcp-server.datalayer.tech/releases) ![Jupyter MCP Server Demo](https://assets.datalayer.tech/jupyter-mcp/mcp-demo-multimodal.gif) @@ -50,6 +50,7 @@ - ๐Ÿง  **Context-aware:** Understands the entire notebook context for more relevant interactions. - ๐Ÿ“Š **Multimodal support:** Support different output types, including images, plots, and text. - ๐Ÿ“š **Multi-notebook support:** Seamlessly switch between multiple notebooks. +- ๐ŸŽจ **JupyterLab integration:** Enhanced UI integration like automatic notebook opening. - ๐Ÿค **MCP-compatible:** Works with any MCP client, such as Claude Desktop, Cursor, Windsurf, and more. Compatible with any Jupyter deployment (local, JupyterHub, ...) and with [Datalayer](https://datalayer.ai/) hosted Notebooks. @@ -100,7 +101,7 @@ For comprehensive setup instructionsโ€”including `Streamable HTTP` transport, ru ```bash pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 ipykernel pip uninstall -y pycrdt datalayer_pycrdt -pip install datalayer_pycrdt==0.12.17 +pip install datalayer_pycrdt==0.12.17 jupyter_mcp_tools ``` ### 2. Start JupyterLab diff --git a/docs/docs/configure/index.mdx b/docs/docs/configure/index.mdx index 87ac2c40..1fcccf69 100644 --- a/docs/docs/configure/index.mdx +++ b/docs/docs/configure/index.mdx @@ -17,6 +17,9 @@ Options: Defaults to 'stdio'. --provider [jupyter|datalayer] The provider to use for the document and runtime. Defaults to 'jupyter'. + --jupyterlab BOOLEAN Enable JupyterLab mode for enhanced UI + integration like automatic notebook opening. + Defaults to True. --runtime-url TEXT The runtime URL to use. For the jupyter provider, this is the Jupyter server URL. For the datalayer provider, this is the diff --git a/docs/docs/deployment/jupyter/index.mdx b/docs/docs/deployment/jupyter/index.mdx index 9fa03b6a..b0404c2f 100644 --- a/docs/docs/deployment/jupyter/index.mdx +++ b/docs/docs/deployment/jupyter/index.mdx @@ -6,6 +6,12 @@ The Jupyter MCP Server acts as a bridge between the MCP client and the JupyterLa You can customize the setup further based on your requirements. Refer to the [server configuration](/configure) for more details on the possible configurations. +:::tip JupyterLab Mode + +**New in v0.17.0**: Enable JupyterLab mode for enhanced UI integration! When enabled, notebooks automatically open in JupyterLab and additional UI tools become available. See the [JupyterLab Mode configuration](/configure#jupyterlab-mode) for details. + +::: + Jupyter MCP Server supports two types of transport to connect to your MCP client: **STDIO** and **Streamable HTTP**. Choose the one that best fits your needs. For more details on the different transports, refer to the official MCP documentation [here](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports). diff --git a/docs/docs/deployment/jupyter/stdio/index.mdx b/docs/docs/deployment/jupyter/stdio/index.mdx index 600a68a8..f8fd6c02 100644 --- a/docs/docs/deployment/jupyter/stdio/index.mdx +++ b/docs/docs/deployment/jupyter/stdio/index.mdx @@ -9,7 +9,7 @@ Make sure you have the following packages installed in your environment. The col ```bash pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 ipykernel pip uninstall -y pycrdt datalayer_pycrdt -pip install datalayer_pycrdt==0.12.17 +pip install datalayer_pycrdt==0.12.17 jupyter_mcp_tools ``` ### JupyterLab start diff --git a/docs/docs/deployment/jupyter/streamable-http/jupyter-extension/index.mdx b/docs/docs/deployment/jupyter/streamable-http/jupyter-extension/index.mdx index 472a0cdc..3b5801b7 100644 --- a/docs/docs/deployment/jupyter/streamable-http/jupyter-extension/index.mdx +++ b/docs/docs/deployment/jupyter/streamable-http/jupyter-extension/index.mdx @@ -9,7 +9,7 @@ Make sure you have the following packages installed in your environment. The col ```bash pip install "jupyter-mcp-server>=0.15.0" "jupyterlab==4.4.1" "jupyter-collaboration==4.0.2" "ipykernel" pip uninstall -y pycrdt datalayer_pycrdt -pip install datalayer_pycrdt==0.12.17 +pip install datalayer_pycrdt==0.12.17 jupyter_mcp_tools ``` ### JupyterLab and MCP start diff --git a/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx b/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx index c2903681..05053031 100644 --- a/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx +++ b/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx @@ -9,7 +9,7 @@ Make sure you have the following packages installed in your environment. The col ```bash pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 ipykernel pip uninstall -y pycrdt datalayer_pycrdt -pip install datalayer_pycrdt==0.12.17 +pip install datalayer_pycrdt==0.12.17 jupyter_mcp_tools ``` ### JupyterLab start diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index c1f67b13..c07f372c 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -26,6 +26,7 @@ Key features include: - ๐Ÿง  **Context-aware:** Understands the entire notebook context for more relevant interactions. - ๐Ÿ“Š **Multimodal support:** Support different output types, including images, plots, and text. - ๐Ÿ“ **Multi-notebook support:** Seamlessly switch between multiple notebooks. +- ๐ŸŽ›๏ธ **JupyterLab integration:** Enhanced UI integration like automatic notebook opening. - ๐Ÿค **MCP-compatible:** Works with any MCP client, such as [Claude Desktop](/clients/claude_desktop), [Cursor](/clients/cursor), [Cline](/clients/cline), [Windsurf](/clients/windsurf) and more. To use Jupyter MCP Server, you first need to decide which setup fits your needs: diff --git a/docs/docs/releases/index.mdx b/docs/docs/releases/index.mdx index be1b6dea..89bf24bb 100644 --- a/docs/docs/releases/index.mdx +++ b/docs/docs/releases/index.mdx @@ -1,5 +1,9 @@ # Releases +## 17.0 - 17 Oct 2025 + +- [JupyterLab Mode Integration](https://github.com/datalayer/jupyter-mcp-server/pull/109): Enhanced UI integration like automatic notebook opening. + ## 0.16.x - 13 Oct 2025 - [Merge the three execute tools into a single unified tool](https://github.com/datalayer/jupyter-mcp-server/pull/111) diff --git a/jupyter_mcp_server/CLI.py b/jupyter_mcp_server/CLI.py index 96855754..c0d818e3 100644 --- a/jupyter_mcp_server/CLI.py +++ b/jupyter_mcp_server/CLI.py @@ -34,6 +34,13 @@ def _common_options(f): 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", @@ -157,6 +164,7 @@ def _do_start( document_token: str, port: int, provider: str, + jupyterlab: bool, ): """Internal function to execute the start logic.""" @@ -180,7 +188,8 @@ def _do_start( document_url=document_url, document_id=document_id, document_token=document_token, - port=port + port=port, + jupyterlab=jupyterlab ) # Reset ServerContext to pick up new configuration @@ -259,6 +268,7 @@ def server( jupyter_token: str, port: int, provider: str, + jupyterlab: bool, ): """Manages Jupyter MCP Server. @@ -293,6 +303,7 @@ def server( document_token=resolved_document_token, port=port, provider=provider, + jupyterlab=jupyterlab, ) @@ -314,6 +325,7 @@ def connect_command( document_id: str, document_token: str, provider: str, + jupyterlab: bool, ): """Command to connect a Jupyter MCP Server to a document and a runtime.""" @@ -325,7 +337,8 @@ def connect_command( runtime_token=runtime_token, document_url=document_url, document_id=document_id, - document_token=document_token + document_token=document_token, + jupyterlab=jupyterlab ) config = get_config() @@ -402,6 +415,7 @@ def start_command( 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 @@ -425,6 +439,7 @@ def start_command( document_token=resolved_document_token, port=port, provider=provider, + jupyterlab=jupyterlab, ) diff --git a/jupyter_mcp_server/config.py b/jupyter_mcp_server/config.py index 613a4e7a..ee9848c8 100644 --- a/jupyter_mcp_server/config.py +++ b/jupyter_mcp_server/config.py @@ -29,6 +29,7 @@ class JupyterMCPConfig(BaseModel): # Server configuration port: int = Field(default=4040, description="The port to use for the Streamable HTTP transport") + jupyterlab: bool = Field(default=True, description="Enable JupyterLab mode (defaults to True)") class Config: """Pydantic configuration.""" @@ -42,6 +43,10 @@ def is_local_document(self) -> bool: def is_local_runtime(self) -> bool: """Check if runtime URL is set to local.""" return self.runtime_url == "local" + + def is_jupyterlab_mode(self) -> bool: + """Check if JupyterLab mode is enabled.""" + return self.jupyterlab def _get_env_bool(env_name: str, default_value: bool = True) -> bool: """ diff --git a/jupyter_mcp_server/jupyter_extension/context.py b/jupyter_mcp_server/jupyter_extension/context.py index b88b8d01..ecb558e2 100644 --- a/jupyter_mcp_server/jupyter_extension/context.py +++ b/jupyter_mcp_server/jupyter_extension/context.py @@ -44,6 +44,7 @@ def __init__(self): self._serverapp: Optional['ServerApp'] = None self._document_url: Optional[str] = None self._runtime_url: Optional[str] = None + self._jupyterlab: bool = True # Default to True @property def context_type(self) -> Literal["MCP_SERVER", "JUPYTER_SERVER"]: @@ -65,12 +66,18 @@ def runtime_url(self) -> Optional[str]: """Get the configured runtime URL.""" return self._runtime_url + @property + def jupyterlab(self) -> bool: + """Get the jupyterlab mode flag.""" + return self._jupyterlab + def update( self, context_type: Literal["MCP_SERVER", "JUPYTER_SERVER"], serverapp: Optional['ServerApp'] = None, document_url: Optional[str] = None, - runtime_url: Optional[str] = None + runtime_url: Optional[str] = None, + jupyterlab: Optional[bool] = None ): """ Update the server context. @@ -80,6 +87,7 @@ def update( serverapp: Jupyter ServerApp instance (required for JUPYTER_SERVER mode) document_url: Document URL configuration runtime_url: Runtime URL configuration + jupyterlab: JupyterLab mode flag (defaults to True when JUPYTER_SERVER mode is true) """ with self._lock: self._context_type = context_type @@ -87,6 +95,12 @@ def update( self._document_url = document_url self._runtime_url = runtime_url + # Set jupyterlab flag - default to True if JUPYTER_SERVER mode, otherwise keep current value + if jupyterlab is not None: + self._jupyterlab = jupyterlab + elif context_type == "JUPYTER_SERVER": + self._jupyterlab = True # Default to True for JUPYTER_SERVER mode + if context_type == "JUPYTER_SERVER" and serverapp is None: raise ValueError("serverapp is required when context_type is JUPYTER_SERVER") @@ -104,6 +118,10 @@ def is_local_runtime(self) -> bool: and self._runtime_url == "local" ) + def is_jupyterlab_mode(self) -> bool: + """Check if JupyterLab mode is enabled.""" + return self._jupyterlab + def get_contents_manager(self): """ Get the Jupyter contents manager (only available in JUPYTER_SERVER mode with local access). @@ -165,6 +183,7 @@ def reset(self): self._serverapp = None self._document_url = None self._runtime_url = None + self._jupyterlab = True # Default to True # Global accessor diff --git a/jupyter_mcp_server/jupyter_extension/extension.py b/jupyter_mcp_server/jupyter_extension/extension.py index 5a4c0e29..8ae882bc 100644 --- a/jupyter_mcp_server/jupyter_extension/extension.py +++ b/jupyter_mcp_server/jupyter_extension/extension.py @@ -98,6 +98,12 @@ class JupyterMCPServerExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): help='Provider type for document/runtime' ) + jupyterlab = Bool( + True, + config=True, + help='Enable JupyterLab mode (defaults to True)' + ) + def initialize_settings(self): """ Initialize extension settings. @@ -113,6 +119,7 @@ def initialize_settings(self): logger.info(f" Runtime URL: {self.runtime_url}") logger.info(f" Document ID: {self.document_id}") logger.info(f" Start New Runtime: {self.start_new_runtime}") + logger.info(f" JupyterLab Mode: {self.jupyterlab}") if self.runtime_id: logger.info(f" Runtime ID: {self.runtime_id}") @@ -122,7 +129,8 @@ def initialize_settings(self): context_type="JUPYTER_SERVER", serverapp=self.serverapp, document_url=self.document_url, - runtime_url=self.runtime_url + runtime_url=self.runtime_url, + jupyterlab=self.jupyterlab ) # Update global MCP configuration @@ -136,6 +144,7 @@ def initialize_settings(self): config.start_new_runtime = self.start_new_runtime config.runtime_id = self.runtime_id if self.runtime_id else None config.provider = self.provider + config.jupyterlab = self.jupyterlab # Store configuration in settings for handlers self.settings.update({ @@ -147,6 +156,7 @@ def initialize_settings(self): "mcp_start_new_runtime": self.start_new_runtime, "mcp_runtime_id": self.runtime_id, "mcp_provider": self.provider, + "mcp_jupyterlab": self.jupyterlab, "mcp_serverapp": self.serverapp, }) diff --git a/jupyter_mcp_server/jupyter_extension/handlers.py b/jupyter_mcp_server/jupyter_extension/handlers.py index 3eb80142..3dad4ec0 100644 --- a/jupyter_mcp_server/jupyter_extension/handlers.py +++ b/jupyter_mcp_server/jupyter_extension/handlers.py @@ -18,6 +18,7 @@ from jupyter_server.extension.handler import ExtensionHandlerMixin from jupyter_mcp_server.jupyter_extension.context import get_server_context +from jupyter_mcp_server.server_context import ServerContext from jupyter_mcp_server.jupyter_extension.backends.local_backend import LocalBackend from jupyter_mcp_server.jupyter_extension.backends.remote_backend import RemoteBackend @@ -149,16 +150,72 @@ async def post(self): logger.info(f"Querying jupyter_mcp_tools at {base_url}") - # Get ALL tools from jupyter_mcp_tools (no query filter) - jupyter_tools_data = await get_tools( - base_url=base_url, - token=token, - query=None, # Get all tools - enabled_only=True, - wait_timeout=5 # Short timeout - ) + # Check if JupyterLab mode is enabled before loading jupyter-mcp-tools + context = ServerContext.get_instance() + jupyterlab_enabled = context.is_jupyterlab_mode() + logger.info(f"JupyterLab mode check: enabled={jupyterlab_enabled}") - logger.info(f"Got {len(jupyter_tools_data)} tools from jupyter_mcp_tools extension") + if jupyterlab_enabled: + # Define specific tools we want to load from jupyter-mcp-tools + allowed_jupyter_tools = [ + "notebook_run-all-cells", # Run all cells in current notebook + # Add more specific tools here as needed when JupyterLab mode is enabled + ] + + logger.info(f"Looking for specific jupyter-mcp-tools: {allowed_jupyter_tools}") + + # Try querying with broader terms since specific IDs don't work + try: + search_query = ",".join(allowed_jupyter_tools) + logger.info(f"Searching jupyter-mcp-tools with query: '{search_query}' (allowed_tools: {allowed_jupyter_tools})") + + # Query for notebook-related tools with broader search term + all_notebook_tools = await get_tools( + base_url=base_url, + token=token, + query=search_query, + enabled_only=True + ) + logger.info(f"Query returned {len(all_notebook_tools)} tools") + + # If no tools found with specific query, try a broader search for debugging + if len(all_notebook_tools) == 0: + logger.warning("Specific query returned 0 tools, trying broader search for debugging...") + debug_tools = await get_tools( + base_url=base_url, + token=token, + query="notebook", # Very broad search + enabled_only=True + ) + logger.info(f"DEBUG: Broader 'notebook' query returned {len(debug_tools)} tools") + # show all found tools for debugging + for tool in debug_tools: + logger.info(f"DEBUG: Found tool: {tool.get('id', '')}") + + # Also try getting ALL tools to see what's available + all_tools_debug = await get_tools( + base_url=base_url, + token=token, + query=None, # Get all tools + enabled_only=True + ) + logger.info(f"DEBUG: Total tools available: {len(all_tools_debug)}") + for tool in all_tools_debug: + logger.info(f"DEBUG: Found tool: {tool.get('id', '')}") + + # Use the tools directly since query should return only what we want + jupyter_tools_data = all_notebook_tools + for tool in jupyter_tools_data: + logger.info(f"Found tool: {tool.get('id', '')}") + except Exception as e: + logger.warning(f"Failed to load jupyter-mcp-tools: {e}") + jupyter_tools_data = [] + + logger.info(f"Successfully loaded {len(jupyter_tools_data)} specific jupyter-mcp-tools") + else: + # JupyterLab mode disabled, don't load any jupyter-mcp-tools + jupyter_tools_data = [] + logger.info("JupyterLab mode disabled, skipping jupyter-mcp-tools") # Build set of jupyter tool names and cache it for routing decisions jupyter_tool_names = {tool_data.get('id', '') for tool_data in jupyter_tools_data} diff --git a/jupyter_mcp_server/server.py b/jupyter_mcp_server/server.py index bb2cd24d..79fe7c0c 100644 --- a/jupyter_mcp_server/server.py +++ b/jupyter_mcp_server/server.py @@ -559,81 +559,133 @@ async def get_registered_tools(): context = ServerContext.get_instance() mode = context._mode - # For JUPYTER_SERVER mode, expose BOTH FastMCP tools AND jupyter-mcp-tools + # For JUPYTER_SERVER mode, expose BOTH FastMCP tools AND jupyter-mcp-tools (when enabled) if mode == ServerMode.JUPYTER_SERVER: all_tools = [] jupyter_tool_names = set() - # First, get tools from jupyter-mcp-tools extension - try: - from jupyter_mcp_tools import get_tools - - # Get the base_url and token from server context - # In JUPYTER_SERVER mode, we should use the actual serverapp URL, not hardcoded localhost - if server_context.serverapp is not None: - # Use the actual Jupyter server connection URL - base_url = server_context.serverapp.connection_url - token = server_context.serverapp.token - logger.info(f"Using Jupyter ServerApp connection URL: {base_url}") - else: - # Fallback to configuration (for remote scenarios) - config = get_config() - base_url = config.runtime_url if config.runtime_url else "http://localhost:8888" - token = config.runtime_token - logger.info(f"Using config runtime URL: {base_url}") - - logger.info(f"Querying jupyter-mcp-tools at {base_url}") + # Check if JupyterLab mode is enabled before loading jupyter-mcp-tools + if server_context.is_jupyterlab_mode(): + logger.info("JupyterLab mode enabled, loading selected jupyter-mcp-tools") - tools_data = await get_tools( - base_url=base_url, - token=token, - query=None, # Get all tools - enabled_only=True - ) - - logger.info(f"Retrieved {len(tools_data)} tools from jupyter-mcp-tools extension") - - # Convert jupyter-mcp-tools format to MCP format - for tool_data in tools_data: - tool_name = tool_data.get('id', '') - jupyter_tool_names.add(tool_name) + # Get tools from jupyter-mcp-tools extension + try: + from jupyter_mcp_tools import get_tools - # Only include MCP protocol fields (exclude internal fields like commandId) - tool_dict = { - "name": tool_name, - "description": tool_data.get('caption', tool_data.get('label', '')), - } - - # Convert parameters to inputSchema - # The parameters field contains the JSON Schema for the tool's arguments - params = tool_data.get('parameters', {}) - if params and isinstance(params, dict) and params.get('properties'): - # Tool has parameters - use them as inputSchema - tool_dict["inputSchema"] = params - tool_dict["parameters"] = list(params['properties'].keys()) - logger.debug(f"Tool {tool_dict['name']} has parameters: {tool_dict['parameters']}") + # Get the base_url and token from server context + # In JUPYTER_SERVER mode, we should use the actual serverapp URL, not hardcoded localhost + if server_context.serverapp is not None: + # Use the actual Jupyter server connection URL + base_url = server_context.serverapp.connection_url + token = server_context.serverapp.token + logger.info(f"Using Jupyter ServerApp connection URL: {base_url}") else: - # Tool has no parameters - use empty schema - tool_dict["parameters"] = [] - tool_dict["inputSchema"] = { - "type": "object", - "properties": {}, - "description": tool_data.get('usage', '') + # Fallback to configuration (for remote scenarios) + config = get_config() + base_url = config.runtime_url if config.runtime_url else "http://localhost:8888" + token = config.runtime_token + logger.info(f"Using config runtime URL: {base_url}") + + logger.info(f"Querying jupyter-mcp-tools at {base_url}") + + # Define specific tools we want to load from jupyter-mcp-tools + allowed_jupyter_tools = [ + "notebook_run-all-cells", # Run all cells in current notebook + ] + + # Try querying with broader terms since specific IDs don't work + try: + search_query = ",".join(allowed_jupyter_tools) + logger.info(f"Searching jupyter-mcp-tools with query: '{search_query}' (allowed_tools: {allowed_jupyter_tools})") + + # Query for notebook-related tools with broader search term + all_notebook_tools = await get_tools( + base_url=base_url, + token=token, + query=search_query, + enabled_only=True + ) + logger.info(f"Query returned {len(all_notebook_tools)} tools") + + # If no tools found with specific query, try a broader search for debugging + if len(all_notebook_tools) == 0: + logger.warning("Specific query returned 0 tools, trying broader search for debugging...") + debug_tools = await get_tools( + base_url=base_url, + token=token, + query="notebook", # Very broad search + enabled_only=True + ) + logger.info(f"DEBUG: Broader 'notebook' query returned {len(debug_tools)} tools") + for tool in debug_tools: + logger.info(f"DEBUG: Available tool ID: '{tool.get('id', '')}' - {tool.get('label', '')}") + + # Also try getting ALL tools to see what's available + all_tools_debug = await get_tools( + base_url=base_url, + token=token, + query=None, # Get all tools + enabled_only=True + ) + logger.info(f"DEBUG: Total tools available: {len(all_tools_debug)}") + for tool in all_tools_debug: + logger.info(f"DEBUG: Found tool: {tool.get('id', '')} - {tool.get('label', '')}") + + # Use the tools directly since query should return only what we want + tools_data = all_notebook_tools + for tool in tools_data: + logger.info(f"Found tool: {tool.get('id', '')}") + + except Exception as e: + logger.warning(f"Failed to load jupyter-mcp-tools: {e}") + tools_data = [] + + logger.info(f"Successfully loaded {len(tools_data)} specific jupyter-mcp-tools") + + logger.info(f"Retrieved {len(tools_data)} tools from jupyter-mcp-tools extension") + + # Convert jupyter-mcp-tools format to MCP format + for tool_data in tools_data: + tool_name = tool_data.get('id', '') + jupyter_tool_names.add(tool_name) + + # Only include MCP protocol fields (exclude internal fields like commandId) + tool_dict = { + "name": tool_name, + "description": tool_data.get('caption', tool_data.get('label', '')), } + + # Convert parameters to inputSchema + # The parameters field contains the JSON Schema for the tool's arguments + params = tool_data.get('parameters', {}) + if params and isinstance(params, dict) and params.get('properties'): + # Tool has parameters - use them as inputSchema + tool_dict["inputSchema"] = params + tool_dict["parameters"] = list(params['properties'].keys()) + logger.debug(f"Tool {tool_dict['name']} has parameters: {tool_dict['parameters']}") + else: + # Tool has no parameters - use empty schema + tool_dict["parameters"] = [] + tool_dict["inputSchema"] = { + "type": "object", + "properties": {}, + "description": tool_data.get('usage', '') + } + + all_tools.append(tool_dict) - all_tools.append(tool_dict) - - logger.info(f"Converted {len(all_tools)} tool(s) from jupyter-mcp-tools with parameter schemas") - - except Exception as e: - logger.error(f"Error querying jupyter-mcp-tools extension: {e}", exc_info=True) - # Continue to add FastMCP tools even if jupyter-mcp-tools fails + logger.info(f"Converted {len(all_tools)} tool(s) from jupyter-mcp-tools with parameter schemas") + + except Exception as e: + logger.error(f"Error querying jupyter-mcp-tools extension: {e}", exc_info=True) + # Continue to add FastMCP tools even if jupyter-mcp-tools fails + else: + logger.info("JupyterLab mode disabled, skipping jupyter-mcp-tools integration") # Second, add FastMCP tools (excluding duplicates) # Map FastMCP tool names to their jupyter-mcp-tools equivalents fastmcp_to_jupyter_mapping = { - "insert_execute_code_cell": "notebook_append-execute", - # Add more mappings as needed + # Add mappings as needed when tools overlap } try: diff --git a/jupyter_mcp_server/server_context.py b/jupyter_mcp_server/server_context.py index 51bfbebc..31fbae74 100644 --- a/jupyter_mcp_server/server_context.py +++ b/jupyter_mcp_server/server_context.py @@ -145,4 +145,10 @@ def server_client(self): def kernel_client(self): if not self._initialized: self.initialize() - return self._kernel_client \ No newline at end of file + return self._kernel_client + + def is_jupyterlab_mode(self) -> bool: + """Check if JupyterLab mode is enabled.""" + from jupyter_mcp_server.config import get_config + config = get_config() + return config.is_jupyterlab_mode() \ No newline at end of file diff --git a/jupyter_mcp_server/tools/use_notebook_tool.py b/jupyter_mcp_server/tools/use_notebook_tool.py index 33205ff4..cc82ca4e 100644 --- a/jupyter_mcp_server/tools/use_notebook_tool.py +++ b/jupyter_mcp_server/tools/use_notebook_tool.py @@ -262,8 +262,61 @@ async def execute( notebook_manager.set_current_notebook(notebook_name) - # Return message based on mode + # If JupyterLab mode is enabled and we're in JUPYTER_SERVER mode, + # also open the notebook in JupyterLab UI + success_message = "" if use_mode == "create": - return f"Successfully created and using notebook '{notebook_name}' at path '{notebook_path}' in {mode.value} mode." + success_message = f"Successfully created and using notebook '{notebook_name}' at path '{notebook_path}' in {mode.value} mode." else: - return f"Successfully using notebook '{notebook_name}' at path '{notebook_path}' in {mode.value} mode." + success_message = f"Successfully using notebook '{notebook_name}' at path '{notebook_path}' in {mode.value} mode." + + # Check if we should open in JupyterLab UI (when JupyterLab mode is enabled) + try: + from jupyter_mcp_server.jupyter_extension.context import get_server_context + context = get_server_context() + + if context.is_jupyterlab_mode(): + logger.info(f"JupyterLab mode enabled, attempting to open notebook '{notebook_path}' in JupyterLab UI") + + # Determine base_url and token based on mode + base_url = None + token = None + + if mode == ServerMode.JUPYTER_SERVER and context.serverapp is not None: + # JUPYTER_SERVER mode: Use ServerApp connection details + base_url = context.serverapp.connection_url + token = context.serverapp.token + elif mode == ServerMode.MCP_SERVER and runtime_url: + # MCP_SERVER mode: Use runtime_url and runtime_token + base_url = runtime_url + token = runtime_token + + if base_url and token: + try: + from jupyter_mcp_tools.client import MCPToolsClient + + async with MCPToolsClient(base_url=base_url, token=token) as client: + execution_result = await client.execute_tool( + tool_id="docmanager_open", # docmanager:open converted to underscore format + parameters={"path": notebook_path} + ) + + if execution_result.get('success'): + logger.info(f"Successfully opened notebook '{notebook_path}' in JupyterLab UI") + success_message += " Notebook opened in JupyterLab UI." + else: + logger.warning(f"Failed to open notebook in JupyterLab UI: {execution_result}") + success_message += " (Note: Could not open in JupyterLab UI)" + + except ImportError: + logger.warning("jupyter_mcp_tools not available, skipping JupyterLab UI opening") + except Exception as e: + logger.warning(f"Failed to open notebook in JupyterLab UI: {e}") + success_message += " (Note: Could not open in JupyterLab UI)" + else: + logger.warning("No valid base_url or token available for opening notebook in JupyterLab UI") + + except Exception as e: + logger.debug(f"Could not check JupyterLab mode: {e}") + + return success_message From aa72486c731519fe973d3dc7842a92bea6bda8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?El=C3=A9onore=20Charles?= Date: Fri, 17 Oct 2025 13:27:24 +0200 Subject: [PATCH 5/6] fix get_tools --- README.md | 8 +++++ docs/docs/tools/index.mdx | 15 ++++++++- .../jupyter_extension/handlers.py | 32 ++----------------- jupyter_mcp_server/server.py | 32 ++----------------- 4 files changed, 28 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 814159d4..958c8125 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,14 @@ The server provides a rich set of tools for interacting with Jupyter notebooks, | `insert_execute_code_cell` | A convenient tool to insert a new code cell and execute it in one step. | | `execute_ipython` | Execute IPython code directly in the kernel, including magic and shell commands. | +### JupyterLab Integration + +*Available only when JupyterLab mode is enabled. It is enabled by default.* + +| Name | Description | +|:---|:---| +| `notebook_run-all-cells` | Execute all cells in the current notebook sequentially using JupyterLab's native commands. | + For more details on each tool, their parameters, and return values, please refer to the [official Tools documentation](https://jupyter-mcp-server.datalayer.tech/tools). ## ๐Ÿ Getting Started diff --git a/docs/docs/tools/index.mdx b/docs/docs/tools/index.mdx index 66fe4bb5..f9b6d2ed 100644 --- a/docs/docs/tools/index.mdx +++ b/docs/docs/tools/index.mdx @@ -1,6 +1,6 @@ # Tools -The server currently offers 16 tools organized into 3 categories: +The server currently offers 16 tools organized into 3 categories, plus 1 additional JupyterLab-specific tool: ## Server Management Tools (3 tools) @@ -164,3 +164,16 @@ The server currently offers 16 tools organized into 3 categories: - `timeout`(int): Execution timeout in seconds (default: 60s) - Returns: - `list[Union[str, ImageContent]]`: List of outputs from the executed code (supports multimodal output including images) + +## JupyterLab-Specific Tools (1 tool) + +*Available only when JupyterLab mode is enabled (`--jupyterlab` or `jupyterlab: true` in config). It is set to `True` by default.* + +#### 17. `notebook_run-all-cells` + +- Execute all cells in the current notebook sequentially. +- This tool provides a convenient way to run the entire notebook from start to finish, which is particularly useful for batch execution or notebook validation. +- Only available when JupyterLab mode is enabled for enhanced UI integration. +- Input: None +- Returns: Success message indicating that all cells have been executed +- Note: This tool uses JupyterLab's native execution commands for optimal performance and UI feedback. diff --git a/jupyter_mcp_server/jupyter_extension/handlers.py b/jupyter_mcp_server/jupyter_extension/handlers.py index 3dad4ec0..7be1cacd 100644 --- a/jupyter_mcp_server/jupyter_extension/handlers.py +++ b/jupyter_mcp_server/jupyter_extension/handlers.py @@ -170,41 +170,15 @@ async def post(self): logger.info(f"Searching jupyter-mcp-tools with query: '{search_query}' (allowed_tools: {allowed_jupyter_tools})") # Query for notebook-related tools with broader search term - all_notebook_tools = await get_tools( + jupyter_tools_data = await get_tools( base_url=base_url, token=token, query=search_query, - enabled_only=True + enabled_only=False ) - logger.info(f"Query returned {len(all_notebook_tools)} tools") + logger.info(f"Query returned {len(jupyter_tools_data)} tools") - # If no tools found with specific query, try a broader search for debugging - if len(all_notebook_tools) == 0: - logger.warning("Specific query returned 0 tools, trying broader search for debugging...") - debug_tools = await get_tools( - base_url=base_url, - token=token, - query="notebook", # Very broad search - enabled_only=True - ) - logger.info(f"DEBUG: Broader 'notebook' query returned {len(debug_tools)} tools") - # show all found tools for debugging - for tool in debug_tools: - logger.info(f"DEBUG: Found tool: {tool.get('id', '')}") - - # Also try getting ALL tools to see what's available - all_tools_debug = await get_tools( - base_url=base_url, - token=token, - query=None, # Get all tools - enabled_only=True - ) - logger.info(f"DEBUG: Total tools available: {len(all_tools_debug)}") - for tool in all_tools_debug: - logger.info(f"DEBUG: Found tool: {tool.get('id', '')}") - # Use the tools directly since query should return only what we want - jupyter_tools_data = all_notebook_tools for tool in jupyter_tools_data: logger.info(f"Found tool: {tool.get('id', '')}") except Exception as e: diff --git a/jupyter_mcp_server/server.py b/jupyter_mcp_server/server.py index 79fe7c0c..da1c4c7e 100644 --- a/jupyter_mcp_server/server.py +++ b/jupyter_mcp_server/server.py @@ -598,41 +598,15 @@ async def get_registered_tools(): search_query = ",".join(allowed_jupyter_tools) logger.info(f"Searching jupyter-mcp-tools with query: '{search_query}' (allowed_tools: {allowed_jupyter_tools})") - # Query for notebook-related tools with broader search term - all_notebook_tools = await get_tools( + tools_data = await get_tools( base_url=base_url, token=token, query=search_query, - enabled_only=True + enabled_only=False ) - logger.info(f"Query returned {len(all_notebook_tools)} tools") + logger.info(f"Query returned {len(tools_data)} tools") - # If no tools found with specific query, try a broader search for debugging - if len(all_notebook_tools) == 0: - logger.warning("Specific query returned 0 tools, trying broader search for debugging...") - debug_tools = await get_tools( - base_url=base_url, - token=token, - query="notebook", # Very broad search - enabled_only=True - ) - logger.info(f"DEBUG: Broader 'notebook' query returned {len(debug_tools)} tools") - for tool in debug_tools: - logger.info(f"DEBUG: Available tool ID: '{tool.get('id', '')}' - {tool.get('label', '')}") - - # Also try getting ALL tools to see what's available - all_tools_debug = await get_tools( - base_url=base_url, - token=token, - query=None, # Get all tools - enabled_only=True - ) - logger.info(f"DEBUG: Total tools available: {len(all_tools_debug)}") - for tool in all_tools_debug: - logger.info(f"DEBUG: Found tool: {tool.get('id', '')} - {tool.get('label', '')}") - # Use the tools directly since query should return only what we want - tools_data = all_notebook_tools for tool in tools_data: logger.info(f"Found tool: {tool.get('id', '')}") From 9cec1320360ac2a22778066e3e1bf33524b479ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?El=C3=A9onore=20Charles?= Date: Fri, 17 Oct 2025 14:09:07 +0200 Subject: [PATCH 6/6] correct setup automated release notes --- .github/workflows/release.yml | 29 +++++++++---------- README.md | 7 ++--- docs/docs/deployment/jupyter/stdio/index.mdx | 4 +-- .../streamable-http/standalone/index.mdx | 4 +-- docs/docs/releases/index.mdx | 26 +++++++++-------- pyproject.toml | 2 +- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75b0c6b3..fa219d5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,21 +116,20 @@ jobs: id: version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + - name: Create Release with Auto-Generated Notes + run: | + gh release create ${{ github.ref_name }} \ + --title "Release ${{ steps.version.outputs.VERSION }}" \ + --generate-notes \ + --notes "## ๐Ÿš€ Release ${{ steps.version.outputs.VERSION }} + + ### ๐Ÿ”— Links + + - [PyPI](https://pypi.org/project/jupyter-mcp-server/${{ steps.version.outputs.VERSION }}/) + - [Docker Hub](https://hub.docker.com/r/datalayer/jupyter-mcp-server) - - name: Create Release - uses: actions/create-release@v1 + --- + + ### What's Changed" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ steps.version.outputs.VERSION }} - body: | - ## ๐Ÿš€ Release ${{ steps.version.outputs.VERSION }} - - ### ๐Ÿ”— Links - - - [PyPI](https://pypi.org/project/jupyter-mcp-server/${{ steps.version.outputs.VERSION }}/) - - [Docker Hub](https://hub.docker.com/r/datalayer/jupyter-mcp-server) - draft: false - prerelease: false - generate_release_notes: true diff --git a/README.md b/README.md index 958c8125..4d9e19cc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Trust Score -> ๐Ÿšจ **Latest Release: v17.0**: **JupyterLab Mode Integration!** Enhanced UI integration with automatic notebook opening and selective tool exposure for improved performance. [๐Ÿ“‹ Read more in the release notes](https://jupyter-mcp-server.datalayer.tech/releases) +> ๐Ÿšจ **Latest Release: v17.0**: **JupyterLab Mode Integration!** Enhanced UI integration with automatic notebook opening. [๐Ÿ“‹ Read more in the release notes](https://jupyter-mcp-server.datalayer.tech/releases) ![Jupyter MCP Server Demo](https://assets.datalayer.tech/jupyter-mcp/mcp-demo-multimodal.gif) @@ -42,7 +42,6 @@ - [Contributing](#-contributing) - [Resources](#-resources) - ## ๐Ÿš€ Key Features - โšก **Real-time control:** Instantly view notebook changes as they happen. @@ -107,9 +106,9 @@ For comprehensive setup instructionsโ€”including `Streamable HTTP` transport, ru ### 1. Set Up Your Environment ```bash -pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 ipykernel +pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 jupyter-mcp-tools>=0.1.4 ipykernel pip uninstall -y pycrdt datalayer_pycrdt -pip install datalayer_pycrdt==0.12.17 jupyter_mcp_tools +pip install datalayer_pycrdt==0.12.17 ``` ### 2. Start JupyterLab diff --git a/docs/docs/deployment/jupyter/stdio/index.mdx b/docs/docs/deployment/jupyter/stdio/index.mdx index f8fd6c02..59abdb2f 100644 --- a/docs/docs/deployment/jupyter/stdio/index.mdx +++ b/docs/docs/deployment/jupyter/stdio/index.mdx @@ -7,9 +7,9 @@ Make sure you have the following packages installed in your environment. The collaboration package is needed as the modifications made on the notebook can be seen thanks to [Jupyter Real Time Collaboration](https://jupyterlab.readthedocs.io/en/stable/user/rtc.html). ```bash -pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 ipykernel +pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 jupyter-mcp-tools>=0.1.4 ipykernel pip uninstall -y pycrdt datalayer_pycrdt -pip install datalayer_pycrdt==0.12.17 jupyter_mcp_tools +pip install datalayer_pycrdt==0.12.17 ``` ### JupyterLab start diff --git a/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx b/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx index 05053031..6954a11d 100644 --- a/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx +++ b/docs/docs/deployment/jupyter/streamable-http/standalone/index.mdx @@ -7,9 +7,9 @@ Make sure you have the following packages installed in your environment. The collaboration package is needed as the modifications made on the notebook can be seen thanks to [Jupyter Real Time Collaboration](https://jupyterlab.readthedocs.io/en/stable/user/rtc.html). ```bash -pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 ipykernel +pip install jupyterlab==4.4.1 jupyter-collaboration==4.0.2 jupyter-mcp-tools>=0.1.4 ipykernel pip uninstall -y pycrdt datalayer_pycrdt -pip install datalayer_pycrdt==0.12.17 jupyter_mcp_tools +pip install datalayer_pycrdt==0.12.17 ``` ### JupyterLab start diff --git a/docs/docs/releases/index.mdx b/docs/docs/releases/index.mdx index 89bf24bb..bcd8c296 100644 --- a/docs/docs/releases/index.mdx +++ b/docs/docs/releases/index.mdx @@ -1,14 +1,16 @@ # Releases -## 17.0 - 17 Oct 2025 +## Latest Release -- [JupyterLab Mode Integration](https://github.com/datalayer/jupyter-mcp-server/pull/109): Enhanced UI integration like automatic notebook opening. +See the latest release notes on the [GitHub Releases page](https://github.com/datalayer/jupyter-mcp-server/releases). -## 0.16.x - 13 Oct 2025 +## Older Releases + +### 0.16.x - 13 Oct 2025 - [Merge the three execute tools into a single unified tool](https://github.com/datalayer/jupyter-mcp-server/pull/111) -## 0.15.x - 08 Oct 2025 +### 0.15.x - 08 Oct 2025 - [Run as Jupyter Server Extension + Tool registry + Use tool](https://github.com/datalayer/jupyter-mcp-server/pull/95) - [simplify tool implementations](https://github.com/datalayer/jupyter-mcp-server/pull/101) @@ -16,37 +18,37 @@ - [document as a Jupyter Extension](https://github.com/datalayer/jupyter-mcp-server/pull/101) - Fix Minor Bugs: [#108](https://github.com/datalayer/jupyter-mcp-server/pull/108),[#110](https://github.com/datalayer/jupyter-mcp-server/pull/110) -## 0.14.0 - 03 Oct 2025 +### 0.14.0 - 03 Oct 2025 - [Additional Tools & Bug fixes](https://github.com/datalayer/jupyter-mcp-server/pull/93). - [Execute IPython](https://github.com/datalayer/jupyter-mcp-server/pull/90). - [Multi notebook management](https://github.com/datalayer/jupyter-mcp-server/pull/88). -## 0.13.0 - 25 Sep 2025 +### 0.13.0 - 25 Sep 2025 - [Add multimodal output support for Jupyter cell execution](https://github.com/datalayer/jupyter-mcp-server/pull/75). - [Unify cell insertion functionality](https://github.com/datalayer/jupyter-mcp-server/pull/73). -## 0.11.0 - 01 Aug 2025 +### 0.11.0 - 01 Aug 2025 - [Rename room to document](https://github.com/datalayer/jupyter-mcp-server/pull/35). -## 0.10.2 - 17 Jul 2025 +### 0.10.2 - 17 Jul 2025 - [Tools docstring improvements](https://github.com/datalayer/jupyter-mcp-server/pull/30). -## 0.10.1 - 11 Jul 2025 +### 0.10.1 - 11 Jul 2025 - [CORS Support](https://github.com/datalayer/jupyter-mcp-server/pull/29). -## 0.10.0 - 07 Jul 2025 +### 0.10.0 - 07 Jul 2025 - More [fixes](https://github.com/datalayer/jupyter-mcp-server/pull/28) issues for nbclient stop. -## 0.9.0 - 02 Jul 2025 +### 0.9.0 - 02 Jul 2025 - Fix issues with `nbmodel` stops. -## 0.6.0 - 01 Jul 2025 +### 0.6.0 - 01 Jul 2025 - Configuration change, see details on the [clients page](/clients) and [server configuration](/configure). diff --git a/pyproject.toml b/pyproject.toml index 576170a9..378e4619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ ] dependencies = [ "jupyter-kernel-client>=0.7.3", - "jupyter-mcp-tools>=0.1.2", + "jupyter-mcp-tools>=0.1.4", "jupyter-nbmodel-client>=0.14.2", "jupyter-server-nbmodel", "jupyter-server-api",