#
tokens: 48729/50000 8/133 files (page 3/6)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 of 6. Use http://codebase.md/datalayer/jupyter-mcp-server?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .dockerignore
├── .github
│   ├── copilot-instructions.md
│   ├── dependabot.yml
│   └── workflows
│       ├── build.yml
│       ├── fix-license-header.yml
│       ├── lint.sh
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .licenserc.yaml
├── .pre-commit-config.yaml
├── .vscode
│   ├── mcp.json
│   └── settings.json
├── ARCHITECTURE.md
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── dev
│   ├── content
│   │   ├── new.ipynb
│   │   ├── notebook.ipynb
│   │   └── README.md
│   └── README.md
├── Dockerfile
├── docs
│   ├── .gitignore
│   ├── .yarnrc.yml
│   ├── babel.config.js
│   ├── docs
│   │   ├── _category_.yaml
│   │   ├── clients
│   │   │   ├── _category_.yaml
│   │   │   ├── claude_desktop
│   │   │   │   ├── _category_.yaml
│   │   │   │   └── index.mdx
│   │   │   ├── cline
│   │   │   │   ├── _category_.yaml
│   │   │   │   └── index.mdx
│   │   │   ├── cursor
│   │   │   │   ├── _category_.yaml
│   │   │   │   └── index.mdx
│   │   │   ├── index.mdx
│   │   │   ├── vscode
│   │   │   │   ├── _category_.yaml
│   │   │   │   └── index.mdx
│   │   │   └── windsurf
│   │   │       ├── _category_.yaml
│   │   │       └── index.mdx
│   │   ├── configure
│   │   │   ├── _category_.yaml
│   │   │   └── index.mdx
│   │   ├── contribute
│   │   │   ├── _category_.yaml
│   │   │   └── index.mdx
│   │   ├── deployment
│   │   │   ├── _category_.yaml
│   │   │   ├── datalayer
│   │   │   │   ├── _category_.yaml
│   │   │   │   └── streamable-http
│   │   │   │       └── index.mdx
│   │   │   ├── index.mdx
│   │   │   └── jupyter
│   │   │       ├── _category_.yaml
│   │   │       ├── index.mdx
│   │   │       ├── stdio
│   │   │       │   ├── _category_.yaml
│   │   │       │   └── index.mdx
│   │   │       └── streamable-http
│   │   │           ├── _category_.yaml
│   │   │           ├── jupyter-extension
│   │   │           │   └── index.mdx
│   │   │           └── standalone
│   │   │               └── index.mdx
│   │   ├── index.mdx
│   │   ├── releases
│   │   │   ├── _category_.yaml
│   │   │   └── index.mdx
│   │   ├── resources
│   │   │   ├── _category_.yaml
│   │   │   └── index.mdx
│   │   └── tools
│   │       ├── _category_.yaml
│   │       └── index.mdx
│   ├── docusaurus.config.js
│   ├── LICENSE
│   ├── Makefile
│   ├── package.json
│   ├── README.md
│   ├── sidebars.js
│   ├── src
│   │   ├── components
│   │   │   ├── HomepageFeatures.js
│   │   │   ├── HomepageFeatures.module.css
│   │   │   ├── HomepageProducts.js
│   │   │   └── HomepageProducts.module.css
│   │   ├── css
│   │   │   └── custom.css
│   │   ├── pages
│   │   │   ├── index.module.css
│   │   │   ├── markdown-page.md
│   │   │   └── testimonials.tsx
│   │   └── theme
│   │       └── CustomDocItem.tsx
│   └── static
│       └── img
│           ├── datalayer
│           │   ├── logo.png
│           │   └── logo.svg
│           ├── favicon.ico
│           ├── feature_1.svg
│           ├── feature_2.svg
│           ├── feature_3.svg
│           ├── product_1.svg
│           ├── product_2.svg
│           └── product_3.svg
├── examples
│   └── integration_example.py
├── jupyter_mcp_server
│   ├── __init__.py
│   ├── __main__.py
│   ├── __version__.py
│   ├── config.py
│   ├── enroll.py
│   ├── env.py
│   ├── jupyter_extension
│   │   ├── __init__.py
│   │   ├── backends
│   │   │   ├── __init__.py
│   │   │   ├── base.py
│   │   │   ├── local_backend.py
│   │   │   └── remote_backend.py
│   │   ├── context.py
│   │   ├── extension.py
│   │   ├── handlers.py
│   │   └── protocol
│   │       ├── __init__.py
│   │       └── messages.py
│   ├── models.py
│   ├── notebook_manager.py
│   ├── server_modes.py
│   ├── server.py
│   ├── tools
│   │   ├── __init__.py
│   │   ├── _base.py
│   │   ├── _registry.py
│   │   ├── assign_kernel_to_notebook_tool.py
│   │   ├── delete_cell_tool.py
│   │   ├── execute_cell_tool.py
│   │   ├── execute_ipython_tool.py
│   │   ├── insert_cell_tool.py
│   │   ├── insert_execute_code_cell_tool.py
│   │   ├── list_cells_tool.py
│   │   ├── list_files_tool.py
│   │   ├── list_kernels_tool.py
│   │   ├── list_notebooks_tool.py
│   │   ├── overwrite_cell_source_tool.py
│   │   ├── read_cell_tool.py
│   │   ├── read_cells_tool.py
│   │   ├── restart_notebook_tool.py
│   │   ├── unuse_notebook_tool.py
│   │   └── use_notebook_tool.py
│   └── utils.py
├── jupyter-config
│   ├── jupyter_notebook_config
│   │   └── jupyter_mcp_server.json
│   └── jupyter_server_config.d
│       └── jupyter_mcp_server.json
├── LICENSE
├── Makefile
├── pyproject.toml
├── pytest.ini
├── README.md
├── RELEASE.md
├── smithery.yaml
└── tests
    ├── __init__.py
    ├── conftest.py
    ├── test_common.py
    ├── test_config.py
    ├── test_jupyter_extension.py
    ├── test_list_kernels.py
    ├── test_tools.py
    └── test_use_notebook.py
```

# Files

--------------------------------------------------------------------------------
/jupyter_mcp_server/notebook_manager.py:
--------------------------------------------------------------------------------

```python
  1 | # Copyright (c) 2023-2024 Datalayer, Inc.
  2 | #
  3 | # BSD 3-Clause License
  4 | 
  5 | """
  6 | Unified Notebook and Kernel Management Module
  7 | 
  8 | This module provides centralized management for Jupyter notebooks and kernels,
  9 | replacing the scattered global variable approach with a unified architecture.
 10 | """
 11 | 
 12 | from typing import Dict, Any, Optional, Callable, Union
 13 | from types import TracebackType
 14 | 
 15 | from jupyter_nbmodel_client import NbModelClient, get_notebook_websocket_url
 16 | from jupyter_kernel_client import KernelClient
 17 | 
 18 | from .config import get_config
 19 | 
 20 | 
 21 | class NotebookConnection:
 22 |     """
 23 |     Context manager for Notebook connections that handles the lifecycle
 24 |     of NbModelClient instances.
 25 |     
 26 |     Note: This is only used in MCP_SERVER mode with remote Jupyter servers that have RTC enabled.
 27 |     In JUPYTER_SERVER mode (local), notebook content is accessed directly via contents_manager.
 28 |     """
 29 |     
 30 |     def __init__(self, notebook_info: Dict[str, str], is_local: bool = False):
 31 |         self.notebook_info = notebook_info
 32 |         self.is_local = is_local
 33 |         self._notebook: Optional[NbModelClient] = None
 34 |     
 35 |     async def __aenter__(self) -> NbModelClient:
 36 |         """Enter context, establish notebook connection."""
 37 |         if self.is_local:
 38 |             raise ValueError(
 39 |                 "NotebookConnection cannot be used in local/JUPYTER_SERVER mode. "
 40 |                 "Cell operations in local mode should use contents_manager directly to read notebook JSON files."
 41 |             )
 42 |         
 43 |         config = get_config()
 44 |         ws_url = get_notebook_websocket_url(
 45 |             server_url=self.notebook_info.get("server_url", config.document_url),
 46 |             token=self.notebook_info.get("token", config.document_token),
 47 |             path=self.notebook_info.get("path", config.document_id),
 48 |             provider=config.provider
 49 |         )
 50 |         self._notebook = NbModelClient(ws_url)
 51 |         await self._notebook.__aenter__()
 52 |         return self._notebook
 53 |     
 54 |     async def __aexit__(
 55 |         self, 
 56 |         exc_type: Optional[type], 
 57 |         exc_val: Optional[BaseException], 
 58 |         exc_tb: Optional[TracebackType]
 59 |     ) -> None:
 60 |         """Exit context, clean up connection."""
 61 |         if self._notebook:
 62 |             await self._notebook.__aexit__(exc_type, exc_val, exc_tb)
 63 | 
 64 | 
 65 | class NotebookManager:
 66 |     """
 67 |     Centralized manager for multiple notebooks and their corresponding kernels.
 68 |     
 69 |     This class replaces the global kernel variable approach with a unified
 70 |     management system that supports both single and multiple notebook scenarios.
 71 |     """
 72 |     
 73 |     def __init__(self):
 74 |         self._notebooks: Dict[str, Dict[str, Any]] = {}
 75 |         self._default_notebook_name = "default"
 76 |         self._current_notebook: Optional[str] = None  # Currently active notebook
 77 |     
 78 |     def __contains__(self, name: str) -> bool:
 79 |         """Check if a notebook is managed by this instance."""
 80 |         return name in self._notebooks
 81 |     
 82 |     def __iter__(self):
 83 |         """Iterate over notebook name, info pairs."""
 84 |         return iter(self._notebooks.items())
 85 |     
 86 |     def add_notebook(
 87 |         self, 
 88 |         name: str, 
 89 |         kernel: Union[KernelClient, Dict[str, Any]],  # Can be KernelClient or dict with kernel metadata
 90 |         server_url: Optional[str] = None,
 91 |         token: Optional[str] = None,
 92 |         path: Optional[str] = None
 93 |     ) -> None:
 94 |         """
 95 |         Add a notebook to the manager.
 96 |         
 97 |         Args:
 98 |             name: Unique identifier for the notebook
 99 |             kernel: Kernel client instance (MCP_SERVER mode) or kernel metadata dict (JUPYTER_SERVER mode)
100 |             server_url: Jupyter server URL (optional, uses config default). Use "local" for JUPYTER_SERVER mode.
101 |             token: Authentication token (optional, uses config default)
102 |             path: Notebook file path (optional, uses config default)
103 |         """
104 |         config = get_config()
105 |         
106 |         # Determine if this is local (JUPYTER_SERVER) mode or HTTP (MCP_SERVER) mode
107 |         is_local_mode = server_url == "local"
108 |         
109 |         self._notebooks[name] = {
110 |             "kernel": kernel,
111 |             "is_local": is_local_mode,
112 |             "notebook_info": {
113 |                 "server_url": server_url or config.document_url,
114 |                 "token": token or config.document_token,
115 |                 "path": path or config.document_id
116 |             }
117 |         }
118 |         
119 |         # For backward compatibility: if this is the first notebook or it's "default",
120 |         # set it as the current notebook
121 |         if self._current_notebook is None or name == self._default_notebook_name:
122 |             self._current_notebook = name
123 |     
124 |     def remove_notebook(self, name: str) -> bool:
125 |         """
126 |         Remove a notebook from the manager.
127 |         
128 |         Args:
129 |             name: Notebook identifier
130 |             
131 |         Returns:
132 |             True if removed successfully, False if not found
133 |         """
134 |         if name in self._notebooks:
135 |             try:
136 |                 notebook_data = self._notebooks[name]
137 |                 is_local = notebook_data.get("is_local", False)
138 |                 kernel = notebook_data["kernel"]
139 |                 
140 |                 # Only stop kernel if it's an HTTP KernelClient (MCP_SERVER mode)
141 |                 # In JUPYTER_SERVER mode, kernel is just metadata, actual kernel managed elsewhere
142 |                 if not is_local and kernel and hasattr(kernel, 'stop'):
143 |                     kernel.stop()
144 |             except Exception:
145 |                 # Ignore errors during kernel cleanup
146 |                 pass
147 |             finally:
148 |                 del self._notebooks[name]
149 |                 
150 |                 # If we removed the current notebook, update the current pointer
151 |                 if self._current_notebook == name:
152 |                     # Set to another notebook if available, prefer "default" for compatibility
153 |                     if self._default_notebook_name in self._notebooks:
154 |                         self._current_notebook = self._default_notebook_name
155 |                     elif self._notebooks:
156 |                         # Set to the first available notebook
157 |                         self._current_notebook = next(iter(self._notebooks.keys()))
158 |                     else:
159 |                         # No notebooks left
160 |                         self._current_notebook = None
161 |             return True
162 |         return False
163 |     
164 |     def get_kernel(self, name: str) -> Optional[Union[KernelClient, Dict[str, Any]]]:
165 |         """
166 |         Get the kernel for a specific notebook.
167 |         
168 |         Args:
169 |             name: Notebook identifier
170 |             
171 |         Returns:
172 |             Kernel client (MCP_SERVER mode) or kernel metadata dict (JUPYTER_SERVER mode), or None if not found
173 |         """
174 |         if name in self._notebooks:
175 |             return self._notebooks[name]["kernel"]
176 |         return None
177 |     
178 |     def get_kernel_id(self, name: str) -> Optional[str]:
179 |         """
180 |         Get the kernel ID for a specific notebook.
181 |         
182 |         Args:
183 |             name: Notebook identifier
184 |             
185 |         Returns:
186 |             Kernel ID string or None if not found
187 |         """
188 |         if name in self._notebooks:
189 |             kernel = self._notebooks[name]["kernel"]
190 |             # Handle both KernelClient objects and kernel metadata dicts
191 |             if isinstance(kernel, dict):
192 |                 return kernel.get("id")
193 |             elif hasattr(kernel, 'kernel_id'):
194 |                 return kernel.kernel_id
195 |         return None
196 |     
197 |     def is_local_notebook(self, name: str) -> bool:
198 |         """
199 |         Check if a notebook is using local (JUPYTER_SERVER) mode.
200 |         
201 |         Args:
202 |             name: Notebook identifier
203 |             
204 |         Returns:
205 |             True if local mode, False otherwise
206 |         """
207 |         if name in self._notebooks:
208 |             return self._notebooks[name].get("is_local", False)
209 |         return False
210 |     
211 |     def get_notebook_connection(self, name: str) -> NotebookConnection:
212 |         """
213 |         Get a context manager for notebook connection.
214 |         
215 |         Args:
216 |             name: Notebook identifier
217 |             
218 |         Returns:
219 |             NotebookConnection context manager
220 |             
221 |         Raises:
222 |             ValueError: If notebook doesn't exist
223 |         """
224 |         if name not in self._notebooks:
225 |             raise ValueError(f"Notebook '{name}' does not exist in manager")
226 |         
227 |         return NotebookConnection(self._notebooks[name]["notebook_info"])
228 |     
229 |     def restart_notebook(self, name: str) -> bool:
230 |         """
231 |         Restart the kernel for a specific notebook.
232 |         
233 |         Args:
234 |             name: Notebook identifier
235 |             
236 |         Returns:
237 |             True if restarted successfully, False otherwise
238 |         """
239 |         if name in self._notebooks:
240 |             try:
241 |                 kernel = self._notebooks[name]["kernel"]
242 |                 if kernel and hasattr(kernel, 'restart'):
243 |                     kernel.restart()
244 |                 return True
245 |             except Exception:
246 |                 return False
247 |         return False
248 |     
249 |     def is_empty(self) -> bool:
250 |         """Check if the manager is empty (no notebooks)."""
251 |         return len(self._notebooks) == 0
252 |     
253 |     def ensure_kernel_alive(self, name: str, kernel_factory: Callable[[], KernelClient]) -> KernelClient:
254 |         """
255 |         Ensure a kernel is alive, create if necessary.
256 |         
257 |         Args:
258 |             name: Notebook identifier
259 |             kernel_factory: Function to create a new kernel
260 |             
261 |         Returns:
262 |             The alive kernel instance
263 |         """
264 |         kernel = self.get_kernel(name)
265 |         if kernel is None or not hasattr(kernel, 'is_alive') or not kernel.is_alive():
266 |             # Create new kernel
267 |             new_kernel = kernel_factory()
268 |             self.add_notebook(name, new_kernel)
269 |             return new_kernel
270 |         return kernel
271 |     
272 |     def set_current_notebook(self, name: str) -> bool:
273 |         """
274 |         Set the currently active notebook.
275 |         
276 |         Args:
277 |             name: Notebook identifier
278 |             
279 |         Returns:
280 |             True if set successfully, False if notebook doesn't exist
281 |         """
282 |         if name in self._notebooks:
283 |             self._current_notebook = name
284 |             return True
285 |         return False
286 |     
287 |     def get_current_notebook(self) -> Optional[str]:
288 |         """
289 |         Get the name of the currently active notebook.
290 |         
291 |         Returns:
292 |             Current notebook name or None if no active notebook
293 |         """
294 |         return self._current_notebook
295 |     
296 |     def get_current_connection(self) -> NotebookConnection:
297 |         """
298 |         Get the connection for the currently active notebook.
299 |         For backward compatibility, defaults to "default" if no current notebook is set.
300 |         
301 |         Returns:
302 |             NotebookConnection context manager for the current notebook
303 |             
304 |         Raises:
305 |             ValueError: If no notebooks exist and no default config is available
306 |         """
307 |         current = self._current_notebook or self._default_notebook_name
308 |         
309 |         # For backward compatibility: if the requested notebook doesn't exist but we're 
310 |         # asking for default, create a connection using the default config
311 |         if current not in self._notebooks and current == self._default_notebook_name:
312 |             # Return a connection using default configuration
313 |             config = get_config()
314 |             return NotebookConnection({
315 |                 "server_url": config.document_url,
316 |                 "token": config.document_token,
317 |                 "path": config.document_id
318 |             })
319 |         
320 |         return self.get_notebook_connection(current)
321 |     
322 |     def get_current_notebook_path(self) -> Optional[str]:
323 |         """
324 |         Get the file path of the currently active notebook.
325 |         
326 |         Returns:
327 |             Notebook file path or None if no active notebook
328 |         """
329 |         current = self._current_notebook or self._default_notebook_name
330 |         if current in self._notebooks:
331 |             return self._notebooks[current]["notebook_info"].get("path")
332 |         return None
333 |     
334 |     def list_all_notebooks(self) -> Dict[str, Dict[str, Any]]:
335 |         """
336 |         Get information about all managed notebooks.
337 |         
338 |         Returns:
339 |             Dictionary with notebook names as keys and their info as values
340 |         """
341 |         result = {}
342 |         for name, notebook_data in self._notebooks.items():
343 |             kernel = notebook_data["kernel"]
344 |             notebook_info = notebook_data["notebook_info"]
345 |             
346 |             # Check kernel status
347 |             kernel_status = "unknown"
348 |             if kernel:
349 |                 try:
350 |                     kernel_status = "alive" if hasattr(kernel, 'is_alive') and kernel.is_alive() else "dead"
351 |                 except Exception:
352 |                     kernel_status = "error"
353 |             else:
354 |                 kernel_status = "not_initialized"
355 |             
356 |             result[name] = {
357 |                 "path": notebook_info.get("path", ""),
358 |                 "kernel_status": kernel_status,
359 |                 "is_current": name == self._current_notebook
360 |             }
361 |         
362 |         return result
363 | 
```

--------------------------------------------------------------------------------
/jupyter_mcp_server/tools/use_notebook_tool.py:
--------------------------------------------------------------------------------

```python
  1 | # Copyright (c) 2023-2024 Datalayer, Inc.
  2 | #
  3 | # BSD 3-Clause License
  4 | 
  5 | """Use notebook tool implementation."""
  6 | 
  7 | import logging
  8 | from typing import Any, Optional, Literal
  9 | from pathlib import Path
 10 | from jupyter_server_api import JupyterServerClient, NotFoundError
 11 | from jupyter_kernel_client import KernelClient
 12 | from jupyter_mcp_server.tools._base import BaseTool, ServerMode
 13 | from jupyter_mcp_server.notebook_manager import NotebookManager
 14 | 
 15 | logger = logging.getLogger(__name__)
 16 | 
 17 | 
 18 | class UseNotebookTool(BaseTool):
 19 |     """Tool to use (connect to or create) a notebook file."""
 20 |     
 21 |     @property
 22 |     def name(self) -> str:
 23 |         return "use_notebook"
 24 |     
 25 |     @property
 26 |     def description(self) -> str:
 27 |         return """Use a notebook file (connect to existing, create new, or switch to already-connected notebook).
 28 |     
 29 | Args:
 30 |     notebook_name: Unique identifier for the notebook
 31 |     notebook_path: Path to the notebook file, relative to the Jupyter server root (e.g. "notebook.ipynb").
 32 |                   Optional - if not provided, switches to an already-connected notebook with the given name.
 33 |     mode: "connect" to connect to existing, "create" to create new
 34 |     kernel_id: Specific kernel ID to use (optional, will create new if not provided)
 35 |     
 36 | Returns:
 37 |     str: Success message with notebook information"""
 38 |     
 39 |     async def _check_path_http(
 40 |         self, 
 41 |         server_client: JupyterServerClient, 
 42 |         notebook_path: str, 
 43 |         mode: str
 44 |     ) -> tuple[bool, Optional[str]]:
 45 |         """Check if path exists using HTTP API."""
 46 |         path = Path(notebook_path)
 47 |         try:
 48 |             parent_path = path.parent.as_posix() if path.parent.as_posix() != "." else ""
 49 |             
 50 |             if parent_path:
 51 |                 dir_contents = server_client.contents.list_directory(parent_path)
 52 |             else:
 53 |                 dir_contents = server_client.contents.list_directory("")
 54 |                 
 55 |             if mode == "connect":
 56 |                 file_exists = any(file.name == path.name for file in dir_contents)
 57 |                 if not file_exists:
 58 |                     return False, f"'{notebook_path}' not found in jupyter server, please check the notebook already exists."
 59 |             
 60 |             return True, None
 61 |         except NotFoundError:
 62 |             parent_dir = path.parent.as_posix() if path.parent.as_posix() != "." else "root directory"
 63 |             return False, f"'{parent_dir}' not found in jupyter server, please check the directory path already exists."
 64 |         except Exception as e:
 65 |             return False, f"Failed to check the path '{notebook_path}': {e}"
 66 |     
 67 |     async def _check_path_local(
 68 |         self,
 69 |         contents_manager: Any,
 70 |         notebook_path: str,
 71 |         mode: str
 72 |     ) -> tuple[bool, Optional[str]]:
 73 |         """Check if path exists using local contents_manager API."""
 74 |         path = Path(notebook_path)
 75 |         try:
 76 |             parent_path = str(path.parent) if str(path.parent) != "." else ""
 77 |             
 78 |             # Get directory contents using local API
 79 |             model = await contents_manager.get(parent_path, content=True, type='directory')
 80 |             
 81 |             if mode == "connect":
 82 |                 file_exists = any(item['name'] == path.name for item in model.get('content', []))
 83 |                 if not file_exists:
 84 |                     return False, f"'{notebook_path}' not found in jupyter server, please check the notebook already exists."
 85 |             
 86 |             return True, None
 87 |         except Exception as e:
 88 |             parent_dir = str(path.parent) if str(path.parent) != "." else "root directory"
 89 |             return False, f"'{parent_dir}' not found in jupyter server: {e}"
 90 |     
 91 |     async def execute(
 92 |         self,
 93 |         mode: ServerMode,
 94 |         server_client: Optional[JupyterServerClient] = None,
 95 |         kernel_client: Optional[Any] = None,
 96 |         contents_manager: Optional[Any] = None,
 97 |         kernel_manager: Optional[Any] = None,
 98 |         kernel_spec_manager: Optional[Any] = None,
 99 |         session_manager: Optional[Any] = None,
100 |         notebook_manager: Optional[NotebookManager] = None,
101 |         # Tool-specific parameters
102 |         notebook_name: str = None,
103 |         notebook_path: Optional[str] = None,
104 |         use_mode: Literal["connect", "create"] = "connect",
105 |         kernel_id: Optional[str] = None,
106 |         runtime_url: Optional[str] = None,
107 |         runtime_token: Optional[str] = None,
108 |         **kwargs
109 |     ) -> str:
110 |         """Execute the use_notebook tool.
111 |         
112 |         Args:
113 |             mode: Server mode (MCP_SERVER or JUPYTER_SERVER)
114 |             server_client: HTTP client for MCP_SERVER mode
115 |             contents_manager: Direct API access for JUPYTER_SERVER mode
116 |             kernel_manager: Direct kernel manager for JUPYTER_SERVER mode
117 |             session_manager: Session manager for creating kernel-notebook associations
118 |             notebook_manager: Notebook manager instance
119 |             notebook_name: Unique identifier for the notebook
120 |             notebook_path: Path to the notebook file (optional, if not provided switches to existing notebook)
121 |             use_mode: "connect" or "create"
122 |             kernel_id: Optional specific kernel ID
123 |             runtime_url: Runtime URL for HTTP mode
124 |             runtime_token: Runtime token for HTTP mode
125 |             **kwargs: Additional parameters
126 |             
127 |         Returns:
128 |             Success message with notebook information
129 |         """
130 |         # If no notebook_path provided, switch to already-connected notebook
131 |         if notebook_path is None:
132 |             if notebook_name not in notebook_manager:
133 |                 return f"Notebook '{notebook_name}' is not connected. Please provide a notebook_path to connect to it first."
134 |             
135 |             # Switch to the existing notebook
136 |             notebook_manager.set_current_notebook(notebook_name)
137 |             return f"Successfully switched to notebook '{notebook_name}'."
138 |         
139 |         # Rest of the logic for connecting/creating new notebooks
140 |         if notebook_name in notebook_manager:
141 |             return f"Notebook '{notebook_name}' is already using. Use unuse_notebook first if you want to reconnect."
142 |         
143 |         # Check server connectivity (HTTP mode only)
144 |         if mode == ServerMode.MCP_SERVER and server_client is not None:
145 |             try:
146 |                 server_client.get_status()
147 |             except Exception as e:
148 |                 return f"Failed to connect the Jupyter server: {e}"
149 |         
150 |         # Check the path exists
151 |         if mode == ServerMode.JUPYTER_SERVER and contents_manager is not None:
152 |             path_ok, error_msg = await self._check_path_local(contents_manager, notebook_path, use_mode)
153 |         elif mode == ServerMode.MCP_SERVER and server_client is not None:
154 |             path_ok, error_msg = await self._check_path_http(server_client, notebook_path, use_mode)
155 |         else:
156 |             return f"Invalid mode or missing required clients: mode={mode}"
157 |         
158 |         if not path_ok:
159 |             return error_msg
160 |         
161 |         # Check kernel if kernel_id provided (HTTP mode only for now)
162 |         if kernel_id and mode == ServerMode.MCP_SERVER and server_client is not None:
163 |             kernels = server_client.kernels.list_kernels()
164 |             kernel_exists = any(kernel.id == kernel_id for kernel in kernels)
165 |             if not kernel_exists:
166 |                 return f"Kernel '{kernel_id}' not found in jupyter server, please check the kernel already exists."
167 |         
168 |         # Create notebook if needed
169 |         if use_mode == "create":
170 |             content = {
171 |                 "cells": [{
172 |                     "cell_type": "markdown",
173 |                     "metadata": {},
174 |                     "source": [
175 |                         "New Notebook Created by Jupyter MCP Server",
176 |                     ]
177 |                 }],
178 |                 "metadata": {},
179 |                 "nbformat": 4,
180 |                 "nbformat_minor": 4
181 |             }
182 |             if mode == ServerMode.JUPYTER_SERVER and contents_manager is not None:
183 |                 # Use local API to create notebook
184 |                 await contents_manager.new(model={'type': 'notebook'}, path=notebook_path)
185 |             elif mode == ServerMode.MCP_SERVER and server_client is not None:
186 |                 server_client.contents.create_notebook(notebook_path, content=content)
187 |         
188 |         # Create/connect to kernel based on mode
189 |         if mode == ServerMode.JUPYTER_SERVER and kernel_manager is not None:
190 |             # JUPYTER_SERVER mode: Use local kernel manager API directly
191 |             if kernel_id:
192 |                 # Connect to existing kernel - verify it exists
193 |                 if kernel_id not in kernel_manager:
194 |                     return f"Kernel '{kernel_id}' not found in local kernel manager."
195 |                 kernel_info = {"id": kernel_id}
196 |             else:
197 |                 # Start a new kernel using local API
198 |                 kernel_id = await kernel_manager.start_kernel()
199 |                 logger.info(f"Started kernel '{kernel_id}', waiting for it to be ready...")
200 |                 
201 |                 # CRITICAL: Wait for the kernel to actually start and be ready
202 |                 # The start_kernel() call returns immediately, but kernel takes time to start
203 |                 import asyncio
204 |                 max_wait_time = 30  # seconds
205 |                 wait_interval = 0.5  # seconds
206 |                 elapsed = 0
207 |                 kernel_ready = False
208 |                 
209 |                 while elapsed < max_wait_time:
210 |                     try:
211 |                         # Get kernel model to check its state
212 |                         kernel_model = kernel_manager.get_kernel(kernel_id)
213 |                         if kernel_model is not None:
214 |                             # Kernel exists, check if it's ready
215 |                             # In Jupyter, we can try to get connection info which indicates readiness
216 |                             try:
217 |                                 kernel_manager.get_connection_info(kernel_id)
218 |                                 kernel_ready = True
219 |                                 logger.info(f"Kernel '{kernel_id}' is ready (took {elapsed:.1f}s)")
220 |                                 break
221 |                             except:
222 |                                 # Connection info not available yet, kernel still starting
223 |                                 pass
224 |                     except Exception as e:
225 |                         logger.debug(f"Waiting for kernel to start: {e}")
226 |                     
227 |                     await asyncio.sleep(wait_interval)
228 |                     elapsed += wait_interval
229 |                 
230 |                 if not kernel_ready:
231 |                     logger.warning(f"Kernel '{kernel_id}' may not be fully ready after {max_wait_time}s wait")
232 |                 
233 |                 kernel_info = {"id": kernel_id}
234 |             
235 |             # Create a Jupyter session to associate the kernel with the notebook
236 |             # This is CRITICAL for JupyterLab to recognize the kernel-notebook connection
237 |             if session_manager is not None:
238 |                 try:
239 |                     # create_session is an async method, so we await it directly
240 |                     session_dict = await session_manager.create_session(
241 |                         path=notebook_path,
242 |                         kernel_id=kernel_id,
243 |                         type="notebook",
244 |                         name=notebook_path
245 |                     )
246 |                     logger.info(f"Created Jupyter session '{session_dict.get('id')}' for notebook '{notebook_path}' with kernel '{kernel_id}'")
247 |                 except Exception as e:
248 |                     logger.warning(f"Failed to create Jupyter session: {e}. Notebook may not be properly connected in JupyterLab UI.")
249 |             else:
250 |                 logger.warning("No session_manager available. Notebook may not be properly connected in JupyterLab UI.")
251 |             
252 |             # For JUPYTER_SERVER mode, store kernel info (not KernelClient object)
253 |             # The actual kernel is managed by kernel_manager
254 |             notebook_manager.add_notebook(
255 |                 notebook_name,
256 |                 kernel_info,  # Store kernel metadata, not client object
257 |                 server_url="local",  # Indicate local mode
258 |                 token=None,
259 |                 path=notebook_path
260 |             )
261 |         elif mode == ServerMode.MCP_SERVER and runtime_url:
262 |             # MCP_SERVER mode: Use HTTP-based kernel client
263 |             kernel = KernelClient(
264 |                 server_url=runtime_url,
265 |                 token=runtime_token,
266 |                 kernel_id=kernel_id
267 |             )
268 |             kernel.start()
269 |             
270 |             # Add notebook to manager with HTTP client
271 |             notebook_manager.add_notebook(
272 |                 notebook_name,
273 |                 kernel,
274 |                 server_url=runtime_url,
275 |                 token=runtime_token,
276 |                 path=notebook_path
277 |             )
278 |         else:
279 |             return f"Invalid configuration: mode={mode}, runtime_url={runtime_url}, kernel_manager={kernel_manager is not None}"
280 |         
281 |         notebook_manager.set_current_notebook(notebook_name)
282 |         
283 |         # Return message based on mode
284 |         if use_mode == "create":
285 |             return f"Successfully created and using notebook '{notebook_name}' at path '{notebook_path}' in {mode.value} mode."
286 |         else:
287 |             return f"Successfully using notebook '{notebook_name}' at path '{notebook_path}' in {mode.value} mode."
288 | 
```

--------------------------------------------------------------------------------
/jupyter_mcp_server/tools/insert_execute_code_cell_tool.py:
--------------------------------------------------------------------------------

```python
  1 | # Copyright (c) 2023-2024 Datalayer, Inc.
  2 | #
  3 | # BSD 3-Clause License
  4 | 
  5 | """Insert and execute code cell tool implementation."""
  6 | 
  7 | import asyncio
  8 | import logging
  9 | from pathlib import Path
 10 | from typing import Any, Optional, List, Union
 11 | from jupyter_server_api import JupyterServerClient
 12 | from jupyter_mcp_server.tools._base import BaseTool, ServerMode
 13 | from jupyter_mcp_server.notebook_manager import NotebookManager
 14 | from jupyter_mcp_server.utils import get_current_notebook_context, safe_extract_outputs, execute_via_execution_stack
 15 | from mcp.types import ImageContent
 16 | 
 17 | logger = logging.getLogger(__name__)
 18 | 
 19 | 
 20 | class InsertExecuteCodeCellTool(BaseTool):
 21 |     """Tool to insert and execute a code cell."""
 22 |     
 23 |     @property
 24 |     def name(self) -> str:
 25 |         return "insert_execute_code_cell"
 26 |     
 27 |     @property
 28 |     def description(self) -> str:
 29 |         return """Insert and execute a code cell in a Jupyter notebook.
 30 | 
 31 | Args:
 32 |     cell_index: Index of the cell to insert (0-based). Use -1 to append at end and execute.
 33 |     cell_source: Code source
 34 | 
 35 | Returns:
 36 |     list[Union[str, ImageContent]]: List of outputs from the executed cell"""
 37 |     
 38 |     async def _get_jupyter_ydoc(self, serverapp: Any, file_id: str):
 39 |         """Get the YNotebook document if it's currently open in a collaborative session."""
 40 |         try:
 41 |             yroom_manager = serverapp.web_app.settings.get("yroom_manager")
 42 |             if yroom_manager is None:
 43 |                 return None
 44 |                 
 45 |             room_id = f"json:notebook:{file_id}"
 46 |             
 47 |             if yroom_manager.has_room(room_id):
 48 |                 yroom = yroom_manager.get_room(room_id)
 49 |                 notebook = await yroom.get_jupyter_ydoc()
 50 |                 return notebook
 51 |         except Exception:
 52 |             pass
 53 |         
 54 |         return None
 55 |     
 56 |     async def _insert_execute_ydoc(
 57 |         self,
 58 |         serverapp: Any,
 59 |         notebook_path: str,
 60 |         cell_index: int,
 61 |         cell_source: str,
 62 |         kernel_manager,
 63 |         kernel_id: str,
 64 |         safe_extract_outputs_fn
 65 |     ) -> List[Union[str, ImageContent]]:
 66 |         """Insert and execute cell using YDoc (collaborative editing mode)."""
 67 |         # Get file_id from file_id_manager
 68 |         file_id_manager = serverapp.web_app.settings.get("file_id_manager")
 69 |         if file_id_manager is None:
 70 |             raise RuntimeError("file_id_manager not available in serverapp")
 71 |         
 72 |         file_id = file_id_manager.get_id(notebook_path)
 73 |         
 74 |         # Try to get YDoc
 75 |         ydoc = await self._get_jupyter_ydoc(serverapp, file_id)
 76 |         
 77 |         if ydoc:
 78 |             # Notebook is open in collaborative mode, use YDoc
 79 |             total_cells = len(ydoc.ycells)
 80 |             actual_index = cell_index if cell_index != -1 else total_cells
 81 |             
 82 |             if actual_index < 0 or actual_index > total_cells:
 83 |                 raise ValueError(
 84 |                     f"Cell index {cell_index} is out of range. Notebook has {total_cells} cells. Use -1 to append at end."
 85 |                 )
 86 |             
 87 |             # Create and insert the cell
 88 |             cell = {
 89 |                 "cell_type": "code",
 90 |                 "source": cell_source,
 91 |             }
 92 |             ycell = ydoc.create_ycell(cell)
 93 |             
 94 |             if actual_index >= total_cells:
 95 |                 ydoc.ycells.append(ycell)
 96 |             else:
 97 |                 ydoc.ycells.insert(actual_index, ycell)
 98 |             
 99 |             # Get the inserted cell's ID for RTC metadata
100 |             inserted_cell_id = ycell.get("id")
101 |             
102 |             # Build document_id for RTC (format: json:notebook:<file_id>)
103 |             document_id = f"json:notebook:{file_id}"
104 |             
105 |             # Execute the cell using ExecutionStack with RTC metadata
106 |             # This will automatically update the cell outputs in the YDoc
107 |             return await execute_via_execution_stack(
108 |                 serverapp, kernel_id, cell_source, 
109 |                 document_id=document_id, 
110 |                 cell_id=inserted_cell_id,
111 |                 timeout=300,
112 |                 logger=logger
113 |             )
114 |         else:
115 |             # YDoc not available - use file operations + direct kernel execution
116 |             # This path is used when notebook is not open in JupyterLab but we still have kernel access
117 |             logger.info("YDoc not available, using file operations + ExecutionStack execution fallback")
118 |             
119 |             # Insert cell using file operations
120 |             from jupyter_mcp_server.tools.insert_cell_tool import InsertCellTool
121 |             insert_tool = InsertCellTool()
122 |             
123 |             # Call the file-based insertion method directly
124 |             await insert_tool._insert_cell_file(notebook_path, cell_index, "code", cell_source)
125 |             
126 |             # Calculate actual index where cell was inserted
127 |             import nbformat
128 |             with open(notebook_path, 'r', encoding='utf-8') as f:
129 |                 notebook = nbformat.read(f, as_version=4)
130 |             total_cells = len(notebook.cells)
131 |             actual_index = cell_index if cell_index != -1 else total_cells - 1
132 |             
133 |             # Then execute directly via ExecutionStack (without RTC metadata since notebook not open)
134 |             outputs = await execute_via_execution_stack(
135 |                 serverapp, kernel_id, cell_source, timeout=300, logger=logger
136 |             )
137 |             
138 |             # CRITICAL: Write outputs back to the notebook file so they're visible in UI
139 |             logger.info(f"Writing {len(outputs)} outputs back to notebook cell {actual_index}")
140 |             await self._write_outputs_to_cell(notebook_path, actual_index, outputs)
141 |             
142 |             return outputs
143 |     
144 |     async def _insert_execute_websocket(
145 |         self,
146 |         notebook_manager: NotebookManager,
147 |         cell_index: int,
148 |         cell_source: str,
149 |         ensure_kernel_alive
150 |     ) -> List[Union[str, ImageContent]]:
151 |         """Insert and execute cell using WebSocket connection (MCP_SERVER mode)."""
152 |         # Ensure kernel is alive
153 |         if ensure_kernel_alive:
154 |             kernel = ensure_kernel_alive()
155 |         else:
156 |             # Fallback: get kernel from notebook_manager
157 |             current_notebook = notebook_manager.get_current_notebook() or "default"
158 |             kernel = notebook_manager.get_kernel(current_notebook)
159 |             if not kernel:
160 |                 raise RuntimeError("No kernel available for execution")
161 |         
162 |         async with notebook_manager.get_current_connection() as notebook:
163 |             actual_index = cell_index if cell_index != -1 else len(notebook)
164 |             
165 |             if actual_index < 0 or actual_index > len(notebook):
166 |                 raise ValueError(f"Cell index {cell_index} out of range")
167 |             
168 |             notebook.insert_cell(actual_index, cell_source, "code")
169 |             notebook.execute_cell(actual_index, kernel)
170 | 
171 |             outputs = notebook[actual_index].get("outputs", [])
172 |             return safe_extract_outputs(outputs)
173 |     
174 |     async def _write_outputs_to_cell(
175 |         self,
176 |         notebook_path: str,
177 |         cell_index: int,
178 |         outputs: List[Union[str, ImageContent]]
179 |     ):
180 |         """Write execution outputs back to a notebook cell.
181 |         
182 |         This is critical for making outputs visible in JupyterLab when using
183 |         file-based execution (when YDoc/RTC is not available).
184 |         
185 |         Args:
186 |             notebook_path: Path to the notebook file
187 |             cell_index: Index of the cell to update
188 |             outputs: List of output strings or ImageContent objects
189 |         """
190 |         import nbformat
191 |         from jupyter_mcp_server.utils import _clean_notebook_outputs
192 |         
193 |         # Read the notebook
194 |         with open(notebook_path, 'r', encoding='utf-8') as f:
195 |             notebook = nbformat.read(f, as_version=4)
196 |         
197 |         # Clean any transient fields
198 |         _clean_notebook_outputs(notebook)
199 |         
200 |         if cell_index < 0 or cell_index >= len(notebook.cells):
201 |             logger.warning(f"Cell index {cell_index} out of range, cannot write outputs")
202 |             return
203 |         
204 |         cell = notebook.cells[cell_index]
205 |         if cell.cell_type != 'code':
206 |             logger.warning(f"Cell {cell_index} is not a code cell, cannot write outputs")
207 |             return
208 |         
209 |         # Convert formatted outputs to nbformat structure
210 |         cell.outputs = []
211 |         for output in outputs:
212 |             if isinstance(output, ImageContent):
213 |                 # Image output
214 |                 cell.outputs.append(nbformat.v4.new_output(
215 |                     output_type='display_data',
216 |                     data={output.mimeType: output.data},
217 |                     metadata={}
218 |                 ))
219 |             elif isinstance(output, str):
220 |                 # Text output - determine if it's an error or regular output
221 |                 if output.startswith('[ERROR:') or output.startswith('[TIMEOUT ERROR:'):
222 |                     # Error output
223 |                     cell.outputs.append(nbformat.v4.new_output(
224 |                         output_type='stream',
225 |                         name='stderr',
226 |                         text=output
227 |                     ))
228 |                 else:
229 |                     # Regular output (assume execute_result for simplicity)
230 |                     cell.outputs.append(nbformat.v4.new_output(
231 |                         output_type='execute_result',
232 |                         data={'text/plain': output},
233 |                         metadata={},
234 |                         execution_count=None
235 |                     ))
236 |         
237 |         # Update execution count
238 |         max_count = 0
239 |         for c in notebook.cells:
240 |             if c.cell_type == 'code' and c.execution_count:
241 |                 max_count = max(max_count, c.execution_count)
242 |         cell.execution_count = max_count + 1
243 |         
244 |         # Write back to file
245 |         with open(notebook_path, 'w', encoding='utf-8') as f:
246 |             nbformat.write(notebook, f)
247 |         
248 |         logger.info(f"Wrote {len(outputs)} outputs to cell {cell_index} in {notebook_path}")
249 |     
250 |     async def execute(
251 |         self,
252 |         mode: ServerMode,
253 |         server_client: Optional[JupyterServerClient] = None,
254 |         kernel_client: Optional[Any] = None,
255 |         contents_manager: Optional[Any] = None,
256 |         kernel_manager: Optional[Any] = None,
257 |         kernel_spec_manager: Optional[Any] = None,
258 |         notebook_manager: Optional[NotebookManager] = None,
259 |         # Tool-specific parameters
260 |         cell_index: int = None,
261 |         cell_source: str = None,
262 |         # Helper function passed from server.py
263 |         ensure_kernel_alive = None,
264 |         **kwargs
265 |     ) -> List[Union[str, ImageContent]]:
266 |         """Execute the insert_execute_code_cell tool.
267 |         
268 |         Args:
269 |             mode: Server mode (MCP_SERVER or JUPYTER_SERVER)
270 |             kernel_manager: Kernel manager for JUPYTER_SERVER mode
271 |             notebook_manager: Notebook manager instance
272 |             cell_index: Index to insert cell (0-based, -1 to append)
273 |             cell_source: Code source
274 |             ensure_kernel_alive: Function to ensure kernel is alive
275 |             **kwargs: Additional parameters
276 |             
277 |         Returns:
278 |             List of outputs from the executed cell
279 |         """
280 |         if mode == ServerMode.JUPYTER_SERVER and kernel_manager is not None:
281 |             # JUPYTER_SERVER mode: Use YDoc and kernel_manager
282 |             from jupyter_mcp_server.jupyter_extension.context import get_server_context
283 |             from jupyter_mcp_server.config import get_config
284 |             
285 |             context = get_server_context()
286 |             serverapp = context.serverapp
287 |             
288 |             notebook_path, kernel_id = get_current_notebook_context(notebook_manager)
289 |             
290 |             # Resolve to absolute path FIRST
291 |             if serverapp and not Path(notebook_path).is_absolute():
292 |                 root_dir = serverapp.root_dir
293 |                 notebook_path = str(Path(root_dir) / notebook_path)
294 |             
295 |             if kernel_id is None:
296 |                 # No kernel available - start a new one on demand
297 |                 logger.info("No kernel_id available, starting new kernel for insert_execute_code_cell")
298 |                 kernel_id = await kernel_manager.start_kernel()
299 |                 
300 |                 # Wait a bit for kernel to initialize
301 |                 await asyncio.sleep(1.0)
302 |                 logger.info(f"Kernel {kernel_id} started and initialized")
303 |                 
304 |                 # Store the kernel with ABSOLUTE path in notebook_manager
305 |                 if notebook_manager is not None:
306 |                     kernel_info = {"id": kernel_id}
307 |                     notebook_manager.add_notebook(
308 |                         name=notebook_path,
309 |                         kernel=kernel_info,
310 |                         server_url="local",
311 |                         path=notebook_path
312 |                     )
313 |             
314 |             if serverapp:
315 |                 return await self._insert_execute_ydoc(
316 |                     serverapp, notebook_path, cell_index, cell_source,
317 |                     kernel_manager, kernel_id, safe_extract_outputs
318 |                 )
319 |             else:
320 |                 raise RuntimeError("serverapp not available in JUPYTER_SERVER mode")
321 |                 
322 |         elif mode == ServerMode.MCP_SERVER and notebook_manager is not None:
323 |             # MCP_SERVER mode: Use WebSocket connection
324 |             return await self._insert_execute_websocket(
325 |                 notebook_manager, cell_index, cell_source, ensure_kernel_alive
326 |             )
327 |         else:
328 |             raise ValueError(f"Invalid mode or missing required clients: mode={mode}")
329 | 
```

--------------------------------------------------------------------------------
/jupyter_mcp_server/jupyter_extension/backends/local_backend.py:
--------------------------------------------------------------------------------

```python
  1 | # Copyright (c) 2023-2024 Datalayer, Inc.
  2 | #
  3 | # BSD 3-Clause License
  4 | 
  5 | """
  6 | Local Backend Implementation
  7 | 
  8 | This backend uses the Jupyter Server's local API directly when running as an extension.
  9 | It provides efficient local access to contents_manager and kernel_manager.
 10 | """
 11 | 
 12 | from typing import Optional, Any, Union, Literal, TYPE_CHECKING
 13 | import asyncio
 14 | from mcp.types import ImageContent
 15 | from jupyter_mcp_server.jupyter_extension.backends.base import Backend
 16 | from jupyter_mcp_server.utils import safe_extract_outputs
 17 | 
 18 | if TYPE_CHECKING:
 19 |     from jupyter_server.serverapp import ServerApp
 20 | 
 21 | 
 22 | class LocalBackend(Backend):
 23 |     """
 24 |     Backend that uses local Jupyter Server API directly.
 25 |     
 26 |     Uses:
 27 |     - serverapp.contents_manager for notebook file operations
 28 |     - serverapp.kernel_manager for kernel management
 29 |     - serverapp.kernel_spec_manager for kernel specs
 30 |     
 31 |     This backend is only available when running as a Jupyter Server extension
 32 |     with document_url="local" or runtime_url="local".
 33 |     """
 34 |     
 35 |     def __init__(self, serverapp: 'ServerApp'):
 36 |         """
 37 |         Initialize local backend with direct serverapp access.
 38 |         
 39 |         Args:
 40 |             serverapp: Jupyter ServerApp instance
 41 |         """
 42 |         self.serverapp = serverapp
 43 |         self.contents_manager = serverapp.contents_manager
 44 |         self.kernel_manager = serverapp.kernel_manager
 45 |         self.kernel_spec_manager = serverapp.kernel_spec_manager
 46 |     
 47 |     # Notebook operations
 48 |     
 49 |     async def get_notebook_content(self, path: str) -> dict[str, Any]:
 50 |         """
 51 |         Get notebook content using local contents_manager.
 52 |         
 53 |         Args:
 54 |             path: Path to notebook file
 55 |             
 56 |         Returns:
 57 |             Notebook content dictionary
 58 |         """
 59 |         model = await asyncio.to_thread(
 60 |             self.contents_manager.get,
 61 |             path,
 62 |             type='notebook',
 63 |             content=True
 64 |         )
 65 |         return model['content']
 66 |     
 67 |     async def list_notebooks(self, path: str = "") -> list[str]:
 68 |         """
 69 |         List all notebooks recursively using local contents_manager.
 70 |         
 71 |         Args:
 72 |             path: Directory path to search
 73 |             
 74 |         Returns:
 75 |             List of notebook paths
 76 |         """
 77 |         notebooks = []
 78 |         await self._list_notebooks_recursive(path, notebooks)
 79 |         return notebooks
 80 |     
 81 |     async def _list_notebooks_recursive(self, path: str, notebooks: list[str]) -> None:
 82 |         """Helper to recursively list notebooks."""
 83 |         try:
 84 |             model = await asyncio.to_thread(
 85 |                 self.contents_manager.get,
 86 |                 path,
 87 |                 content=True
 88 |             )
 89 |             
 90 |             if model['type'] == 'directory':
 91 |                 for item in model['content']:
 92 |                     item_path = f"{path}/{item['name']}" if path else item['name']
 93 |                     
 94 |                     if item['type'] == 'directory':
 95 |                         await self._list_notebooks_recursive(item_path, notebooks)
 96 |                     elif item['type'] == 'notebook' or item['name'].endswith('.ipynb'):
 97 |                         notebooks.append(item_path)
 98 |         except Exception:
 99 |             # Skip directories we can't access
100 |             pass
101 |     
102 |     async def notebook_exists(self, path: str) -> bool:
103 |         """
104 |         Check if notebook exists using local contents_manager.
105 |         
106 |         Args:
107 |             path: Path to notebook
108 |             
109 |         Returns:
110 |             True if exists
111 |         """
112 |         try:
113 |             await asyncio.to_thread(
114 |                 self.contents_manager.get,
115 |                 path,
116 |                 content=False
117 |             )
118 |             return True
119 |         except Exception:
120 |             return False
121 |     
122 |     async def create_notebook(self, path: str) -> dict[str, Any]:
123 |         """
124 |         Create a new notebook using local contents_manager.
125 |         
126 |         Args:
127 |             path: Path for new notebook
128 |             
129 |         Returns:
130 |             Created notebook content
131 |         """
132 |         model = await asyncio.to_thread(
133 |             self.contents_manager.new,
134 |             path=path
135 |         )
136 |         return model['content']
137 |     
138 |     # Cell operations
139 |     
140 |     async def read_cells(
141 |         self, 
142 |         path: str, 
143 |         start_index: Optional[int] = None,
144 |         end_index: Optional[int] = None
145 |     ) -> list[dict[str, Any]]:
146 |         """
147 |         Read cells from notebook.
148 |         
149 |         Args:
150 |             path: Notebook path
151 |             start_index: Start index
152 |             end_index: End index
153 |             
154 |         Returns:
155 |             List of cells
156 |         """
157 |         content = await self.get_notebook_content(path)
158 |         cells = content.get('cells', [])
159 |         
160 |         if start_index is not None or end_index is not None:
161 |             start = start_index or 0
162 |             end = end_index if end_index is not None else len(cells)
163 |             cells = cells[start:end]
164 |         
165 |         return cells
166 |     
167 |     async def append_cell(
168 |         self, 
169 |         path: str, 
170 |         cell_type: Literal["code", "markdown"],
171 |         source: Union[str, list[str]]
172 |     ) -> int:
173 |         """
174 |         Append a cell to notebook.
175 |         
176 |         Args:
177 |             path: Notebook path
178 |             cell_type: Cell type
179 |             source: Cell source
180 |             
181 |         Returns:
182 |             Index of appended cell
183 |         """
184 |         content = await self.get_notebook_content(path)
185 |         cells = content.get('cells', [])
186 |         
187 |         # Normalize source to list of strings
188 |         if isinstance(source, str):
189 |             source = source.splitlines(keepends=True)
190 |         
191 |         new_cell = {
192 |             'cell_type': cell_type,
193 |             'metadata': {},
194 |             'source': source
195 |         }
196 |         
197 |         if cell_type == 'code':
198 |             new_cell['outputs'] = []
199 |             new_cell['execution_count'] = None
200 |         
201 |         cells.append(new_cell)
202 |         content['cells'] = cells
203 |         
204 |         # Save updated notebook
205 |         await asyncio.to_thread(
206 |             self.contents_manager.save,
207 |             {
208 |                 'type': 'notebook',
209 |                 'content': content
210 |             },
211 |             path
212 |         )
213 |         
214 |         return len(cells) - 1
215 |     
216 |     async def insert_cell(
217 |         self,
218 |         path: str,
219 |         cell_index: int,
220 |         cell_type: Literal["code", "markdown"],
221 |         source: Union[str, list[str]]
222 |     ) -> int:
223 |         """
224 |         Insert a cell at specific index.
225 |         
226 |         Args:
227 |             path: Notebook path
228 |             cell_index: Insert position
229 |             cell_type: Cell type
230 |             source: Cell source
231 |             
232 |         Returns:
233 |             Index of inserted cell
234 |         """
235 |         content = await self.get_notebook_content(path)
236 |         cells = content.get('cells', [])
237 |         
238 |         # Normalize source
239 |         if isinstance(source, str):
240 |             source = source.splitlines(keepends=True)
241 |         
242 |         new_cell = {
243 |             'cell_type': cell_type,
244 |             'metadata': {},
245 |             'source': source
246 |         }
247 |         
248 |         if cell_type == 'code':
249 |             new_cell['outputs'] = []
250 |             new_cell['execution_count'] = None
251 |         
252 |         cells.insert(cell_index, new_cell)
253 |         content['cells'] = cells
254 |         
255 |         # Save updated notebook
256 |         await asyncio.to_thread(
257 |             self.contents_manager.save,
258 |             {
259 |                 'type': 'notebook',
260 |                 'content': content
261 |             },
262 |             path
263 |         )
264 |         
265 |         return cell_index
266 |     
267 |     async def delete_cell(self, path: str, cell_index: int) -> None:
268 |         """
269 |         Delete a cell from notebook.
270 |         
271 |         Args:
272 |             path: Notebook path
273 |             cell_index: Index to delete
274 |         """
275 |         content = await self.get_notebook_content(path)
276 |         cells = content.get('cells', [])
277 |         
278 |         if 0 <= cell_index < len(cells):
279 |             cells.pop(cell_index)
280 |             content['cells'] = cells
281 |             
282 |             await asyncio.to_thread(
283 |                 self.contents_manager.save,
284 |                 {
285 |                     'type': 'notebook',
286 |                     'content': content
287 |                 },
288 |                 path
289 |             )
290 |     
291 |     async def overwrite_cell(
292 |         self,
293 |         path: str,
294 |         cell_index: int,
295 |         new_source: Union[str, list[str]]
296 |     ) -> tuple[str, str]:
297 |         """
298 |         Overwrite cell content.
299 |         
300 |         Args:
301 |             path: Notebook path
302 |             cell_index: Cell index
303 |             new_source: New source
304 |             
305 |         Returns:
306 |             Tuple of (old_source, new_source)
307 |         """
308 |         content = await self.get_notebook_content(path)
309 |         cells = content.get('cells', [])
310 |         
311 |         if cell_index < 0 or cell_index >= len(cells):
312 |             raise ValueError(f"Cell index {cell_index} out of range")
313 |         
314 |         cell = cells[cell_index]
315 |         old_source = ''.join(cell['source']) if isinstance(cell['source'], list) else cell['source']
316 |         
317 |         # Normalize new source
318 |         if isinstance(new_source, str):
319 |             new_source_str = new_source
320 |             new_source = new_source.splitlines(keepends=True)
321 |         else:
322 |             new_source_str = ''.join(new_source)
323 |         
324 |         cell['source'] = new_source
325 |         content['cells'] = cells
326 |         
327 |         await asyncio.to_thread(
328 |             self.contents_manager.save,
329 |             {
330 |                 'type': 'notebook',
331 |                 'content': content
332 |             },
333 |             path
334 |         )
335 |         
336 |         return (old_source, new_source_str)
337 |     
338 |     # Kernel operations
339 |     
340 |     async def get_or_create_kernel(self, path: str, kernel_id: Optional[str] = None) -> str:
341 |         """
342 |         Get existing kernel or create new one.
343 |         
344 |         Args:
345 |             path: Notebook path (for context)
346 |             kernel_id: Specific kernel ID
347 |             
348 |         Returns:
349 |             Kernel ID
350 |         """
351 |         if kernel_id and kernel_id in self.kernel_manager:
352 |             return kernel_id
353 |         
354 |         # Start new kernel
355 |         kernel_id = await self.kernel_manager.start_kernel()
356 |         return kernel_id
357 |     
358 |     async def execute_cell(
359 |         self,
360 |         path: str,
361 |         cell_index: int,
362 |         kernel_id: str,
363 |         timeout_seconds: int = 300
364 |     ) -> list[Union[str, ImageContent]]:
365 |         """
366 |         Execute a cell using local kernel manager.
367 |         
368 |         Args:
369 |             path: Notebook path
370 |             cell_index: Cell index
371 |             kernel_id: Kernel ID
372 |             timeout_seconds: Timeout
373 |             
374 |         Returns:
375 |             List of outputs
376 |         """
377 |         # Get cell source
378 |         cells = await self.read_cells(path)
379 |         if cell_index < 0 or cell_index >= len(cells):
380 |             raise ValueError(f"Cell index {cell_index} out of range")
381 |         
382 |         cell = cells[cell_index]
383 |         source = ''.join(cell['source']) if isinstance(cell['source'], list) else cell['source']
384 |         
385 |         # Get kernel client
386 |         kernel = self.kernel_manager.get_kernel(kernel_id)
387 |         client = kernel.client()
388 |         
389 |         # Execute code
390 |         msg_id = client.execute(source)
391 |         
392 |         # Collect outputs
393 |         outputs = []
394 |         start_time = asyncio.get_event_loop().time()
395 |         
396 |         while True:
397 |             if asyncio.get_event_loop().time() - start_time > timeout_seconds:
398 |                 raise TimeoutError(f"Cell execution exceeded {timeout_seconds} seconds")
399 |             
400 |             try:
401 |                 msg = await asyncio.wait_for(
402 |                     asyncio.to_thread(client.get_iopub_msg, timeout=1),
403 |                     timeout=2
404 |                 )
405 |                 
406 |                 msg_type = msg['header']['msg_type']
407 |                 
408 |                 if msg_type == 'status':
409 |                     if msg['content']['execution_state'] == 'idle':
410 |                         break
411 |                 elif msg_type in ['execute_result', 'display_data']:
412 |                     outputs.append(msg['content'])
413 |                 elif msg_type == 'stream':
414 |                     outputs.append(msg['content'])
415 |                 elif msg_type == 'error':
416 |                     outputs.append(msg['content'])
417 |             except asyncio.TimeoutError:
418 |                 continue
419 |             except Exception:
420 |                 break
421 |         
422 |         # Update cell with outputs
423 |         content = await self.get_notebook_content(path)
424 |         if cell_index < len(content['cells']):
425 |             content['cells'][cell_index]['outputs'] = outputs
426 |             await asyncio.to_thread(
427 |                 self.contents_manager.save,
428 |                 {'type': 'notebook', 'content': content},
429 |                 path
430 |             )
431 |         
432 |         return safe_extract_outputs(outputs)
433 |     
434 |     async def interrupt_kernel(self, kernel_id: str) -> None:
435 |         """Interrupt a kernel."""
436 |         if kernel_id in self.kernel_manager:
437 |             kernel = self.kernel_manager.get_kernel(kernel_id)
438 |             await kernel.interrupt()
439 |     
440 |     async def restart_kernel(self, kernel_id: str) -> None:
441 |         """Restart a kernel."""
442 |         if kernel_id in self.kernel_manager:
443 |             await self.kernel_manager.restart_kernel(kernel_id)
444 |     
445 |     async def shutdown_kernel(self, kernel_id: str) -> None:
446 |         """Shutdown a kernel."""
447 |         if kernel_id in self.kernel_manager:
448 |             await self.kernel_manager.shutdown_kernel(kernel_id)
449 |     
450 |     async def list_kernels(self) -> list[dict[str, Any]]:
451 |         """List all running kernels."""
452 |         return [
453 |             {
454 |                 'id': kid,
455 |                 'name': self.kernel_manager.get_kernel(kid).kernel_name
456 |             }
457 |             for kid in self.kernel_manager.list_kernel_ids()
458 |         ]
459 |     
460 |     async def kernel_exists(self, kernel_id: str) -> bool:
461 |         """Check if kernel exists."""
462 |         return kernel_id in self.kernel_manager
463 | 
```

--------------------------------------------------------------------------------
/jupyter_mcp_server/tools/insert_cell_tool.py:
--------------------------------------------------------------------------------

```python
  1 | # Copyright (c) 2023-2024 Datalayer, Inc.
  2 | #
  3 | # BSD 3-Clause License
  4 | 
  5 | """Insert cell tool implementation."""
  6 | 
  7 | from typing import Any, Optional, Literal
  8 | from pathlib import Path
  9 | import nbformat
 10 | from jupyter_server_api import JupyterServerClient
 11 | from jupyter_mcp_server.tools._base import BaseTool, ServerMode
 12 | from jupyter_mcp_server.notebook_manager import NotebookManager
 13 | from jupyter_mcp_server.utils import get_current_notebook_context
 14 | from jupyter_mcp_server.utils import get_surrounding_cells_info
 15 | 
 16 | 
 17 | class InsertCellTool(BaseTool):
 18 |     """Tool to insert a cell at a specified position."""
 19 |     
 20 |     @property
 21 |     def name(self) -> str:
 22 |         return "insert_cell"
 23 |     
 24 |     @property
 25 |     def description(self) -> str:
 26 |         return """Insert a cell to specified position.
 27 | 
 28 | Args:
 29 |     cell_index: target index for insertion (0-based). Use -1 to append at end.
 30 |     cell_type: Type of cell to insert ("code" or "markdown")
 31 |     cell_source: Source content for the cell
 32 | 
 33 | Returns:
 34 |     str: Success message and the structure of its surrounding cells (up to 5 cells above and 5 cells below)"""
 35 |     
 36 |     async def _get_jupyter_ydoc(self, serverapp: Any, file_id: str):
 37 |         """Get the YNotebook document if it's currently open in a collaborative session.
 38 |         
 39 |         This follows the jupyter_ai_tools pattern of accessing YDoc through the
 40 |         yroom_manager when the notebook is actively being edited.
 41 |         
 42 |         Args:
 43 |             serverapp: The Jupyter ServerApp instance
 44 |             file_id: The file ID for the document
 45 |             
 46 |         Returns:
 47 |             YNotebook instance or None if not in a collaborative session
 48 |         """
 49 |         try:
 50 |             yroom_manager = serverapp.web_app.settings.get("yroom_manager")
 51 |             if yroom_manager is None:
 52 |                 return None
 53 |                 
 54 |             room_id = f"json:notebook:{file_id}"
 55 |             
 56 |             if yroom_manager.has_room(room_id):
 57 |                 yroom = yroom_manager.get_room(room_id)
 58 |                 notebook = await yroom.get_jupyter_ydoc()
 59 |                 return notebook
 60 |         except Exception:
 61 |             # YDoc not available, will fall back to file operations
 62 |             pass
 63 |         
 64 |         return None
 65 |     
 66 |     async def _insert_cell_ydoc(
 67 |         self,
 68 |         serverapp: Any,
 69 |         notebook_path: str,
 70 |         cell_index: int,
 71 |         cell_type: Literal["code", "markdown"],
 72 |         cell_source: str
 73 |     ) -> str:
 74 |         """Insert cell using YDoc (collaborative editing mode).
 75 |         
 76 |         Args:
 77 |             serverapp: Jupyter ServerApp instance
 78 |             notebook_path: Path to the notebook
 79 |             cell_index: Index to insert at (-1 for append)
 80 |             cell_type: Type of cell to insert
 81 |             cell_source: Source content for the cell
 82 |             
 83 |         Returns:
 84 |             Success message with surrounding cells info
 85 |         """
 86 |         # Get file_id from file_id_manager
 87 |         file_id_manager = serverapp.web_app.settings.get("file_id_manager")
 88 |         if file_id_manager is None:
 89 |             raise RuntimeError("file_id_manager not available in serverapp")
 90 |         
 91 |         file_id = file_id_manager.get_id(notebook_path)
 92 |         
 93 |         # Try to get YDoc
 94 |         ydoc = await self._get_jupyter_ydoc(serverapp, file_id)
 95 |         
 96 |         if ydoc:
 97 |             # Notebook is open in collaborative mode, use YDoc
 98 |             total_cells = len(ydoc.ycells)
 99 |             actual_index = cell_index if cell_index != -1 else total_cells
100 |             
101 |             if actual_index < 0 or actual_index > total_cells:
102 |                 raise ValueError(
103 |                     f"Cell index {cell_index} is out of range. Notebook has {total_cells} cells. Use -1 to append at end."
104 |                 )
105 |             
106 |             # Create the cell
107 |             cell = {
108 |                 "cell_type": cell_type,
109 |                 "source": "",
110 |             }
111 |             ycell = ydoc.create_ycell(cell)
112 |             
113 |             # Insert at the specified position
114 |             if actual_index >= total_cells:
115 |                 ydoc.ycells.append(ycell)
116 |             else:
117 |                 ydoc.ycells.insert(actual_index, ycell)
118 |             
119 |             # Write content to the cell collaboratively
120 |             if cell_source:
121 |                 # Set the source directly on the ycell
122 |                 ycell["source"] = cell_source
123 |             
124 |             # Get surrounding cells info (simplified version for YDoc)
125 |             new_total_cells = len(ydoc.ycells)
126 |             surrounding_info = self._get_surrounding_cells_info_ydoc(ydoc, actual_index, new_total_cells)
127 |             
128 |             return f"Cell inserted successfully at index {actual_index} ({cell_type})!\n\nCurrent Surrounding Cells:\n{surrounding_info}"
129 |         else:
130 |             # YDoc not available, use file operations
131 |             return await self._insert_cell_file(notebook_path, cell_index, cell_type, cell_source)
132 |     
133 |     def _get_surrounding_cells_info_ydoc(self, ydoc, center_index: int, total_cells: int) -> str:
134 |         """Get info about surrounding cells from YDoc."""
135 |         lines = []
136 |         start_index = max(0, center_index - 5)
137 |         end_index = min(total_cells, center_index + 6)
138 |         
139 |         for i in range(start_index, end_index):
140 |             cell = ydoc.ycells[i]
141 |             cell_type = cell.get("cell_type", "unknown")
142 |             source = cell.get("source", "")
143 |             if isinstance(source, list):
144 |                 source = "".join(source)
145 |             first_line = source.split('\n')[0][:50] if source else "(empty)"
146 |             marker = " <-- NEW" if i == center_index else ""
147 |             lines.append(f"  [{i}] {cell_type}: {first_line}{marker}")
148 |         
149 |         return "\n".join(lines)
150 |     
151 |     async def _insert_cell_file(
152 |         self,
153 |         notebook_path: str,
154 |         cell_index: int,
155 |         cell_type: Literal["code", "markdown"],
156 |         cell_source: str
157 |     ) -> str:
158 |         """Insert cell using file operations (non-collaborative mode).
159 |         
160 |         Args:
161 |             notebook_path: Absolute path to the notebook
162 |             cell_index: Index to insert at (-1 for append)
163 |             cell_type: Type of cell to insert
164 |             cell_source: Source content for the cell
165 |             
166 |         Returns:
167 |             Success message with surrounding cells info
168 |         """
169 |         # Read notebook file
170 |         with open(notebook_path, "r", encoding="utf-8") as f:
171 |             # Read as version 4 (latest) to ensure consistency and support for cell IDs
172 |             notebook = nbformat.read(f, as_version=4)
173 |         
174 |         # Clean any transient fields from existing outputs (kernel protocol field not in nbformat schema)
175 |         self._clean_notebook_outputs(notebook)
176 |         
177 |         total_cells = len(notebook.cells)
178 |         actual_index = cell_index if cell_index != -1 else total_cells
179 |         
180 |         if actual_index < 0 or actual_index > total_cells:
181 |             raise ValueError(
182 |                 f"Cell index {cell_index} is out of range. Notebook has {total_cells} cells. Use -1 to append at end."
183 |             )
184 |         
185 |         # Create and insert the cell
186 |         if cell_type == "code":
187 |             new_cell = nbformat.v4.new_code_cell(source=cell_source or "")
188 |         elif cell_type == "markdown":
189 |             new_cell = nbformat.v4.new_markdown_cell(source=cell_source or "")
190 |         else:
191 |             raise ValueError(f"Invalid cell_type: {cell_type}. Must be 'code' or 'markdown'.")
192 |         
193 |         notebook.cells.insert(actual_index, new_cell)
194 |         
195 |         # Write back to file
196 |         with open(notebook_path, "w", encoding="utf-8") as f:
197 |             nbformat.write(notebook, f)
198 |         
199 |         # Get surrounding cells info
200 |         new_total_cells = len(notebook.cells)
201 |         surrounding_info = self._get_surrounding_cells_info_file(notebook, actual_index, new_total_cells)
202 |         
203 |         return f"Cell inserted successfully at index {actual_index} ({cell_type})!\n\nCurrent Surrounding Cells:\n{surrounding_info}"
204 |     
205 |     def _clean_notebook_outputs(self, notebook):
206 |         """Remove transient fields from all cell outputs.
207 |         
208 |         The 'transient' field is part of the Jupyter kernel messaging protocol
209 |         but is NOT part of the nbformat schema. This causes validation errors.
210 |         
211 |         Args:
212 |             notebook: nbformat notebook object to clean (modified in place)
213 |         """
214 |         # Clean transient fields from outputs
215 |         for cell in notebook.cells:
216 |             if cell.cell_type == 'code' and hasattr(cell, 'outputs'):
217 |                 for output in cell.outputs:
218 |                     if isinstance(output, dict) and 'transient' in output:
219 |                         del output['transient']
220 |     
221 |     def _get_surrounding_cells_info_file(self, notebook, center_index: int, total_cells: int) -> str:
222 |         """Get info about surrounding cells from nbformat notebook."""
223 |         lines = []
224 |         start_index = max(0, center_index - 5)
225 |         end_index = min(total_cells, center_index + 6)
226 |         
227 |         for i in range(start_index, end_index):
228 |             cell = notebook.cells[i]
229 |             cell_type = cell.cell_type
230 |             source = cell.source
231 |             first_line = source.split('\n')[0][:50] if source else "(empty)"
232 |             marker = " <-- NEW" if i == center_index else ""
233 |             lines.append(f"  [{i}] {cell_type}: {first_line}{marker}")
234 |         
235 |         return "\n".join(lines)
236 |     
237 |     async def _insert_cell_websocket(
238 |         self,
239 |         notebook_manager: NotebookManager,
240 |         cell_index: int,
241 |         cell_type: Literal["code", "markdown"],
242 |         cell_source: str
243 |     ) -> str:
244 |         """Insert cell using WebSocket connection (MCP_SERVER mode).
245 |         
246 |         Args:
247 |             notebook_manager: Notebook manager instance
248 |             cell_index: Index to insert at (-1 for append)
249 |             cell_type: Type of cell to insert
250 |             cell_source: Source content for the cell
251 |             
252 |         Returns:
253 |             Success message with surrounding cells info
254 |         """
255 |         async with notebook_manager.get_current_connection() as notebook:
256 |             actual_index = cell_index if cell_index != -1 else len(notebook)
257 |             if actual_index < 0 or actual_index > len(notebook):
258 |                 raise ValueError(f"Cell index {cell_index} out of range")
259 |             
260 |             notebook.insert_cell(actual_index, cell_source, cell_type)
261 |             
262 |             # Get surrounding cells info
263 |             new_total_cells = len(notebook)
264 |             surrounding_info = get_surrounding_cells_info(notebook, actual_index, new_total_cells)
265 |             
266 |             return f"Cell inserted successfully at index {actual_index} ({cell_type})!\n\nCurrent Surrounding Cells:\n{surrounding_info}"
267 |     
268 |     async def execute(
269 |         self,
270 |         mode: ServerMode,
271 |         server_client: Optional[JupyterServerClient] = None,
272 |         kernel_client: Optional[Any] = None,
273 |         contents_manager: Optional[Any] = None,
274 |         kernel_manager: Optional[Any] = None,
275 |         kernel_spec_manager: Optional[Any] = None,
276 |         notebook_manager: Optional[NotebookManager] = None,
277 |         # Tool-specific parameters
278 |         cell_index: int = None,
279 |         cell_type: Literal["code", "markdown"] = None,
280 |         cell_source: str = None,
281 |         **kwargs
282 |     ) -> str:
283 |         """Execute the insert_cell tool.
284 |         
285 |         This tool supports three modes of operation:
286 |         
287 |         1. JUPYTER_SERVER mode with YDoc (collaborative):
288 |            - Checks if notebook is open in a collaborative session
289 |            - Uses YDoc for real-time collaborative editing
290 |            - Changes are immediately visible to all connected users
291 |            
292 |         2. JUPYTER_SERVER mode without YDoc (file-based):
293 |            - Falls back to direct file operations using nbformat
294 |            - Suitable when notebook is not actively being edited
295 |            
296 |         3. MCP_SERVER mode (WebSocket):
297 |            - Uses WebSocket connection to remote Jupyter server
298 |            - Accesses YDoc through NbModelClient
299 |         
300 |         Args:
301 |             mode: Server mode (MCP_SERVER or JUPYTER_SERVER)
302 |             server_client: HTTP client for MCP_SERVER mode
303 |             contents_manager: Direct API access for JUPYTER_SERVER mode
304 |             notebook_manager: Notebook manager instance
305 |             cell_index: Target index for insertion (0-based, -1 to append)
306 |             cell_type: Type of cell ("code" or "markdown")
307 |             cell_source: Source content for the cell
308 |             **kwargs: Additional parameters
309 |             
310 |         Returns:
311 |             Success message with surrounding cells info
312 |         """
313 |         if mode == ServerMode.JUPYTER_SERVER and contents_manager is not None:
314 |             # JUPYTER_SERVER mode: Try YDoc first, fall back to file operations
315 |             from jupyter_mcp_server.jupyter_extension.context import get_server_context
316 |             
317 |             context = get_server_context()
318 |             serverapp = context.serverapp
319 |             notebook_path, _ = get_current_notebook_context(notebook_manager)
320 |             
321 |             # Resolve to absolute path
322 |             if serverapp and not Path(notebook_path).is_absolute():
323 |                 root_dir = serverapp.root_dir
324 |                 notebook_path = str(Path(root_dir) / notebook_path)
325 |             
326 |             if serverapp:
327 |                 # Try YDoc approach first
328 |                 return await self._insert_cell_ydoc(serverapp, notebook_path, cell_index, cell_type, cell_source)
329 |             else:
330 |                 # Fall back to file operations
331 |                 return await self._insert_cell_file(notebook_path, cell_index, cell_type, cell_source)
332 |                 
333 |         elif mode == ServerMode.MCP_SERVER and notebook_manager is not None:
334 |             # MCP_SERVER mode: Use WebSocket connection
335 |             return await self._insert_cell_websocket(notebook_manager, cell_index, cell_type, cell_source)
336 |         else:
337 |             raise ValueError(f"Invalid mode or missing required clients: mode={mode}")
338 | 
```

--------------------------------------------------------------------------------
/docs/src/css/custom.css:
--------------------------------------------------------------------------------

```css
  1 | /*
  2 |  * Copyright (c) 2023-2024 Datalayer, Inc.
  3 |  *
  4 |  * BSD 3-Clause License
  5 |  */
  6 | 
  7 | /* stylelint-disable docusaurus/copyright-header */
  8 | /**
  9 |  * Any CSS included here will be global. The classic template
 10 |  * bundles Infima by default. Infima is a CSS framework designed to
 11 |  * work well for content-centric websites.
 12 |  */
 13 | 
 14 | /* You can override the default Infima variables here. */
 15 | :root {
 16 |   --ifm-color-primary: #25c2a0;
 17 |   --ifm-color-primary-dark: rgb(33, 175, 144);
 18 |   --ifm-color-primary-darker: rgb(31, 165, 136);
 19 |   --ifm-color-primary-darkest: rgb(26, 136, 112);
 20 |   --ifm-color-primary-light: rgb(70, 203, 174);
 21 |   --ifm-color-primary-lighter: rgb(102, 212, 189);
 22 |   --ifm-color-primary-lightest: rgb(146, 224, 208);
 23 |   --ifm-code-font-size: 95%;
 24 | }
 25 | 
 26 | .docusaurus-highlight-code-line {
 27 |   background-color: rgb(72, 77, 91);
 28 |   display: block;
 29 |   margin: 0 calc(-1 * var(--ifm-pre-padding));
 30 |   padding: 0 var(--ifm-pre-padding);
 31 | }
 32 | 
 33 | .header-datalayer-io-link::before {
 34 |   content: '';
 35 |   width: 24px;
 36 |   height: 24px;
 37 |   display: flex;
 38 |   background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' aria-hidden='true' viewBox='0 0 20 20'%3E%3Cpath fill='%232ECC71' d='M0 0h20v4H0zm0 0'/%3E%3Cpath fill='%231ABC9C' d='M0 8h20v4H0zm0 0'/%3E%3Cpath fill='%2316A085' d='M0 16h20v4H0zm0 0'/%3E%3C/svg%3E%0A")
 39 |     no-repeat;
 40 | }
 41 | 
 42 | .header-datalayer-io-link:hover {
 43 |   opacity: 0.6;
 44 | }
 45 | 
 46 | [data-theme='dark'] .header-datalayer-io-link::before {
 47 |   background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' aria-hidden='true' viewBox='0 0 20 20'%3E%3Cpath fill='%232ECC71' d='M0 0h20v4H0zm0 0'/%3E%3Cpath fill='%231ABC9C' d='M0 8h20v4H0zm0 0'/%3E%3Cpath fill='%2316A085' d='M0 16h20v4H0zm0 0'/%3E%3C/svg%3E%0A")
 48 |     no-repeat;
 49 | }
 50 | 
 51 | .header-github-link::before {
 52 |   content: '';
 53 |   width: 24px;
 54 |   height: 24px;
 55 |   display: flex;
 56 |   background: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8' standalone='no'%3F%3E%3Csvg viewBox='0 0 80 80' version='1.1' id='svg4' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cdefs id='defs8' /%3E%3Cpath fill='%23959da5' d='M 40,0 C 17.9,0 0,17.900001 0,40 c 0,17.7 11.45,32.65 27.35,37.950001 2,0.35 2.75,-0.85 2.75,-1.9 0,-0.95 -0.05,-4.1 -0.05,-7.45 C 20,70.45 17.4,66.15 16.6,63.9 16.15,62.75 14.2,59.2 12.5,58.25 11.1,57.5 9.1,55.65 12.45,55.600001 c 3.15,-0.05 5.4,2.899999 6.15,4.1 3.6,6.05 9.35,4.35 11.65,3.3 0.35,-2.6 1.4,-4.35 2.55,-5.35 -8.9,-1 -18.2,-4.45 -18.2,-19.75 0,-4.35 1.55,-7.95 4.1,-10.75 -0.4,-1 -1.8,-5.1 0.4,-10.6 0,0 3.35,-1.05 11,4.1 3.2,-0.9 6.6,-1.35 10,-1.35 3.4,0 6.8,0.45 10,1.35 7.65,-5.2 11,-4.1 11,-4.1 2.2,5.5 0.8,9.6 0.4,10.6 2.55,2.8 4.1,6.35 4.1,10.75 0,15.35 -9.35,18.75 -18.25,19.75 1.45,1.25 2.7,3.65 2.7,7.4 0,5.349999 -0.05,9.65 -0.05,11 0,1.05 0.75,2.3 2.75,1.9 A 40.065,40.065 0 0 0 80,40 C 80,17.900001 62.1,0 40,0 Z' id='path2' style='stroke-width:5' /%3E%3C/svg%3E%0A")
 57 |     no-repeat;
 58 | }
 59 | 
 60 | .header-github-link:hover {
 61 |   opacity: 0.6;
 62 | }
 63 | 
 64 | [data-theme='dark'] .header-github-link::before {
 65 |   background: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8' standalone='no'%3F%3E%3Csvg viewBox='0 0 80 80' version='1.1' id='svg4' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cdefs id='defs8' /%3E%3Cpath fill='%23959da5' d='M 40,0 C 17.9,0 0,17.900001 0,40 c 0,17.7 11.45,32.65 27.35,37.950001 2,0.35 2.75,-0.85 2.75,-1.9 0,-0.95 -0.05,-4.1 -0.05,-7.45 C 20,70.45 17.4,66.15 16.6,63.9 16.15,62.75 14.2,59.2 12.5,58.25 11.1,57.5 9.1,55.65 12.45,55.600001 c 3.15,-0.05 5.4,2.899999 6.15,4.1 3.6,6.05 9.35,4.35 11.65,3.3 0.35,-2.6 1.4,-4.35 2.55,-5.35 -8.9,-1 -18.2,-4.45 -18.2,-19.75 0,-4.35 1.55,-7.95 4.1,-10.75 -0.4,-1 -1.8,-5.1 0.4,-10.6 0,0 3.35,-1.05 11,4.1 3.2,-0.9 6.6,-1.35 10,-1.35 3.4,0 6.8,0.45 10,1.35 7.65,-5.2 11,-4.1 11,-4.1 2.2,5.5 0.8,9.6 0.4,10.6 2.55,2.8 4.1,6.35 4.1,10.75 0,15.35 -9.35,18.75 -18.25,19.75 1.45,1.25 2.7,3.65 2.7,7.4 0,5.349999 -0.05,9.65 -0.05,11 0,1.05 0.75,2.3 2.75,1.9 A 40.065,40.065 0 0 0 80,40 C 80,17.900001 62.1,0 40,0 Z' id='path2' style='stroke-width:5' /%3E%3C/svg%3E%0A")
 66 |     no-repeat;
 67 | }
 68 | 
 69 | .header-bluesky-link::before {
 70 |   content: '';
 71 |   width: 24px;
 72 |   height: 24px;
 73 |   display: flex;
 74 |   background: url("data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%20standalone%3D%22no%22%3F%3E%0A%3Csvg%0A%20%20%20width%3D%2224%22%0A%20%20%20height%3D%2224%22%0A%20%20%20viewBox%3D%220%200%202.88%202.88%22%0A%20%20%20version%3D%221.1%22%0A%20%20%20id%3D%22svg4%22%0A%20%20%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%0A%20%20%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%0A%20%20%20%20%20id%3D%22defs8%22%20%2F%3E%0A%20%20%3Cpath%0A%20%20%20%20%20fill%3D%22%23959da5%22%0A%20%20%20%20%20d%3D%22M%201.44%2C1.306859%20C%201.30956%2C1.053179%200.95447995%2C0.58049901%200.62423999%2C0.34745901%200.30791999%2C0.12413901%200.18732%2C0.16277901%200.10824%2C0.19865901%200.01668%2C0.23981901%200%2C0.38045901%200%2C0.46301901%20c%200%2C0.0828%200.04536%2C0.67799999%200.07488%2C0.77747999%200.0978%2C0.32832%200.44556%2C0.4392%200.76595999%2C0.4036799%200.01632%2C-0.0024%200.033%2C-0.00468%200.0498%2C-0.00672%20-0.01656%2C0.00264%20-0.03312%2C0.0048%20-0.0498%2C0.00672%20C%200.3714%2C1.7137789%20-0.0456%2C1.884779%200.50124%2C2.493539%201.1027999%2C3.1163391%201.3256399%2C2.359979%201.44%2C1.976579%201.55436%2C2.359979%201.686%2C3.0890991%202.36796%2C2.493539%202.88%2C1.976579%202.5086%2C1.713779%202.0391599%2C1.6441789%20a%201.04892%2C1.04892%200%200%201%20-0.0498%2C-0.00672%20c%200.0168%2C0.00204%200.03348%2C0.00432%200.0498%2C0.00672%20C%202.35956%2C1.6798189%202.7073199%2C1.5688189%202.80512%2C1.240499%202.8346401%2C1.141139%202.88%2C0.54569891%202.88%2C0.46313901%20c%200%2C-0.0828%20-0.01668%2C-0.22332%20-0.10824%2C-0.26472%20-0.07908%2C-0.03576%20-0.19968%2C-0.0744%20-0.516%2C0.1488%20C%201.92552%2C0.58061901%201.5704399%2C1.053299%201.44%2C1.306859%22%0A%20%20%20%20%20style%3D%22stroke-width%3A0.12%22%0A%20%20%20%20%20id%3D%22path2%22%20%2F%3E%0A%3C%2Fsvg%3E%0A")
 75 |     no-repeat;
 76 | }
 77 | 
 78 | .header-bluesky-link:hover {
 79 |   opacity: 0.6;
 80 | }
 81 | 
 82 | [data-theme='dark'] .header-bluesky-link::before {
 83 |   background: url("data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%20standalone%3D%22no%22%3F%3E%0A%3Csvg%0A%20%20%20width%3D%2224%22%0A%20%20%20height%3D%2224%22%0A%20%20%20viewBox%3D%220%200%202.88%202.88%22%0A%20%20%20version%3D%221.1%22%0A%20%20%20id%3D%22svg4%22%0A%20%20%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%0A%20%20%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%0A%20%20%20%20%20id%3D%22defs8%22%20%2F%3E%0A%20%20%3Cpath%0A%20%20%20%20%20fill%3D%22%23959da5%22%0A%20%20%20%20%20d%3D%22M%201.44%2C1.306859%20C%201.30956%2C1.053179%200.95447995%2C0.58049901%200.62423999%2C0.34745901%200.30791999%2C0.12413901%200.18732%2C0.16277901%200.10824%2C0.19865901%200.01668%2C0.23981901%200%2C0.38045901%200%2C0.46301901%20c%200%2C0.0828%200.04536%2C0.67799999%200.07488%2C0.77747999%200.0978%2C0.32832%200.44556%2C0.4392%200.76595999%2C0.4036799%200.01632%2C-0.0024%200.033%2C-0.00468%200.0498%2C-0.00672%20-0.01656%2C0.00264%20-0.03312%2C0.0048%20-0.0498%2C0.00672%20C%200.3714%2C1.7137789%20-0.0456%2C1.884779%200.50124%2C2.493539%201.1027999%2C3.1163391%201.3256399%2C2.359979%201.44%2C1.976579%201.55436%2C2.359979%201.686%2C3.0890991%202.36796%2C2.493539%202.88%2C1.976579%202.5086%2C1.713779%202.0391599%2C1.6441789%20a%201.04892%2C1.04892%200%200%201%20-0.0498%2C-0.00672%20c%200.0168%2C0.00204%200.03348%2C0.00432%200.0498%2C0.00672%20C%202.35956%2C1.6798189%202.7073199%2C1.5688189%202.80512%2C1.240499%202.8346401%2C1.141139%202.88%2C0.54569891%202.88%2C0.46313901%20c%200%2C-0.0828%20-0.01668%2C-0.22332%20-0.10824%2C-0.26472%20-0.07908%2C-0.03576%20-0.19968%2C-0.0744%20-0.516%2C0.1488%20C%201.92552%2C0.58061901%201.5704399%2C1.053299%201.44%2C1.306859%22%0A%20%20%20%20%20style%3D%22stroke-width%3A0.12%22%0A%20%20%20%20%20id%3D%22path2%22%20%2F%3E%0A%3C%2Fsvg%3E%0A")
 84 |     no-repeat;
 85 | }
 86 | 
 87 | .header-linkedin-link::before {
 88 |   content: '';
 89 |   width: 24px;
 90 |   height: 24px;
 91 |   display: flex;
 92 |   background: url("data:image/svg+xml,%0A%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 19 18'%3E%3Cpath d='M3.94 2A2 2 0 1 1 2 0a2 2 0 0 1 1.94 2zM4 5.48H0V18h4zm6.32 0H6.34V18h3.94v-6.57c0-3.66 4.77-4 4.77 0V18H19v-7.93c0-6.17-7.06-5.94-8.72-2.91z' fill='rgb(149, 157, 165)'/%3E%3C/svg%3E")
 93 |     no-repeat;
 94 | }
 95 | 
 96 | .header-linkedin-link:hover {
 97 |   opacity: 0.6;
 98 | }
 99 | 
100 | [data-theme='dark'] .header-linkedin-link::before {
101 |   background-image: url("data:image/svg+xml,%0A%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 19 18'%3E%3Cpath d='M3.94 2A2 2 0 1 1 2 0a2 2 0 0 1 1.94 2zM4 5.48H0V18h4zm6.32 0H6.34V18h3.94v-6.57c0-3.66 4.77-4 4.77 0V18H19v-7.93c0-6.17-7.06-5.94-8.72-2.91z' fill='rgb(149, 157, 165)'/%3E%3C/svg%3E")
102 |     no-repeat;
103 | }
104 | 
105 | .header-x-link::before {
106 |   content: '';
107 |   width: 24px;
108 |   height: 24px;
109 |   display: flex;
110 |   background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201200%201227%22%20fill%3D%22rgb(149%2C%20157%2C%20165)%22%3E%3Cpath%20d%3D%22M714.163%20519.284%201160.89%200h-105.86L667.137%20450.887%20357.328%200H0l468.492%20681.821L0%201226.37h105.866l409.625-476.152%20327.181%20476.152H1200L714.137%20519.284h.026ZM569.165%20687.828l-47.468-67.894-377.686-540.24h162.604l304.797%20435.991%2047.468%2067.894%20396.2%20566.721H892.476L569.165%20687.854v-.026Z%22%20%2F%3E%3C%2Fsvg%3E")
111 |     no-repeat;
112 | }
113 | 
114 | .header-x-link:hover {
115 |   opacity: 0.6;
116 | }
117 | 
118 | [data-theme='dark'] .header-x-link::before {
119 |   background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%231DA1F2' viewBox='0 0 20 20' aria-hidden='true'%3E%3Cpath d='M19.96 3.808a8.333 8.333 0 01-2.353.646 4.132 4.132 0 001.802-2.269 8.47 8.47 0 01-2.606.987 4.1 4.1 0 00-6.986 3.735c-3.409-.161-6.428-1.799-8.45-4.272a4.018 4.018 0 00-.555 2.063A4.1 4.1 0 002.635 8.11a4.087 4.087 0 01-1.857-.513v.05a4.102 4.102 0 003.289 4.022 4.162 4.162 0 01-1.844.07 4.114 4.114 0 003.837 2.848 8.223 8.223 0 01-5.085 1.755c-.325 0-.65-.02-.975-.056a11.662 11.662 0 006.298 1.84c7.544 0 11.665-6.246 11.665-11.654 0-.175 0-.35-.013-.525A8.278 8.278 0 0020 3.825l-.04-.017z'/%3E%3C/svg%3E%0A")
120 |     no-repeat;
121 | }
122 | 
123 | .header-discord-link::before {
124 |   content: '';
125 |   width: 24px;
126 |   height: 18px;
127 |   display: flex;
128 |   background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 127.14 96.36'%3E%3Cpath fill='rgb(149, 157, 165)' d='M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z'/%3E%3C/svg%3E%0A")
129 |     no-repeat;
130 | }
131 | 
132 | .header-discord-link:hover {
133 |   opacity: 0.6;
134 | }
135 | 
136 | [data-theme='dark'] .header-discord-link::before {
137 |   background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 127.14 96.36'%3E%3Cpath fill='rgb(149, 157, 165)' d='M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z'/%3E%3C/svg%3E%0A")
138 |     no-repeat;
139 | }
140 | 
141 | .header-tiktok-link::before {
142 |   content: '';
143 |   width: 24px;
144 |   height: 24px;
145 |   display: flex;
146 |   background: url("data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%20standalone%3D%22no%22%3F%3E%0A%3Csvg%0A%20%20%20fill%3D%22%23959da5%22%0A%20%20%20width%3D%2224%22%0A%20%20%20height%3D%2224%22%0A%20%20%20viewBox%3D%220%200%200.72%200.72%22%0A%20%20%20xml%3Aspace%3D%22preserve%22%0A%20%20%20version%3D%221.1%22%0A%20%20%20id%3D%22svg4%22%0A%20%20%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%0A%20%20%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cdefs%0A%20%20%20%20%20id%3D%22defs8%22%20%2F%3E%3Cpath%0A%20%20%20%20%20d%3D%22M%200.63539429%2C0.16867393%20A%200.1725255%2C0.17252543%200%200%201%200.49969199%2C0.01587392%20V%200%20h%20-0.1240039%20v%200.49212763%20a%200.10424241%2C0.10424236%200%200%201%20-0.1872116%2C0.0627398%20l%20-7.2e-5%2C-3.6e-5%207.2e-5%2C3.6e-5%20A%200.10420641%2C0.10420637%200%200%201%200.30304959%2C0.39252865%20V%200.26654513%20A%200.22781429%2C0.2278142%200%200%200%200.10889089%2C0.65140678%200.22785029%2C0.2278502%200%200%200%200.49969199%2C0.4921636%20V%200.24070051%20a%200.2945136%2C0.29451347%200%200%200%200.1718056%2C0.0549288%20V%200.17241745%20a%200.17389332%2C0.17389325%200%200%201%20-0.0361033%2C-0.003744%20z%22%0A%20%20%20%20%20id%3D%22path2%22%0A%20%20%20%20%20style%3D%22stroke-width%3A0.0359952%22%20%2F%3E%3C%2Fsvg%3E%0A")
147 |     no-repeat;
148 | }
149 | 
150 | .header-tiktok-link:hover {
151 |   opacity: 0.6;
152 | }
153 | 
154 | .header-youtube-link::before {
155 |   content: '';
156 |   width: 24px;
157 |   height: 20px;
158 |   display: flex;
159 |   background: url("data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%20standalone%3D%22no%22%3F%3E%0A%3Csvg%0A%20%20%20viewBox%3D%220%200%2024%2024%22%0A%20%20%20version%3D%221.1%22%0A%20%20%20id%3D%22svg4%22%0A%20%20%20width%3D%2224%22%0A%20%20%20height%3D%2224%22%0A%20%20%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%0A%20%20%20xmlns%3Asvg%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cdefs%0A%20%20%20%20%20id%3D%22defs8%22%20%2F%3E%0A%20%20%3Cpath%0A%20%20%20%20%20d%3D%22M%2023.496693%2C5.8315054%20A%203.0042862%2C3.0042862%200%200%200%2021.393692%2C3.6909515%20C%2019.516013%2C3.1652014%2011.992781%2C3.1652014%2011.992781%2C3.1652014%20A%2072.040279%2C72.040279%200%200%200%202.6043863%2C3.6659158%203.1169469%2C3.1169469%200%200%200%200.488868%2C5.8315054%2032.884416%2C32.884416%200%200%200%206.7149698e-4%2C11.677346%2032.734202%2C32.734202%200%200%200%200.488868%2C17.523186%203.0418398%2C3.0418398%200%200%200%202.6043863%2C19.663739%20c%201.9027146%2C0.525751%209.3883947%2C0.525751%209.3883947%2C0.525751%20a%2072.215529%2C72.215529%200%200%200%209.400911%2C-0.500715%203.0042862%2C3.0042862%200%200%200%202.103001%2C-2.140554%2032.083273%2C32.083273%200%200%200%200.500714%2C-5.845839%2030.042862%2C30.042862%200%200%200%20-0.500714%2C-5.8708766%20z%20M%209.6018695%2C15.320042%20V%208.0346486%20l%206.2589285%2C3.6426974%20z%22%0A%20%20%20%20%20fill%3D%22%23959da5%22%0A%20%20%20%20%20id%3D%22path2%22%0A%20%20%20%20%20style%3D%22stroke-width%3A1.25179%22%20%2F%3E%0A%3C%2Fsvg%3E%0A")
160 |     no-repeat;
161 | }
162 | 
163 | .header-youtube-link:hover {
164 |   opacity: 0.6;
165 | }
166 | 
167 | header .container {
168 |   max-width: 9000px;
169 | }
170 | 
```

--------------------------------------------------------------------------------
/docs/static/img/product_1.svg:
--------------------------------------------------------------------------------

```
  1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?>
  2 | <!--
  3 |   ~ Copyright (c) 2023-2024 Datalayer, Inc.
  4 |   ~
  5 |   ~ BSD 3-Clause License
  6 | -->
  7 | 
  8 | <svg
  9 |    xmlns:dc="http://purl.org/dc/elements/1.1/"
 10 |    xmlns:cc="http://creativecommons.org/ns#"
 11 |    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 12 |    xmlns:svg="http://www.w3.org/2000/svg"
 13 |    xmlns="http://www.w3.org/2000/svg"
 14 |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 15 |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 16 |    viewBox="0 0 222.55631 184.29483"
 17 |    version="1.1"
 18 |    id="svg1377"
 19 |    sodipodi:docname="1.svg"
 20 |    inkscape:version="1.0.1 (c497b03c, 2020-09-10)"
 21 |    width="222.5563"
 22 |    height="184.29483">
 23 |   <metadata
 24 |      id="metadata1381">
 25 |     <rdf:RDF>
 26 |       <cc:Work
 27 |          rdf:about="">
 28 |         <dc:format>image/svg+xml</dc:format>
 29 |         <dc:type
 30 |            rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 31 |         <dc:title>Cloud_database_SVG</dc:title>
 32 |       </cc:Work>
 33 |     </rdf:RDF>
 34 |   </metadata>
 35 |   <sodipodi:namedview
 36 |      pagecolor="#ffffff"
 37 |      bordercolor="#666666"
 38 |      borderopacity="1"
 39 |      objecttolerance="10"
 40 |      gridtolerance="10"
 41 |      guidetolerance="10"
 42 |      inkscape:pageopacity="0"
 43 |      inkscape:pageshadow="2"
 44 |      inkscape:window-width="1349"
 45 |      inkscape:window-height="694"
 46 |      id="namedview1379"
 47 |      showgrid="false"
 48 |      inkscape:zoom="0.81391066"
 49 |      inkscape:cx="246.97899"
 50 |      inkscape:cy="87.469146"
 51 |      inkscape:window-x="173"
 52 |      inkscape:window-y="109"
 53 |      inkscape:window-maximized="0"
 54 |      inkscape:current-layer="svg1377"
 55 |      inkscape:document-rotation="0"
 56 |      fit-margin-top="0"
 57 |      fit-margin-left="0"
 58 |      fit-margin-right="0"
 59 |      fit-margin-bottom="0" />
 60 |   <defs
 61 |      id="defs847">
 62 |     <style
 63 |        id="style833">.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}.cls-3{fill:#d3d3dd;}.cls-4{fill:#e0dee9;}.cls-5{fill:#eaeaf4;}.cls-6{fill:#2a313f;}.cls-7{fill:#9052fe;}.cls-8{opacity:0.4;isolation:isolate;}.cls-9{clip-path:url(#clip-path-3);}.cls-10{fill:#d6d8e5;}.cls-11{clip-path:url(#clip-path-4);}.cls-12{fill:#3a2c6d;}.cls-13{fill:#ffcea9;}.cls-14{fill:#f4f4f4;}.cls-15{fill:#38226d;}.cls-16{fill:#9c73ff;}.cls-17{fill:#ff7b7b;}.cls-18{fill:#ededed;}.cls-19{fill:#8c50ff;}.cls-20{fill:#bfbfbf;}.cls-21{fill:#e5e5e5;}.cls-22{clip-path:url(#clip-path-5);}</style>
 64 |     <clipPath
 65 |        id="clip-path"
 66 |        transform="translate(-28.33 0)">
 67 |       <rect
 68 |          class="cls-1"
 69 |          width="500"
 70 |          height="500"
 71 |          id="rect835"
 72 |          x="0"
 73 |          y="0" />
 74 |     </clipPath>
 75 |     <clipPath
 76 |        id="clip-path-3"
 77 |        transform="translate(-28.33 0)">
 78 |       <rect
 79 |          class="cls-1"
 80 |          x="375.23999"
 81 |          y="440.37"
 82 |          width="36.549999"
 83 |          height="22.549999"
 84 |          id="rect838" />
 85 |     </clipPath>
 86 |     <clipPath
 87 |        id="clip-path-4"
 88 |        transform="translate(-28.33 0)">
 89 |       <rect
 90 |          class="cls-1"
 91 |          x="273.67001"
 92 |          y="353.35001"
 93 |          width="39.59"
 94 |          height="26.34"
 95 |          id="rect841" />
 96 |     </clipPath>
 97 |     <clipPath
 98 |        id="clip-path-5"
 99 |        transform="translate(-28.33 0)">
100 |       <rect
101 |          class="cls-1"
102 |          x="321.47"
103 |          y="473.66"
104 |          width="39.59"
105 |          height="26.34"
106 |          id="rect844" />
107 |     </clipPath>
108 |   </defs>
109 |   <title
110 |      id="title849">Cloud_database_SVG</title>
111 |   <path
112 |      class="cls-5"
113 |      d="m 199.73301,51.578526 0.37501,-0.16811 c 0.49137,-0.2069 0.98275,-0.38793 1.47413,-0.55603 h 0.0905 c 0.45259,-0.15518 0.90517,-0.28449 1.29311,-0.4138 h 0.28448 a 19.396569,19.396569 0 0 1 2.14655,-0.46551 h 0.15518 c 0.55603,-0.0776 1.11207,-0.12931 1.65517,-0.15518 h 0.32328 c 0.51724,0 1.02155,0 1.52586,0 h 0.32328 c 0.38793,0 0.76293,0.0776 1.13793,0.12931 h 0.34914 c 0.43965,0.0776 0.87931,0.18104 1.2931,0.29742 h 0.15517 a 10.538802,10.538802 0 0 1 1.07328,0.375 l 0.34914,0.15517 0.94396,0.45259 0.32328,0.1681 v 0 l -44.96125,-26.13364 -0.14224,-0.0776 -0.18103,-0.0905 -0.94397,-0.45258 -0.18103,-0.0905 -0.15518,-0.0647 c -0.36207,-0.14224 -0.72413,-0.27155 -1.0862,-0.375 h -0.12931 v 0 c -0.42673,-0.11638 -0.85345,-0.21982 -1.29311,-0.29741 h -0.12931 -0.21983 c -0.375,0 -0.75,-0.10345 -1.13793,-0.12931 h -0.21983 -0.10345 c -0.50431,0 -1.00862,0 -1.52586,0 h -0.11638 -0.2069 c -0.5431,0 -1.0862,0.0776 -1.65517,0.15517 h -0.12931 a 19.396569,19.396569 0 0 0 -2.14655,0.46552 h -0.16811 -0.11638 c -0.45258,0.12931 -0.90517,0.25862 -1.2931,0.41379 h -0.0905 c -0.49138,0.16811 -0.98276,0.34914 -1.47414,0.55604 h -0.11638 l -0.25862,0.12931 c -0.45258,0.19396 -0.9181,0.40086 -1.38362,0.63362 -0.15517,0.0776 -0.32328,0.14224 -0.47845,0.23276 -0.5819,0.28448 -1.16379,0.59483 -1.74569,0.94396 l 45.03883,26.17244 c 0.5819,-0.34914 1.1638,-0.65948 1.74569,-0.94396 l 0.47845,-0.23276 c 0.46552,-0.23276 0.91811,-0.43966 1.38363,-0.63362"
114 |      id="path1059"
115 |      style="stroke-width:1.2931" />
116 |   <path
117 |      class="cls-5"
118 |      d="m 173.41834,41.906096 c 0.75,-0.3362 1.5,-0.64655 2.22414,-0.90517 h 0.10344 a 18.439671,18.439671 0 0 1 1.9138,-0.53017 q 0.5431,-0.14224 1.08621,-0.23276 a 12.788804,12.788804 0 0 1 1.2931,-0.2069 l 0.77586,-0.0776 q 0.85345,0 1.66811,0 h 0.375 a 15.168117,15.168117 0 0 1 1.91379,0.24568 l 0.46552,0.10345 a 13.745702,13.745702 0 0 1 1.42242,0.4138 l 0.43965,0.15517 a 11.340527,11.340527 0 0 1 1.69397,0.81466 l -44.90952,-26.25003 -0.14224,-0.0776 a 13.47415,13.47415 0 0 0 -1.29311,-0.63363 l -0.25862,-0.0905 -0.43965,-0.15518 -0.67242,-0.24569 -0.76293,-0.15517 -0.46552,-0.10345 h -0.24569 a 15.20691,15.20691 0 0 0 -1.56466,-0.19396 h -0.47844 c -0.40087,0 -0.81466,0 -1.29311,0 -0.14224,0 -0.28448,0 -0.43965,0 l -0.77587,0.0905 c -0.23276,0 -0.46551,0 -0.7112,0 l -0.64656,0.14224 q -0.5431,0.0905 -1.0862,0.23276 l -0.56897,0.11638 c -0.43966,0.11637 -0.89224,0.25862 -1.2931,0.41379 h -0.10345 c -0.50431,0.18103 -1.02156,0.375 -1.55173,0.59483 l -0.67241,0.29741 -0.63362,0.29741 c -0.7888,0.36207 -1.57759,0.76294 -2.37932,1.22845 -0.80172,0.46552 -1.93965,1.18966 -2.89655,1.87501 l 45.03883,26.17243 c 0.94397,-0.67241 1.9138,-1.2931 2.89656,-1.875 0.98276,-0.5819 1.59051,-0.86638 2.37931,-1.22845 l 0.63362,-0.28448"
119 |      id="path1061"
120 |      style="stroke-width:1.2931" />
121 |   <path
122 |      class="cls-5"
123 |      d="m 130.8752,29.143156 0.68534,-0.29741 c 0.67242,-0.27156 1.29311,-0.53018 2.00431,-0.76294 l 0.375,-0.14224 c 0.7888,-0.25862 1.56466,-0.50431 2.34052,-0.7112 0.43966,-0.12931 0.89224,-0.23276 1.29311,-0.33621 l 0.65948,-0.15517 c 0.63362,-0.14225 1.29311,-0.24569 1.875,-0.34914 h 0.10345 c 0.62069,-0.0905 1.29311,-0.15517 1.83621,-0.21983 h 0.5819 1.07327 a 23.379331,23.379331 0 0 1 2.52156,0 h 0.43965 c 0.65949,0 1.29311,0.12931 1.96552,0.23276 l 0.60776,0.10345 a 19.668121,19.668121 0 0 1 2.19828,0.50431 h 0.0905 a 19.875018,19.875018 0 0 1 1.97845,0.69827 l 0.55603,0.23276 c 0.55604,0.24569 1.08621,0.50431 1.61638,0.7888 l 0.51724,0.27155 v 0 l -45.0259,-26.2112301 -0.23276,-0.12931 -0.28448,-0.14224 c -0.51724,-0.28449 -1.06035,-0.54311 -1.61638,-0.7888 l -0.27155,-0.14224 -0.25862,-0.0905 a 19.875018,19.875018 0 0 0 -1.97845,-0.69826999 h -0.0647 v 0 a 19.668121,19.668121 0 0 0 -2.22414,-0.53017 h -0.25862 -0.34914 c -0.64655,-0.10345 -1.2931,-0.18104 -1.96552,-0.23276 a 2.7543128,2.7543128 0 0 1 -0.32327,0 h -0.10345 a 23.637952,23.637952 0 0 0 -2.58621,0 h -0.23276 -0.82759 -0.58189 c -0.59483,0.0647 -1.21552,0.12931 -1.82328,0.21982 v 0 h -0.0905 c -0.62069,0.0905 -1.2931,0.2069 -1.875,0.34914 -0.23276,0 -0.45259,0.10345 -0.67241,0.15517 -0.21983,0.0517 -0.80173,0.18104 -1.20259,0.29741999 h -0.12931 c -0.77587,0.2069 -1.55173,0.45259 -2.34052,0.71121 l -0.375,0.14224 c -0.65949,0.23276 -1.29311,0.49138 -2.00431,0.76293 l -0.21983,0.0776 c -0.15517,0 -0.31035,0.15517 -0.46552,0.20689 -0.71121,0.31035 -1.42241,0.63362 -2.14655,0.98276 l -0.7888,0.38793 c -0.93103,0.46552 -1.86207,0.9569 -2.7931,1.5 -1.29311,0.73707 -2.58621,1.5388 -3.80173,2.37932 -0.43966,0.29741 -0.85345,0.63362 -1.2931,0.94396 -0.80173,0.56897 -1.61639,1.1379401 -2.40518,1.7456901 -0.50431,0.38794 -0.98276,0.81466 -1.47414,1.29311 -0.71121,0.56897 -1.42241,1.13793 -2.12069,1.74569 -0.69828,0.60776 -1.00862,0.94397 -1.52586,1.42242 -0.51725,0.47844 -1.29311,1.18965 -1.95259,1.82327 l -0.0905,0.0776 q -1.44828,1.46121 -2.87069,2.98707 l -0.77586,0.86638 c -0.36207,0.40086 -0.72414,0.78879 -1.08621,1.20259 -0.36207,0.41379 -0.46552,0.56896 -0.69828,0.85345 -0.67241,0.80172 -1.2931,1.60345 -1.99138,2.4181 -0.25862,0.33621 -0.5431,0.64656 -0.81466,0.99569 -0.27155,0.34914 -0.375,0.51725 -0.56896,0.77587 -0.49138,0.63362 -0.9569,1.2931 -1.43535,1.93965 -0.29741,0.4138 -0.60776,0.80173 -0.89224,1.21552 -0.28448,0.4138 -0.38793,0.59483 -0.59483,0.87931 -0.38793,0.56897 -0.77586,1.15087 -1.15086,1.73276 -0.375,0.5819 -0.5431,0.77587 -0.80173,1.17673 l -0.69827,1.15086 -0.98276,1.60345 -0.56897,0.94397 c -0.32327,0.5431 -0.60776,1.0862 -0.9181,1.62931 l -0.81466,1.47414 -0.32327,0.5819 c -0.43966,0.82758 -0.85345,1.6681 -1.29311,2.50862 l -0.50431,1.00862 -0.10345,0.2069 c -0.60776,1.2931 -1.17672,2.50862 -1.73276,3.77586 l -0.0776,0.19397 c -0.15518,0.34914 -0.28449,0.71121 -0.43966,1.07327 -0.36207,0.87932 -0.73707,1.7457 -1.07328,2.58621 -0.0776,0.18104 -0.12931,0.34914 -0.19396,0.53018 -0.21983,0.56896 -0.4138,1.15086 -0.62069,1.71983 -0.2069,0.56896 -0.46552,1.2931 -0.67242,1.86207 -0.0776,0.25862 -0.14224,0.50431 -0.21982,0.75 -0.23276,0.7112 -0.43966,1.42241 -0.65949,2.13362 -0.21983,0.71121 -0.3362,1.04741 -0.47845,1.57759 -0.0647,0.24569 -0.11638,0.49138 -0.18103,0.75 -0.24569,0.93103 -0.46552,1.84914 -0.68535,2.78017 -0.11638,0.49138 -0.25862,0.98276 -0.36207,1.46121 -0.10344,0.47845 -0.1681,0.80173 -0.24569,1.20259 -0.45258,0.25862 -0.9181,0.49138 -1.38362,0.76293 -0.98276,0.56897 -1.93965,1.18966 -2.89655,1.82328 l -0.49138,0.32327 c -0.91811,0.62069 -1.82328,1.29311 -2.71552,1.96552 l -0.51724,0.4138 c -0.90518,0.7112 -1.79742,1.43534 -2.67673,2.2112 l -0.23276,0.2069 c -0.85345,0.75 -1.68103,1.5388 -2.50862,2.34052 l -0.25862,0.23276 -0.15517,0.1681 c -0.63363,0.62069 -1.29311,1.29311 -1.87501,1.9138 l -0.0905,0.10345 c -0.5819,0.63362 -1.1638,1.2931 -1.73276,1.91379 l -0.15518,0.18104 -0.20689,0.24569 c -0.91811,1.07327 -1.82328,2.17241 -2.70259,3.29741 l -0.0776,0.0905 -0.19396,0.27155 c -0.69828,0.90517 -1.38363,1.83621 -2.04311,2.76725 l -0.2069,0.28448 a 0.53017288,0.53017288 0 0 0 -0.0905,0.14224 c -0.64656,0.90517 -1.29311,1.83621 -1.86207,2.76724 l -0.18104,0.27156 a 4.3577625,4.3577625 0 0 0 -0.24569,0.41379 q -0.73707,1.15086 -1.43534,2.32759 a 3.375003,3.375003 0 0 1 -0.21983,0.36207 l -0.0776,0.14224 c -0.53017,0.90517 -1.03448,1.82328 -1.52586,2.74138 l -0.11638,0.21983 -0.23276,0.45258 q -0.5819,1.099144 -1.125,2.211214 l -0.23276,0.46552 a 2.5862092,2.5862092 0 0 1 -0.12931,0.27155 c -0.43966,0.94397 -0.87931,1.875 -1.2931,2.8319 v 0.0776 l -0.11638,0.31034 c -0.375,0.85345 -0.72414,1.7069 -1.06035,2.58621 l -0.1681,0.42673 c 0,0.10345 -0.0776,0.23276 -0.12931,0.34914 -0.38794,1.00862 -0.75,2.03017 -1.09914,3.05172 v 0 l -0.0647,0.18104 c -0.34914,1.06034 -0.65948,2.12069 -0.96983,3.1681 a 3.6206929,3.6206929 0 0 1 -0.11638,0.40087 1.7586222,1.7586222 0 0 1 0,0.21982 c -0.3362,1.21552 -0.63362,2.44397 -0.90517,3.67242 l -0.0776,0.32328 c 0,0.1681 -0.0647,0.32327 -0.0905,0.47844 -0.10345,0.47845 -0.19397,0.96983 -0.27155,1.44828 -0.0776,0.47845 -0.12932,0.65949 -0.18104,0.99569 -0.0517,0.33621 -0.15517,0.98276 -0.23276,1.48707 0,0.23276 -0.0647,0.46552 -0.10345,0.69828 a 0.91810426,0.91810426 0 0 1 0,0.18103 c -0.12931,1.04742 -0.23276,2.10776 -0.31034,3.14225 v 0.67241 c 0,1.07328 -0.10345,2.14656 -0.10345,3.19397 0,7.97846 1.96552,14.3276 5.31466,18.76295 a 18.607775,18.607775 0 0 0 5.40518,4.83621 l 45.03883,26.15951 c -6.59483,-3.87932 -10.69398,-11.98708 -10.71984,-23.58623 0,-1.04742 0,-2.12069 0.10345,-3.19397 v -0.65948 c 0.0776,-1.04742 0.18104,-2.10776 0.31035,-3.15518 l 0.11638,-0.87931 c 0.0776,-0.50431 0.14224,-0.99569 0.23275,-1.48707 0.0905,-0.49138 0.11638,-0.65948 0.16811,-0.99569 0.0517,-0.33621 0.18103,-0.96983 0.28448,-1.44828 0,-0.27155 0.11638,-0.53017 0.1681,-0.80172 0.27156,-1.21552 0.56897,-2.44397 0.90518,-3.67242 0,-0.2069 0.11638,-0.41379 0.1681,-0.62069 0.31035,-1.06035 0.62069,-2.12069 0.96983,-3.16811 l 0.0776,-0.23276 c 0.3362,-1.03448 0.7112,-2.0431 1.09914,-3.06465 0.0905,-0.25862 0.19396,-0.51725 0.29741,-0.77587 0.33621,-0.85345 0.68534,-1.71983 1.04741,-2.58621 0.0647,-0.12931 0.11638,-0.25862 0.16811,-0.38793 0.41379,-0.95689 0.85345,-1.88793 1.2931,-2.8319 l 0.36207,-0.73707 q 0.54311,-1.09913 1.125,-2.2112 l 0.34914,-0.67242 c 0.49138,-0.9181 0.99569,-1.83621 1.52586,-2.72845 l 0.31035,-0.51724 c 0.45259,-0.78879 0.93103,-1.56466 1.42241,-2.34052 l 0.42673,-0.67241 c 0.59483,-0.93104 1.21552,-1.86208 1.86207,-2.78018 l 0.28448,-0.40086 c 0.67242,-0.94397 1.35776,-1.875 2.05604,-2.78018 l 0.27155,-0.36207 c 0.87931,-1.125 1.78448,-2.22414 2.70259,-3.29741 l 0.36207,-0.42673 c 0.56896,-0.64655 1.15086,-1.2931 1.73276,-1.90086 l 0.0905,-0.11638 c 0.62069,-0.64655 1.2931,-1.29311 1.875,-1.9138 l 0.41379,-0.40086 c 0.82759,-0.80172 1.66811,-1.59052 2.50862,-2.34052 l 0.23276,-0.20689 q 1.29311,-1.15087 2.67673,-2.211214 l 0.51724,-0.4138 c 0.89224,-0.68534 1.79742,-1.2931 2.71552,-1.96552 l 0.47845,-0.32327 c 0.9569,-0.63362 1.92673,-1.29311 2.89655,-1.81035 l 1.39656,-0.77586 c 0.18103,-0.87931 0.40086,-1.77155 0.60776,-2.6638 0.20689,-0.89224 0.43965,-1.84914 0.68534,-2.78017 0.24569,-0.93104 0.42673,-1.55173 0.65948,-2.32759 0.23276,-0.77586 0.42673,-1.42241 0.65949,-2.13362 0.23276,-0.71121 0.5819,-1.73276 0.89224,-2.58621 0.2069,-0.5819 0.40086,-1.16379 0.62069,-1.73276 0.40086,-1.06035 0.82759,-2.10776 1.2931,-3.15518 0.15518,-0.36207 0.28449,-0.73707 0.43966,-1.0862 0.60776,-1.40949 1.29311,-2.79311 1.9138,-4.17673 l 0.51724,-1.00862 c 0.50431,-1.03449 1.03448,-2.06897 1.59052,-3.09052 0.27155,-0.49138 0.5431,-0.98276 0.82758,-1.47414 q 0.72414,-1.29311 1.47414,-2.58621 c 0.32328,-0.54311 0.64655,-1.08621 0.98276,-1.61638 0.49138,-0.7888 0.98276,-1.56466 1.5,-2.32759 0.51724,-0.76293 0.76293,-1.16379 1.15087,-1.73276 0.38793,-0.56897 0.85344,-1.3319 1.38362,-1.97845 0.53017,-0.64655 0.94396,-1.29311 1.43534,-1.95259 0.49138,-0.65948 0.91811,-1.17672 1.38363,-1.75862 0.65948,-0.81466 1.2931,-1.61638 1.99138,-2.41811 0.69827,-0.80172 1.17672,-1.38362 1.78448,-2.05603 0.25862,-0.29742 0.51724,-0.5819 0.78879,-0.86638 q 1.43535,-1.57759 2.94828,-3.06466 c 0.64656,-0.63362 1.29311,-1.29311 1.97845,-1.84914 0.68535,-0.55604 0.98276,-0.94397 1.5,-1.38362 0.51725,-0.43966 1.42242,-1.18966 2.13363,-1.78449 0.49138,-0.40086 0.96983,-0.81465 1.46121,-1.20258 0.78879,-0.60776 1.60345,-1.17673 2.4181,-1.75863 l 1.29311,-0.93103 c 1.2931,-0.84052 2.5862,-1.64224 3.80172,-2.37931 0.93104,-0.54311 1.86207,-1.03449 2.79311,-1.50001 l 0.78879,-0.38793 c 0.72414,-0.34913 1.43535,-0.67241 2.14656,-0.96983"
124 |      id="path1063"
125 |      style="stroke-width:1.2931" />
126 |   <path
127 |      class="cls-3"
128 |      d="m 125.14674,32.000916 c 20.3664,-11.75432 37.50004,-5.59914 42.41383,13.29312 a 33.426754,33.426754 0 0 1 2.89656,-1.875 c 12.84053,-7.40949 23.50864,-2.84483 25.66812,10.00862 14.79312,-8.56035 26.43106,-1.84913 26.43106,14.61209 0,15.28449 -9.91811,33.620724 -22.81036,42.866414 -1.06035,0.75 -2.06897,1.42242 -3.10346,2.01725 a 30.750027,30.750027 0 0 1 -2.97414,1.51293 l -111.90527,64.8104 c -20.31467,11.72846 -36.82762,2.32759 -36.89227,-21.02588 -0.0647,-23.35347 16.33191,-51.72418 36.64658,-63.478504 0.47845,-0.28449 0.93104,-0.51725 1.39656,-0.77587 4.81035,-24.40088 21.85346,-50.22418 42.18107,-61.96557"
129 |      id="path1065"
130 |      style="stroke-width:1.2931" />
131 | </svg>
132 | 
```

--------------------------------------------------------------------------------
/docs/static/img/feature_3.svg:
--------------------------------------------------------------------------------

```
  1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?>
  2 | <!--
  3 |   ~ Copyright (c) 2023-2024 Datalayer, Inc.
  4 |   ~
  5 |   ~ BSD 3-Clause License
  6 | -->
  7 | 
  8 | <svg
  9 |    xmlns:dc="http://purl.org/dc/elements/1.1/"
 10 |    xmlns:cc="http://creativecommons.org/ns#"
 11 |    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 12 |    xmlns:svg="http://www.w3.org/2000/svg"
 13 |    xmlns="http://www.w3.org/2000/svg"
 14 |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 15 |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 16 |    viewBox="0 0 302.65826 398.12268"
 17 |    version="1.1"
 18 |    id="svg1054"
 19 |    sodipodi:docname="6.svg"
 20 |    inkscape:version="1.0.1 (c497b03c, 2020-09-10)"
 21 |    width="302.65826"
 22 |    height="398.12268">
 23 |   <metadata
 24 |      id="metadata1058">
 25 |     <rdf:RDF>
 26 |       <cc:Work
 27 |          rdf:about="">
 28 |         <dc:format>image/svg+xml</dc:format>
 29 |         <dc:type
 30 |            rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 31 |         <dc:title>Web_SVG</dc:title>
 32 |       </cc:Work>
 33 |     </rdf:RDF>
 34 |   </metadata>
 35 |   <sodipodi:namedview
 36 |      pagecolor="#ffffff"
 37 |      bordercolor="#666666"
 38 |      borderopacity="1"
 39 |      objecttolerance="10"
 40 |      gridtolerance="10"
 41 |      guidetolerance="10"
 42 |      inkscape:pageopacity="0"
 43 |      inkscape:pageshadow="2"
 44 |      inkscape:window-width="1256"
 45 |      inkscape:window-height="607"
 46 |      id="namedview1056"
 47 |      showgrid="false"
 48 |      inkscape:zoom="0.83484846"
 49 |      inkscape:cx="105.00162"
 50 |      inkscape:cy="149.95736"
 51 |      inkscape:window-x="0"
 52 |      inkscape:window-y="25"
 53 |      inkscape:window-maximized="0"
 54 |      inkscape:current-layer="svg1054"
 55 |      inkscape:document-rotation="0"
 56 |      fit-margin-top="0"
 57 |      fit-margin-left="0"
 58 |      fit-margin-right="0"
 59 |      fit-margin-bottom="0" />
 60 |   <defs
 61 |      id="defs843">
 62 |     <style
 63 |        id="style833">.cls-1{fill:none;}.cls-2,.cls-9{fill:#d6d8e5;}.cls-2{opacity:0.4;isolation:isolate;}.cls-3{fill:#d5d6e0;}.cls-4{fill:#e9eaf4;}.cls-5{fill:url(#Безымянный_градиент_15);}.cls-6{clip-path:url(#clip-path);}.cls-7{fill:#8c50ff;}.cls-8{opacity:0.05;}.cls-9{opacity:0.4;}.cls-10{fill:#c5c7d3;}.cls-11{fill:#38226d;}.cls-12{fill:#9c73ff;}.cls-13{fill:#ffcea9;}.cls-14{fill:#ededed;}.cls-15{fill:#e5e5e5;}.cls-16{fill:#f4f4f4;}.cls-17{fill:#bfbfbf;}.cls-18{fill:#3a2c6d;}.cls-19{fill:#dceeff;}.cls-20{fill:#dbdbdb;}.cls-21{fill:#1e3779;}.cls-22{fill:#031f60;}</style>
 64 |     <linearGradient
 65 |        id="Безымянный_градиент_15"
 66 |        x1="235.62"
 67 |        y1="356.16"
 68 |        x2="235.62"
 69 |        y2="256.92999"
 70 |        gradientUnits="userSpaceOnUse">
 71 |       <stop
 72 |          offset="0"
 73 |          stop-color="#e9eaf4"
 74 |          id="stop835" />
 75 |       <stop
 76 |          offset="0.63"
 77 |          stop-color="#e9eaf4"
 78 |          stop-opacity="0"
 79 |          id="stop837" />
 80 |     </linearGradient>
 81 |     <clipPath
 82 |        id="clip-path">
 83 |       <circle
 84 |          class="cls-1"
 85 |          cx="233.41"
 86 |          cy="151.32001"
 87 |          r="151.32001"
 88 |          id="circle840" />
 89 |     </clipPath>
 90 |   </defs>
 91 |   <title
 92 |      id="title845">Web_SVG</title>
 93 |   <path
 94 |      class="cls-2"
 95 |      d="m 146.09827,397.27001 -47.08,-27.18 c -2.19,-1.27 -1.92,-3.48 0.62,-4.94 l 45.84,-26.47 c 2.54,-1.46 6.37,-1.62 8.56,-0.36 l 47.07,27.18 c 2.2,1.27 1.92,3.48 -0.61,4.94 l -45.85,26.47 c -2.53,1.47 -6.36,1.62 -8.55,0.36 z"
 96 |      id="path847" />
 97 |   <path
 98 |      class="cls-3"
 99 |      d="m 205.92827,362.37001 -2.32,0.71 -46.23,-26.7 c -2.19,-1.26 -6,-1.1 -8.55,0.36 l -45.85,26.47 -0.11,0.07 -2,-0.61 v 3 c 0,0.11 0,0.22 0,0.34 v 0.24 0 a 2.68,2.68 0 0 0 1.44,1.86 l 47.08,27.18 c 2.19,1.26 6,1.11 8.56,-0.36 l 45.98,-26.43 a 3.62,3.62 0 0 0 2.08,-2.64 v 0 z"
100 |      id="path851" />
101 |   <path
102 |      class="cls-4"
103 |      d="m 149.47827,392.14001 -47.08,-27.14 c -2.19,-1.26 -1.91,-3.47 0.62,-4.93 l 45.85,-26.47 c 2.53,-1.47 6.36,-1.63 8.55,-0.36 l 47.08,27.18 c 2.19,1.27 1.92,3.48 -0.62,4.94 l -45.84,26.47 c -2.54,1.42 -6.37,1.58 -8.56,0.31 z"
104 |      id="path853" />
105 |   <path
106 |      class="cls-3"
107 |      d="m 173.30827,360.25001 v -4 l -5.7,1.5 -12.68,-7.35 a 3.58,3.58 0 0 0 -3.24,0.14 l -12.33,7.12 -5.83,-1.54 v 4.31 0 1 a 0.48,0.48 0 0 0 0,0.12 v 0.1 0 a 1,1 0 0 0 0.55,0.7 l 17.85,10.39 a 3.61,3.61 0 0 0 3.24,-0.13 l 17.38,-10 a 1.36,1.36 0 0 0 0.78,-1 v 0 -1.32 z"
108 |      id="path855" />
109 |   <path
110 |      class="cls-4"
111 |      d="m 151.92827,367.33001 -17.87,-10.33 c -0.83,-0.48 -0.73,-1.32 0.23,-1.87 l 17.38,-10 a 3.58,3.58 0 0 1 3.26,-0.13 l 17.84,10.3 c 0.83,0.48 0.73,1.32 -0.23,1.88 l -17.38,10 a 3.61,3.61 0 0 1 -3.23,0.15 z"
112 |      id="path857" />
113 |   <polygon
114 |      class="cls-5"
115 |      points="129.08,260.92 342.17,256.93 235.48,356.16 "
116 |      id="polygon859"
117 |      style="fill:url(#%D0%91%D0%B5%D0%B7%D1%8B%D0%BC%D1%8F%D0%BD%D0%BD%D1%8B%D0%B9_%D0%B3%D1%80%D0%B0%D0%B4%D0%B8%D0%B5%D0%BD%D1%82_15)"
118 |      transform="translate(-82.071732)" />
119 |   <circle
120 |      class="cls-4"
121 |      cx="151.33827"
122 |      cy="151.32001"
123 |      r="151.32001"
124 |      id="circle861" />
125 |   <g
126 |      class="cls-6"
127 |      clip-path="url(#clip-path)"
128 |      id="g865"
129 |      transform="translate(-82.071732)">
130 |     <path
131 |        class="cls-7"
132 |        d="m 193.61,61.31 0.51,-1.79 v -3.88 a 9.1,9.1 0 0 1 -2.17,0 c -0.38,-0.17 -0.12,-1.23 -0.12,-1.23 l 1.78,-1.53 -3.57,0.76 -1.28,2 -4,2.47 1.79,1 1.15,0.76 1.72,0.26 V 59 c 0,0 2,-0.51 2.49,0.25 l -0.25,1.41 z m -44.22,55.25 a 4,4 0 0 0 -3.28,0 2.7,2.7 0 0 1 -2.56,2.73 l 2.92,1.63 2.92,-1.09 3.82,-1.27 3.27,2.36 c 2.46,-3 0,-4.73 0,-4.73 z m 62.1,55.63 -2.83,-0.6 -4.92,-3.13 h -4.32 l -3.73,-1.93 h -1.49 c 0,0 0.3,-2.09 -0.15,-2.24 -0.45,-0.15 -3.72,-2.09 -3.72,-2.09 l -2.54,0.9 v -1.79 L 186,160.56 h -1.49 l 0.15,-1.49 c 0,0 1.34,-2.65 0.6,-3.11 -0.74,-0.46 -3.43,-6.43 -3.43,-6.43 l -4,-2.24 h -4.32 l -3.73,-3.57 -3.72,-3 -2.09,-4.17 -2.24,-1.49 c 0,0 -2.53,1.64 -3.13,1.49 -0.6,-0.15 -4.32,-1.49 -4.32,-1.49 l -3.13,-0.89 -3.91,1.83 v 4 l -1.49,-1 v -1.5 l 1.34,-2.53 0.6,-1.79 -1.34,-0.59 -1.79,2.08 -4.47,1.33 -0.9,1.94 -2.09,1.94 -0.59,1.64 -3.28,-2.68 a 25.86,25.86 0 0 1 -3.43,1.49 22.67,22.67 0 0 1 -3.43,-2.24 l -1.64,-3.13 0.9,-4.17 0.74,-4.33 -2.83,-1.79 h -3.88 l -3,-0.89 1.4,-2.24 1.64,-3.28 1.49,-2.38 c 0,0 0.42,-1.09 0.9,-1.34 1.9,-1 0,-1.94 0,-1.94 0,0 -4,-0.3 -4.62,0 -0.62,0.3 -2.24,1.19 -2.24,1.19 v 3.43 l -3,1.34 -4.62,1 -3.13,-3.14 c -0.45,-0.44 -1.49,-5.36 -1.49,-5.36 l 1.93,-1.79 0.9,-1.49 0.15,-3.58 1.93,-1.64 0.75,-1.94 2.22,-1.62 2.13,-1.95 1.94,-0.59 2.68,0.59 c 0.15,-1 5.07,-0.59 5.07,-0.59 l 1.94,-0.1 -0.15,-1.09 c 0.9,-0.75 3.88,0 3.88,0 l 4,-0.34 c 0.88,0.19 1.62,2 1.62,2 l -1.79,1.64 1.2,3.57 1.19,1.64 -0.45,1.64 2.87,-0.42 0.14,-2.84 0.15,-3.43 -0.44,-3.76 2.68,-5.78 10.88,-7 -0.15,-2.54 0.74,-3.65 1.94,0.67 3.43,-4.07 4.47,-1.48 2.68,-1.82 1.34,-2.69 4.92,-1.49 4.92,-2.53 -2.26,2.41 -0.3,2.09 2.68,-0.6 2.39,-1.34 2.08,-0.59 1.94,-0.6 0.6,-1.34 h -1.34 l -2.09,-0.75 c -1.34,-1 -3,-2.23 -3,-2.23 a 1.66,1.66 0 0 1 0.44,-1.35 l 1.35,-1.21 c 0,0 0.59,-0.72 0.15,-0.72 -0.44,0 -3.73,-0.72 -3.73,-0.72 l -3.58,1.44 H 168 l 2.54,-2.21 h 2.83 l 3.13,-0.75 h 6.5 l 3.58,-0.75 2.55,-1.37 3.13,-0.32 0.9,-3.35 -2.09,-1.79 -3,-1.55 -0.6,-3.52 -1.64,-3.58 h -1.49 l -0.15,1.94 h -2.38 l -0.6,1.19 -0.89,0.6 -1.49,0.3 -1.35,-1.35 1.79,-1.93 -2.08,-1.94 -2.54,-1.34 H 172 l -1.94,0.59 -2.68,3.13 -2.09,1.79 0.6,1.64 -0.76,2.49 0.45,1 -2.54,0.9 -2.83,0.59 -1.19,-0.3 -0.9,1.2 1.64,3.35 -1.64,1.12 h -2.23 l -1.89,-2.43 -0.45,-1.38 1.34,-0.74 -1,-1.79 -3.13,-0.15 -4.17,-2.59 h -1.94 l -0.45,-1.59 -1.34,-0.59 1.94,-2.24 2.83,-1.64 4.92,-1.79 1.79,-0.59 3.87,-0.9 2.6,-1.44 2.38,-2.09 h 3.13 l 3.73,-0.94 h 2.39 l 1,-2.78 h -4.77 l -1.79,1.37 h -3.13 a 5.39,5.39 0 0 0 -2.39,-1.37 v -1.1 L 163,24.6 165.13,23 a 12.52,12.52 0 0 0 1.79,-3.13 l 4.32,1.49 3.27,-0.17 2.25,-0.13 2.83,0.15 1.64,0.6 C 180.78,23 179,22.11 179,22.11 l -5.37,0.59 -2.39,1.3 0.75,-1.64 -2.54,0.15 -3,1.93 2.09,1.05 h 4.92 l 2.68,1 2.24,-0.89 2.38,0.89 2.84,0.9 -3.43,1.07 -3,1.41 -3,0.65 -2.38,0.89 1,2.09 6.26,-0.15 3.95,2.43 h 3.28 l 0.15,-1.34 0.74,-1.34 1.5,-0.3 -1.2,-1.64 -0.44,-1.24 h 1.78 l 1.5,1.09 c 1.19,0.9 3.72,-1.09 3.72,-1.09 l 3.73,-1.41 -4.62,-1.37 -1.49,-1.49 c 0.44,-0.9 -4.92,-2.24 -4.92,-2.24 l -4.18,-2.53 -1,-1.05 H 178 l 2.83,-1.19 7,0.74 3.58,-0.74 8.2,-2.09 5.51,-1.34 4,-1.49 -4.63,-0.72 c 0,0 -3.28,0.9 -3.73,0.9 -0.45,0 -4.32,0.3 -4.77,0.3 h -10.44 l -3.27,0.74 3,0.75 -1.79,1 h -3.13 l -1.79,0.6 2.38,-1.79 -2.53,-0.6 -3.13,0.75 -3.88,2.23 1.79,0.6 2.54,-1 2.08,0.44 -2.23,0.6 -0.9,1.19 1.49,0.45 -4,0.3 -7,-1.19 -2.83,3.42 -5.67,4 1.49,1.19 -1,0.63 -1.34,0.71 -3.88,-0.71 -5.36,1.41 -9.84,2.43 -32.89,31.65 -13.12,17.1 -7.6,45.62 13.41,-4.47 2.09,2.23 3.58,0.6 2.39,1.49 2.53,-1 2.09,2.09 4.62,2.53 4.47,0.6 1.34,4.47 c 0,0 3.13,2.83 3,3.43 -0.13,0.6 1,3.28 1.64,3.28 a 30.41,30.41 0 0 1 4.33,1.94 l 2.53,0.29 1,-2.23 h 2.24 v 2.23 l 0.9,1.2 0.29,3.42 0.9,2.54 -1.49,1.64 -2.39,3 -3,4.15 0.3,4 -0.75,2.68 2.24,0.75 -1.94,2.68 a 32.55,32.55 0 0 1 1,3.43 30.41,30.41 0 0 0 1.64,3.73 l 2.09,1.79 0.9,3 5.07,10.43 3.57,3 5.22,2.68 c 0,0 3.28,3.58 3.43,4 0.15,0.42 1.79,7 1.79,7 l -0.07,3.68 1,3.87 0.3,4.33 0.45,6 1.49,4.77 1.34,6.26 -1.64,2.09 1,3.09 1.2,4.51 2.08,2.24 -0.14,3.58 0.44,3.57 9.25,9.84 1.78,1.94 v -4.62 l 3,-1.73 V 260 l -3,-1.34 v -2.09 l 1.47,-1.34 1.48,-2.39 v -1.94 c 1.23,-0.44 0,-1.79 0,-1.79 h -2.05 l -0.15,-1.93 h 1.94 l 0.8,1.19 1.58,-1.19 -1.19,-2.39 c 0.3,-0.74 2.39,0 2.39,0 l 3.57,-2.09 1.49,-1.78 v -2.39 l -1.7,-1.53 -1.5,-2.39 h 1.79 c 0,0 2.69,1.35 3.58,0 0.89,-1.35 2.24,-3.13 2.24,-3.13 l 1.64,-3.87 c 0,0 3.13,-5.67 3.43,-6.12 0.3,-0.45 0,-6 0,-6 l 4.92,-4 h 3.28 l 2.68,-4.48 2.09,-2.94 v -5 l 0.74,-4.77 -1.17,-4.66 4.17,-5.52 3.13,-3 -1.19,-5.51 z m 33.22,-117.14 1,0.59 1.36,-1.18 H 249 l 1.78,-0.6 0.8,-1.1 -0.38,-1.52 0.76,-0.81 c 0.09,-1.23 -1.86,-2.12 -1.86,-2.12 H 249 l -1.36,1.57 h -1.44 l -0.51,1.78 0.21,1.34 -1.19,0.74 z m 9,-11.27 -1.52,0.68 v 2.29 l 0.17,0.59 a 6.36,6.36 0 0 1 0.17,1.44 l 1.1,0.76 1.1,0.68 0.51,1 c 0,0 -0.17,1 -0.76,1 l -1.36,0.43 0.51,0.93 c 0,0 -1,0.51 -1.1,0.93 l 0.93,0.68 -0.85,0.85 h -1.86 v 1 h 3.9 l 1.61,-1 2.37,0.42 c 0,0 1.95,-0.42 2.37,-0.42 l -0.34,-0.43 0.6,-0.76 0.93,-1.1 -1.19,-0.48 -1.35,-0.42 0.17,-1.44 -1.28,-1 -1.94,-1.06 -0.17,-1.95 0.17,-1.69 h -2 l -0.42,-0.85 1.18,-1.1 z m 11.1,7.29 v -0.64 h 1.36 l 0.76,-0.55 v -2.12 l 1,-0.51 -0.34,-1.69 L 266,45 263.63,45.76 V 47 l -1.44,0.17 -0.34,1.14 1,0.47 -1.61,0.93 -0.43,0.72 1.36,1.23 z m 85.4,51.37 5.6,3 2.89,-1.12 1.92,2.72 5.13,1.13 2.24,1.12 6.25,-16.5 L 372.45,84 344.45,38.18 336.09,32 H 326 l -4.49,0.64 -1.12,2.25 L 318,34 316.87,32 h -2.25 l 1,1 0.81,1.61 h -3.53 v 1.99 l -2.52,-0.37 -1.53,-0.23 -0.86,0.43 0.8,0.49 1.11,0.25 -1,0.37 -1.34,-0.54 -1.67,-0.56 -0.5,-1.11 -1.23,-1 -1.42,-0.19 -1,-1.17 2.18,0.19 1.92,0.86 1.6,0.19 c 0,0 1,-0.25 1.11,-0.07 a 12.12,12.12 0 0 0 2.35,0.5 l 1.3,-1 L 310,32 306.1,30.85 c 0,0 -6.55,-0.9 -6.86,-0.88 a 28.82,28.82 0 0 1 -2.78,-0.85 l -2.28,-0.56 h -5 l -7.6,1.41 -4.2,2.67 -5,4.51 c 0,0 -4.32,0.62 -4.57,0.68 a 20.44,20.44 0 0 0 -2,1.91 l 1,2.17 -0.25,1 0.68,0.55 0.5,1 0.68,1 2.53,-0.37 2.72,-1.54 2.1,1.11 1.61,2.78 0.3,1.67 1.92,0.37 1.3,-1.11 1.36,-0.31 0.74,-2.23 0.12,-1.73 1.86,-0.55 v -1.14 l -0.68,-0.49 -0.87,-0.56 -0.31,-2 2.83,-1.79 c 0,0 1.5,-1.36 1.5,-1.6 0,-0.24 0.74,-1.06 0.74,-1.06 0,0 1.18,0.07 1.55,0.13 0.37,0.06 2.34,0.74 2.34,0.74 l 0.75,0.56 -2.29,1.11 -1.35,1.36 -1,0.68 0.93,2.16 2.41,1.24 2.16,-0.44 3.43,-0.17 c 0,0 3.93,0.79 4.11,0.92 0.18,0.13 -1.13,0.55 -1.13,0.55 l -1.53,0.43 -0.86,0.56 -1.46,-0.74 a 3.78,3.78 0 0 0 -2,-0.68 4.09,4.09 0 0 0 -1.67,0.74 l 0.93,1.42 1.29,0.87 c 0,0 -0.55,1.6 -0.92,1.54 -0.37,-0.06 -1,-1.17 -1,-1.17 l -1.43,-0.56 -0.92,0.56 0.12,1.36 -0.4,1.92 -1.54,1 -1.12,0.87 -1.48,-0.87 -1.91,0.56 -3.34,-0.19 -0.74,0.68 -1.18,-0.49 c 0,0 -2.9,0.12 -3.09,0.18 a 10.62,10.62 0 0 1 -1.91,-0.86 l -0.74,-1.67 0.92,-1.11 -0.43,-0.5 -0.37,-0.92 0.62,-0.75 -0.93,-0.24 -1.54,1.54 -0.07,1.49 c 0,0 0.81,1.42 0.87,1.6 a 10.91,10.91 0 0 1 0.06,1.3 l -1.54,0.62 -1.67,0.49 0.31,0.62 1.73,0.31 -0.56,1 -1,0.06 -0.49,1.24 -0.31,0.68 -1.36,-0.37 0.06,-0.87 0.06,-1.11 -0.55,-0.56 -0.81,0.87 -0.86,1.11 -2,0.37 -0.93,0.74 -0.62,1.36 -1.48,0.68 -1.17,0.74 -1.8,-0.92 -0.24,0.43 0.37,0.92 -0.43,0.5 -2.35,-0.19 -1.3,-0.18 -0.19,1.73 2.29,0.37 1.11,1 c 0,0 0.87,1.05 1.05,1.05 0.18,0 1.43,0.31 1.43,0.31 l -0.5,2.72 -1.11,2.53 -4.64,-0.06 -5.06,-0.12 -1.61,0.61 -0.5,1.42 0.44,1.49 -0.9,2.79 -0.37,1.49 A 7,7 0 0 0 245,79 c 0.13,0.18 0.87,1.48 0.87,1.48 l -0.56,2.1 1.67,0.5 2.78,0.18 1.48,0.81 1.12,0.18 1.85,-0.8 2.78,-0.37 2.6,-2.17 -0.43,-2.65 1.6,-2 3.4,-2.16 1.48,-0.5 -0.18,-2.22 2,-1.18 2,0.68 1.36,-0.74 4.18,-1.28 4.14,3.84 5.68,3.89 c 0,0 1.18,2.35 1.24,2.53 a 7.8,7.8 0 0 1 -0.87,1.61 H 282 L 280.8,81 c 0,0 0.37,0.92 0.61,0.92 0.24,0 2.79,1.18 2.79,1.18 l 0.8,0.62 0.43,-0.13 -0.06,-1.17 0.25,-1.11 0.86,0.12 a 7.77,7.77 0 0 0 0.93,-1.24 17.93,17.93 0 0 1 0.86,-1.6 l -0.68,-1.3 0.38,-0.87 1,0.13 0.12,1.29 h 0.37 l -0.12,-1.6 -1,-0.62 -1.34,-0.89 -0.93,-1.35 -1.61,-0.44 -1.85,-1.3 -0.43,-1.48 -1.73,-0.86 -0.45,-1.36 0.5,-1.42 1.11,-0.19 0.06,1.36 1.11,0.37 0.75,-0.43 1.11,2 2.28,1.29 1.8,1 1.11,0.13 1.85,2 0.44,2.59 3.46,3.59 0.49,2.53 2.47,1.73 0.68,-0.56 -0.37,-1.6 1.36,-0.74 -0.43,-1.12 -1.37,-0.47 -0.56,-1.11 0.25,-1.8 -0.8,-0.92 0.74,0.12 0.55,0.68 0.87,-1.17 1.48,0.06 1.49,0.62 1.11,1.52 1.11,1.73 c 0,0 -0.74,0.43 -0.8,0.62 a 5.76,5.76 0 0 0 0.62,1.36 l 1.42,1.29 1.11,1.56 2.22,-0.06 0.25,1 1.61,0.18 1.54,-0.37 0.44,-1 0.68,0.93 1.79,0.62 1.54,-0.12 1.3,-1.43 1.17,0.5 0.87,0.55 -0.31,1.24 0.77,2.54 -1.12,2.57 v 3.36 H 313 l -2.56,1 -4.15,-2.07 -2.72,0.32 -4.65,-1.68 h -3.68 l -1.12,1 0.64,1.76 -1.44,1.44 -3.69,-2.24 -2.88,0.32 -1.12,-1.92 -4.33,-1.28 -3,-1.45 -2.3,-1.13 2.72,-3.2 -0.8,-2.72 -1.92,-1.13 -11.38,1.29 -4.48,1.76 -2.89,0.16 -3,1.12 -3.68,-0.8 -2.41,2.88 -2.88,1.92 -2.6,5.45 0.68,2.4 -3.36,3.21 -3.36,1.28 -1.6,3 c 0,0 -3.53,7.85 -3.85,8.49 -0.32,0.64 0,4.65 0,4.65 l 1.44,4.64 -3.36,7.85 1.28,3.85 1.76,3.52 4.65,6.57 -0.14,3.3 9.58,7.29 6.42,-2.58 6.18,1.96 7.68,-4.16 c 0,0 2.57,0 3.37,1.44 0.8,1.44 1.28,2.72 4.16,2.72 2.88,0 4.33,3.21 4.33,3.21 l -1.28,3.68 0.64,1.92 -1.28,3 3,6.24 2.24,4.81 2.09,4.65 0.48,5.92 -0.48,3.37 -3.69,6.41 1.12,9.93 1.28,2.24 -0.48,1.6 2.25,2.41 1.44,5.6 -1.28,5.29 1.92,4 c 0,0 1.76,4.33 1.92,4.81 0.16,0.48 1.28,2.08 1.12,2.88 -0.16,0.8 -0.64,2.89 -0.64,2.89 l 0.64,3.2 h 8.17 l 3.85,-1.28 9.45,-9.61 a 33.38,33.38 0 0 0 2.4,-3.21 c 0.16,-0.48 1.44,-6.4 1.44,-6.4 l 6.73,-2.73 v -5.23 l -1.44,-4.17 3.52,-3.36 9.46,-5.45 0.8,-4.17 v -7.2 l -1.77,-5.45 0.48,-3.36 -1.64,-1.45 3.05,-4.48 1.12,-5.93 4.48,-2.72 1,-2.73 8.17,-8.47 2.57,-8.06 2.4,-4.45 v -4.81 l -2.24,-0.8 -2.57,2.08 -3.52,0.32 -3.68,1 -3.05,-1.93 0.32,-2.4 -3.84,-4.32 -3.85,-2.25 -1.76,-5.76 -3.76,-3.58 -0.32,-4.48 -2.73,-4 -3.36,-7.52 -3.53,-3.52 -0.32,-2.89 1.45,0.65 1.6,1.28 0.64,2.4 c 1.28,0 1.76,-2.4 1.76,-2.4 l 1.12,2.4 1.12,3 3.53,5.29 1.92,0.32 2.24,4.65 -0.16,2.08 6.41,6.57 1.12,6.08 1.12,2.73 2.09,2.08 9.61,-2.56 1.76,-1.61 4.16,-1.28 v -1.92 l 4.81,-3.2 2,-3.72 1.16,-1.89 1.61,-2.24 -2.65,-3.65 -3.7,-3.13 -1.18,-3 -1.36,0.32 -1,2.88 -3.68,1 -1.92,-1.28 -0.64,-2.72 -1.28,0.32 -1.93,-3.53 -1.76,-1 -2.24,-3.68 1.6,-1.77 z m -75,-23.58 0.34,-1.53 v -2.2 a 2.47,2.47 0 0 0 -1.23,-0.59 1.34,1.34 0 0 0 -0.89,0.34 v 2.45 a 4.55,4.55 0 0 1 0,1.53 z m 0,-6.61 a 1,1 0 0 0 -1.19,-1.1 l -0.59,1.69 0.59,1.27 h 1.19 z m -43.7,-43.74 h 6 l 3.27,-4.71 h 7.53 l 1.21,-3 4.63,-2.45 c 2.19,-1.91 0,-5.37 0,-5.37 l -9.44,-1.18 -9.38,1.2 -13.64,-2.54 -8.41,3.82 c 0,0 -6.05,3.27 -6.87,3 a 29.17,29.17 0 0 0 -4.91,0 c 0,0 3,2.72 4.36,3 a 9.87,9.87 0 0 0 3,0 c 0,0 0,2.66 0.55,3.27 1.91,2.19 -2,3.82 -2,3.82 v 2.58 c 0,0 0.43,4.87 1.24,4.65 0.81,-0.22 2.14,2.59 2.14,2.59 l 2.51,2.73 h 4.59 l 5.73,-6.82 5.64,-2.45 z m 117.85,163.14 -0.74,-2 -0.85,0.84 -0.74,1.7 -1.69,1 -0.74,2.23 -1.06,2.64 -4.13,1.27 -1.8,3.07 -0.42,5.08 -1.48,2.52 -1.69,1.8 0.63,3 1.17,1.38 -1.17,1.58 1.17,1 h 3.91 l 2.22,-3.28 c 0,0 1.8,-3.81 1.8,-4.13 0,-0.32 0.85,-3.81 0.85,-3.81 l 3.06,-4 V 198 h 2.12 l -0.63,-3.92 z m -213.61,-81.15 -4.64,-1.58 c 0,0 -1.29,0.18 -2.8,0.54 a 16.83,16.83 0 0 0 -3.24,1 l 6.58,2.24 h 2.66 l 4.44,3.82 h 3 c 0,0 2.45,-1.09 1.63,-3 l -3.81,-1.37 z"
133 |        id="path863" />
134 |   </g>
135 |   <path
136 |      class="cls-8"
137 |      d="M 166.04827,269.00001 A 151.41,151.41 0 0 1 21.048268,74.340006 151.34,151.34 0 1 0 296.34827,194.65001 a 151.23,151.23 0 0 1 -130.3,74.35 z"
138 |      id="path867" />
139 |   <ellipse
140 |      class="cls-3"
141 |      cx="153.54826"
142 |      cy="356.16"
143 |      rx="4.0900002"
144 |      ry="2.3599999"
145 |      id="ellipse1050" />
146 | </svg>
147 | 
```
Page 3/6FirstPrevNextLast