├── .dockerignore ├── .gitignore ├── LICENSE ├── README.md ├── nautobot_mcp ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── start_mcp_dev_server.py │ │ └── start_mcp_server.py ├── migrations │ ├── 0001_create_mcp_tool.py │ └── __init__.py ├── models.py ├── navigation.py ├── scripts │ └── mcp_client_check.py ├── templates │ └── nautobot_mcp │ │ └── tools.html ├── tests │ └── __init__.py ├── tools │ ├── __init__.py │ ├── circuit_tools.py │ ├── device_tools.py │ ├── interface_tools.py │ ├── ipam_tools.py │ ├── location_tools.py │ ├── platform_tools.py │ └── registry.py ├── urls.py ├── utilities │ ├── __init__.py │ └── logger.py └── views.py ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── setup.py └── static ├── mcp_tools_example.PNG └── nautobot_mcp.mp4 /.dockerignore: -------------------------------------------------------------------------------- 1 | # Docker related 2 | development/Dockerfile 3 | development/docker-compose*.yml 4 | development/*.env 5 | *.env 6 | environments/ 7 | 8 | # Python 9 | **/*.pyc 10 | **/*.pyo 11 | **/__pycache__/ 12 | **/.pytest_cache/ 13 | **/.venv/ 14 | 15 | 16 | # Other 17 | docs/_build 18 | FAQ.md 19 | .git/ 20 | .gitignore 21 | .github 22 | LICENSE 23 | **/*.log 24 | **/.vscode/ 25 | invoke*.yml 26 | tasks.py 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ansible Retry Files 2 | *.retry 3 | 4 | # Swap files 5 | *.swp 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # Editor 138 | .vscode/ 139 | 140 | ### macOS ### 141 | # General 142 | .DS_Store 143 | .AppleDouble 144 | .LSOverride 145 | 146 | # Thumbnails 147 | ._* 148 | 149 | # Files that might appear in the root of a volume 150 | .DocumentRevisions-V100 151 | .fseventsd 152 | .Spotlight-V100 153 | .TemporaryItems 154 | .Trashes 155 | .VolumeIcon.icns 156 | .com.apple.timemachine.donotpresent 157 | 158 | # Directories potentially created on remote AFP share 159 | .AppleDB 160 | .AppleDesktop 161 | Network Trash Folder 162 | Temporary Items 163 | .apdisk 164 | 165 | ### Windows ### 166 | # Windows thumbnail cache files 167 | Thumbs.db 168 | Thumbs.db:encryptable 169 | ehthumbs.db 170 | ehthumbs_vista.db 171 | 172 | # Dump file 173 | *.stackdump 174 | 175 | # Folder config file 176 | [Dd]esktop.ini 177 | 178 | # Recycle Bin used on file shares 179 | $RECYCLE.BIN/ 180 | 181 | # Windows Installer files 182 | *.cab 183 | *.msi 184 | *.msix 185 | *.msm 186 | *.msp 187 | 188 | # Windows shortcuts 189 | *.lnk 190 | 191 | ### PyCharm ### 192 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 193 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 194 | 195 | # User-specific stuff 196 | .idea/**/workspace.xml 197 | .idea/**/tasks.xml 198 | .idea/**/usage.statistics.xml 199 | .idea/**/dictionaries 200 | .idea/**/shelf 201 | 202 | # Generated files 203 | .idea/**/contentModel.xml 204 | 205 | # Sensitive or high-churn files 206 | .idea/**/dataSources/ 207 | .idea/**/dataSources.ids 208 | .idea/**/dataSources.local.xml 209 | .idea/**/sqlDataSources.xml 210 | .idea/**/dynamic.xml 211 | .idea/**/uiDesigner.xml 212 | .idea/**/dbnavigator.xml 213 | 214 | # Gradle 215 | .idea/**/gradle.xml 216 | .idea/**/libraries 217 | 218 | # Gradle and Maven with auto-import 219 | # When using Gradle or Maven with auto-import, you should exclude module files, 220 | # since they will be recreated, and may cause churn. Uncomment if using 221 | # auto-import. 222 | # .idea/artifacts 223 | # .idea/compiler.xml 224 | # .idea/jarRepositories.xml 225 | # .idea/modules.xml 226 | # .idea/*.iml 227 | # .idea/modules 228 | # *.iml 229 | # *.ipr 230 | 231 | # CMake 232 | cmake-build-*/ 233 | 234 | # Mongo Explorer plugin 235 | .idea/**/mongoSettings.xml 236 | 237 | # File-based project format 238 | *.iws 239 | 240 | # IntelliJ 241 | out/ 242 | 243 | # mpeltonen/sbt-idea plugin 244 | .idea_modules/ 245 | 246 | # JIRA plugin 247 | atlassian-ide-plugin.xml 248 | 249 | # Cursive Clojure plugin 250 | .idea/replstate.xml 251 | 252 | # Crashlytics plugin (for Android Studio and IntelliJ) 253 | com_crashlytics_export_strings.xml 254 | crashlytics.properties 255 | crashlytics-build.properties 256 | fabric.properties 257 | 258 | # Editor-based Rest Client 259 | .idea/httpRequests 260 | 261 | # Android studio 3.1+ serialized cache file 262 | .idea/caches/build_file_checksums.ser 263 | 264 | ### PyCharm Patch ### 265 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 266 | 267 | # *.iml 268 | # modules.xml 269 | # .idea/misc.xml 270 | # *.ipr 271 | 272 | # Sonarlint plugin 273 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 274 | .idea/**/sonarlint/ 275 | 276 | # SonarQube Plugin 277 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 278 | .idea/**/sonarIssues.xml 279 | 280 | # Markdown Navigator plugin 281 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 282 | .idea/**/markdown-navigator.xml 283 | .idea/**/markdown-navigator-enh.xml 284 | .idea/**/markdown-navigator/ 285 | 286 | # Cache file creation bug 287 | # See https://youtrack.jetbrains.com/issue/JBR-2257 288 | .idea/$CACHE_FILE$ 289 | 290 | # CodeStream plugin 291 | # https://plugins.jetbrains.com/plugin/12206-codestream 292 | .idea/codestream.xml 293 | 294 | ### vscode ### 295 | .vscode/* 296 | *.code-workspace 297 | 298 | # Rando 299 | creds.env 300 | development/*.txt 301 | 302 | # Invoke overrides 303 | invoke.yml 304 | 305 | # Docs 306 | public 307 | /compose.yaml 308 | /dump.sql 309 | /nautobot_mcp/static/nautobot_mcp/docs 310 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache Software License 2.0 2 | 3 | Copyright (c) 2025, Geury Torres 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ Repository Archived 2 | 3 | This project is no longer maintained or updated. I am archiving this repository due to limited time and bandwidth to continue development. 4 | 5 | # Nautobot MCP 6 | 7 | ![Nautobot](https://img.shields.io/badge/Nautobot-2.0+-blue) 8 | ![Python](https://img.shields.io/badge/Python-3.8+-blue) 9 | ![License](https://img.shields.io/badge/License-Apache%202.0-blue) 10 | 11 | This Nautobot app integrates the MCP (Model Context Protocol) server with Nautobot, providing AI-ready tools and interfaces for network automation and management. 12 | 13 | ## Overview 14 | 15 | Nautobot MCP enables AI assistants or applications to interact with your network data through a standardized protocol. The app runs an MCP server alongside Nautobot that exposes tools which can be used by AI systems. 16 | 17 | https://modelcontextprotocol.io/introduction 18 | 19 | ## Demo using Librechat - Connected to Nautobot MCP 20 | 21 | https://github.com/user-attachments/assets/283d68c2-d35f-4506-b909-45c1850e7281 22 | 23 | ## Installation 24 | 25 | ### 1. Install the package 26 | 27 | ```bash 28 | pip install nautobot-mcp 29 | ``` 30 | 31 | ### 2. Add to INSTALLED_APPS in your Nautobot configuration 32 | 33 | ```python 34 | # In your nautobot_config.py 35 | PLUGINS = [ 36 | "nautobot_mcp", 37 | # ... other plugins 38 | ] 39 | ``` 40 | 41 | ### 3. Configuration 42 | 43 | Configure the app through Nautobot's configuration system: 44 | 45 | ```python 46 | # In your nautobot_config.py 47 | PLUGINS_CONFIG = { 48 | "nautobot_mcp": { 49 | "MCP_PORT": 8005, # MCP server port 50 | "MCP_HOST": "0.0.0.0", # Default is 0.0.0.0 51 | "MCP_CUSTOM_TOOLS_DIR": "/path/to/your/custom/tools", # Directory for custom tools 52 | "MCP_LOAD_CORE_TOOLS": False, # Load built-in tools 53 | }, 54 | } 55 | ``` 56 | 57 | ### 4. Run nautobot post upgrade 58 | 59 | ```bash 60 | nautobot-server post_upgrade 61 | ``` 62 | 63 | ## Custom Tools 64 | 65 | You can create your own custom tools by defining Python functions in the directory specified in `MCP_CUSTOM_TOOLS_DIR`. 66 | 67 | Example custom tool: 68 | 69 | ```python 70 | # In /path/to/your/custom/tools/my_tools.py 71 | 72 | def some_tool(param1: str, param2: str) -> dict: 73 | """Some tool description""" 74 | # Your implementation here 75 | return {"result": f"Tool result for {param1} and {param2}"} 76 | ``` 77 | 78 | The MCP server will automatically discover and register all function-based tools in the specified directory. 79 | 80 | ## Deployment Options 81 | 82 | ### Method 1: Manual Start 83 | 84 | You can start the MCP server manually: 85 | 86 | ```bash 87 | nautobot-server start_mcp_server 88 | ``` 89 | 90 | ### Method 2: Systemd Service (Recommended for Production) 91 | 92 | Create a systemd service file at `/etc/systemd/system/nautobot-mcp.service`: 93 | 94 | ```ini 95 | [Unit] 96 | Description=Nautobot MCP Server 97 | After=network-online.target 98 | Wants=network-online.target 99 | 100 | [Service] 101 | User=nautobot 102 | Group=nautobot 103 | WorkingDirectory=/opt/nautobot 104 | ExecStart=/opt/nautobot/venv/bin/nautobot-server start_mcp_server 105 | Restart=on-failure 106 | RestartSec=30 107 | PrivateTmp=true 108 | 109 | [Install] 110 | WantedBy=multi-user.target 111 | ``` 112 | 113 | Then enable and start the service: 114 | 115 | ```bash 116 | sudo systemctl daemon-reload 117 | sudo systemctl enable --now nautobot-mcp.service 118 | ``` 119 | 120 | ## Viewing Available Tools 121 | 122 | You can view all registered tools in the Nautobot web interface at: 123 | 124 | ``` 125 | https://your-nautobot-server/plugins/nautobot-mcp/tools/ 126 | ``` 127 | 128 | This page shows all available tools, their descriptions, module paths, and parameter specifications. 129 | 130 | ![Tools](static/mcp_tools_example.PNG) 131 | 132 | ## TODO 133 | 134 | - [ ] Add a way to route tool execution to a specific Nautobot worker. 135 | - [ ] Enhance the tool view in the Nautobot web interface to show tool usage statistics. 136 | - [ ] Create a docker container to run the MCP server. 137 | - [ ] Add tests. 138 | 139 | ## License 140 | 141 | This project is licensed under the Apache License 2.0 - see the LICENSE file for details. 142 | -------------------------------------------------------------------------------- /nautobot_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | """App declaration for nautobot_mcp.""" 2 | 3 | # Metadata is inherited from Nautobot. If not including Nautobot in the environment, this should be added 4 | from importlib import metadata 5 | from nautobot.apps import NautobotAppConfig 6 | 7 | __version__ = metadata.version(__name__) 8 | 9 | 10 | class NautobotMcpConfig(NautobotAppConfig): 11 | """App configuration for the nautobot_mcp app.""" 12 | 13 | name = "nautobot_mcp" 14 | verbose_name = "Nautobot Mcp" 15 | version = __version__ 16 | author = "Geury Torres" 17 | description = "Nautobot Mcp." 18 | base_url = "nautobot-mcp" 19 | required_settings = [] 20 | min_version = "2.0.0" 21 | max_version = "2.9999" 22 | default_settings = { 23 | "MCP_PORT": 8005, 24 | "MCP_HOST": "0.0.0.0", 25 | "MCP_LOAD_CORE_TOOLS": False, 26 | } 27 | caching_config = {} 28 | 29 | 30 | config = NautobotMcpConfig 31 | -------------------------------------------------------------------------------- /nautobot_mcp/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gt732/nautobot-app-mcp/2afce2714681b7d2cb9c29635f69c6291ef26519/nautobot_mcp/management/__init__.py -------------------------------------------------------------------------------- /nautobot_mcp/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gt732/nautobot-app-mcp/2afce2714681b7d2cb9c29635f69c6291ef26519/nautobot_mcp/management/commands/__init__.py -------------------------------------------------------------------------------- /nautobot_mcp/management/commands/start_mcp_dev_server.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | import uvicorn 3 | import os 4 | import signal 5 | from django.conf import settings 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Start the MCP development server with auto-reload" 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument( 13 | "--port", 14 | type=int, 15 | default=8005, 16 | help="Port to run the server on", 17 | ) 18 | 19 | def _get_custom_tools_dir(self): 20 | """Get the custom tools directory from Nautobot settings.""" 21 | plugin_settings = settings.PLUGINS_CONFIG.get("nautobot_mcp", {}) 22 | custom_tools_dir = plugin_settings.get("MCP_CUSTOM_TOOLS_DIR") 23 | 24 | if custom_tools_dir and os.path.isdir(custom_tools_dir): 25 | return custom_tools_dir 26 | elif custom_tools_dir: 27 | print( 28 | f"Warning: Configured MCP_CUSTOM_TOOLS_DIR '{custom_tools_dir}' is not a valid directory" 29 | ) 30 | return None 31 | 32 | def _get_reload_dirs(self, custom_tools_dir): 33 | """Get directories to watch for auto-reload.""" 34 | project_root = os.path.dirname( 35 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 36 | ) 37 | tools_dir = os.path.join(project_root, "tools") 38 | reload_dirs = [tools_dir] 39 | 40 | if custom_tools_dir: 41 | reload_dirs.append(custom_tools_dir) 42 | print(f"Watching custom tools directory: {custom_tools_dir}") 43 | os.environ["NAUTOBOT_MCP_CUSTOM_TOOLS_DIR"] = custom_tools_dir 44 | else: 45 | if "NAUTOBOT_MCP_CUSTOM_TOOLS_DIR" in os.environ: 46 | del os.environ["NAUTOBOT_MCP_CUSTOM_TOOLS_DIR"] 47 | 48 | return reload_dirs 49 | 50 | def handle(self, *args, **kwargs): 51 | host = kwargs.get( 52 | "host", 53 | settings.PLUGINS_CONFIG.get("nautobot_mcp", {}).get( 54 | "MCP_HOST", "127.0.0.1" 55 | ), 56 | ) 57 | port = kwargs.get( 58 | "port", 59 | settings.PLUGINS_CONFIG.get("nautobot_mcp", {}).get("MCP_PORT", 8005), 60 | ) 61 | custom_tools_dir = self._get_custom_tools_dir() 62 | reload_dirs = self._get_reload_dirs(custom_tools_dir) 63 | 64 | print( 65 | f"Starting MCP development server on http://{host}:{port} with auto-reload enabled" 66 | ) 67 | print(f"Custom tools directory: {custom_tools_dir}") 68 | 69 | def force_reload_handler(signum, frame): 70 | print("Force reloading server...") 71 | os.kill(os.getpid(), signal.SIGTERM) 72 | 73 | signal.signal(signal.SIGUSR1, force_reload_handler) 74 | 75 | def on_reload(): 76 | print("Changes detected - forcing server restart...") 77 | os.kill(os.getpid(), signal.SIGUSR1) 78 | 79 | uvicorn.run( 80 | "nautobot_mcp.management.commands.start_mcp_dev_server:create_app", 81 | host=host, 82 | port=port, 83 | reload=True, 84 | reload_dirs=reload_dirs, 85 | reload_delay=0.25, 86 | timeout_keep_alive=0, 87 | timeout_graceful_shutdown=1, 88 | ) 89 | 90 | 91 | def create_app(): 92 | """Create and configure the MCP application.""" 93 | import nautobot 94 | 95 | nautobot.setup() 96 | 97 | from mcp.server.fastmcp import FastMCP 98 | from nautobot_mcp.tools.registry import ( 99 | register_all_tools_with_mcp, 100 | discover_tools_from_directory, 101 | ) 102 | 103 | mcp = FastMCP("Nautobot MCP Development Server (Auto-reload)", port=8005) 104 | register_all_tools_with_mcp(mcp) 105 | 106 | # Register custom tools if directory is specified in settings 107 | custom_tools_dir = os.environ.get("NAUTOBOT_MCP_CUSTOM_TOOLS_DIR") 108 | if custom_tools_dir: 109 | print(f"Registering custom tools from: {custom_tools_dir}") 110 | discover_tools_from_directory(mcp, custom_tools_dir) 111 | 112 | return mcp.sse_app() 113 | -------------------------------------------------------------------------------- /nautobot_mcp/management/commands/start_mcp_server.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from mcp.server.fastmcp import FastMCP 3 | import os 4 | from django.conf import settings 5 | from nautobot_mcp.tools.registry import ( 6 | register_all_tools_with_mcp, 7 | discover_tools_from_directory, 8 | ) 9 | from nautobot_mcp.utilities.logger import get_logger 10 | 11 | logger = get_logger("management.commands.start_mcp_server") 12 | 13 | 14 | class Command(BaseCommand): 15 | help = "Start the MCP server in production mode" 16 | 17 | def add_arguments(self, parser): 18 | parser.add_argument( 19 | "--port", 20 | type=int, 21 | default=settings.PLUGINS_CONFIG.get("nautobot_mcp", {}).get( 22 | "MCP_PORT", 8005 23 | ), 24 | help="Port to run the server on", 25 | ) 26 | 27 | def _get_custom_tools_dir(self): 28 | """Get the custom tools directory from Nautobot settings.""" 29 | plugin_settings = settings.PLUGINS_CONFIG.get("nautobot_mcp", {}) 30 | custom_tools_dir = plugin_settings.get("MCP_CUSTOM_TOOLS_DIR") 31 | 32 | if custom_tools_dir and os.path.isdir(custom_tools_dir): 33 | logger.info(f"Found valid custom tools directory: {custom_tools_dir}") 34 | return custom_tools_dir 35 | elif custom_tools_dir: 36 | logger.warning( 37 | f"Configured MCP_CUSTOM_TOOLS_DIR '{custom_tools_dir}' is not a valid directory" 38 | ) 39 | return None 40 | 41 | def handle(self, *args, **kwargs): 42 | host = kwargs.get( 43 | "host", 44 | settings.PLUGINS_CONFIG.get("nautobot_mcp", {}).get( 45 | "MCP_HOST", "127.0.0.1" 46 | ), 47 | ) 48 | port = kwargs.get( 49 | "port", 50 | settings.PLUGINS_CONFIG.get("nautobot_mcp", {}).get("MCP_PORT", 8005), 51 | ) 52 | 53 | load_core_tools = settings.PLUGINS_CONFIG.get("nautobot_mcp", {}).get( 54 | "MCP_LOAD_CORE_TOOLS", False 55 | ) 56 | 57 | custom_tools_dir = self._get_custom_tools_dir() 58 | 59 | mcp = FastMCP("Nautobot MCP Server", host=host, port=port) 60 | logger.info(f"Starting MCP server on http://{host}:{port}") 61 | 62 | if load_core_tools: 63 | logger.info("Registering core tools with MCP server") 64 | register_all_tools_with_mcp(mcp) 65 | 66 | if custom_tools_dir: 67 | logger.info(f"Registering custom tools from: {custom_tools_dir}") 68 | discover_tools_from_directory(mcp, custom_tools_dir) 69 | 70 | logger.info("MCP server initialized, starting transport") 71 | mcp.run(transport="sse") 72 | -------------------------------------------------------------------------------- /nautobot_mcp/migrations/0001_create_mcp_tool.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.18 on 2025-03-28 15:35 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='MCPTool', 17 | fields=[ 18 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), 19 | ('name', models.CharField(max_length=255, unique=True)), 20 | ('description', models.TextField(blank=True)), 21 | ('module_path', models.CharField(blank=True, max_length=255)), 22 | ('parameters', models.JSONField(blank=True, null=True)), 23 | ], 24 | options={ 25 | 'verbose_name': 'MCP Tool', 26 | 'verbose_name_plural': 'MCP Tools', 27 | 'ordering': ['name'], 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /nautobot_mcp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gt732/nautobot-app-mcp/2afce2714681b7d2cb9c29635f69c6291ef26519/nautobot_mcp/migrations/__init__.py -------------------------------------------------------------------------------- /nautobot_mcp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from nautobot.core.models import BaseModel 3 | 4 | 5 | class MCPTool(BaseModel): 6 | """Model to track MCP tools registered with the server.""" 7 | 8 | name = models.CharField( 9 | max_length=255, unique=True, help_text="Name of the MCP tool function" 10 | ) 11 | description = models.TextField( 12 | blank=True, help_text="Description of what the tool does" 13 | ) 14 | module_path = models.CharField( 15 | max_length=255, 16 | help_text="Python module path where the tool is defined", 17 | blank=True, 18 | ) 19 | parameters = models.JSONField( 20 | blank=True, null=True, help_text="JSON schema of the tool's parameters" 21 | ) 22 | 23 | class Meta: 24 | ordering = ["name"] 25 | verbose_name = "MCP Tool" 26 | verbose_name_plural = "MCP Tools" 27 | 28 | def __str__(self): 29 | return self.name 30 | -------------------------------------------------------------------------------- /nautobot_mcp/navigation.py: -------------------------------------------------------------------------------- 1 | from nautobot.core.apps import NavMenuGroup, NavMenuItem, NavMenuTab 2 | 3 | items = ( 4 | NavMenuItem( 5 | name="MCP Tools", 6 | link="plugins:nautobot_mcp:mcp_tools", 7 | permissions=["nautobot_mcp.view_mcp_tool"], 8 | ), 9 | ) 10 | 11 | menu_items = ( 12 | NavMenuTab( 13 | name="Nautobot MCP", 14 | groups=(NavMenuGroup(name="Main", items=items),), 15 | ), 16 | ) 17 | -------------------------------------------------------------------------------- /nautobot_mcp/scripts/mcp_client_check.py: -------------------------------------------------------------------------------- 1 | from mcp import ClientSession 2 | from mcp.client.sse import sse_client 3 | from rich import print 4 | 5 | 6 | async def run(): 7 | async with sse_client(url="http://127.0.0.1:8005/sse") as streams: 8 | async with ClientSession(*streams) as session: 9 | 10 | print("Initializing MCP session...") 11 | await session.initialize() 12 | 13 | # List available tools 14 | print("\nAvailable tools:") 15 | tools = await session.list_tools() 16 | for tool in tools: 17 | print(f"- {tool}") 18 | 19 | # Test get_device_interfaces tool 20 | print("\nTesting get_device_interfaces tool:") 21 | device_result = await session.call_tool( 22 | name="get_device_interfaces", arguments={"device_name": "ang01-edge-01"} 23 | ) 24 | print(device_result.content[0].text) 25 | 26 | # Test get_circuit_by_id tool 27 | print("\nTesting get_circuit_by_id tool:") 28 | circuit_result = await session.call_tool( 29 | name="get_circuit_by_id", arguments={"cid": "ntt-104265404093023273"} 30 | ) 31 | print(circuit_result.content[0].text) 32 | 33 | # Test get_prefix_details tool 34 | print("\nTesting get_prefix_details tool:") 35 | prefix_result = await session.call_tool( 36 | name="get_prefix_details", arguments={"prefix": "10.0.3.0/24"} 37 | ) 38 | print(prefix_result.content[0].text) 39 | 40 | 41 | if __name__ == "__main__": 42 | import asyncio 43 | 44 | asyncio.run(run()) 45 | -------------------------------------------------------------------------------- /nautobot_mcp/templates/nautobot_mcp/tools.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block title %}Nautobot MCP Tools{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |

MCP Tools Overview

10 |
11 | {% if mcp_status.running %} 12 | Server Online 13 | {% else %} 14 | Server Offline 15 | {% endif %} 16 |
17 |
18 |
19 | 20 | {% if not mcp_status.running %} 21 |
22 |
23 |
24 | {{ mcp_status.error }} 25 |
26 |
27 |
28 | {% endif %} 29 | 30 | {% if mcp_status.running %} 31 |
32 |
33 |
34 |
35 |

36 | 37 | Available Tools 38 |

39 |
40 |
41 | {% if tools %} 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% for tool in tools %} 53 | 54 | 55 | 56 | 57 | 64 | 65 | {% endfor %} 66 | 67 |
Tool NameDescriptionModuleParameters
{{ tool.name }}{{ tool.description|truncatechars:200 }}{{ tool.module_path }} 58 | {% if tool.parameters %} 59 |
{{ tool.parameters }}
60 | {% else %} 61 | No parameters defined 62 | {% endif %} 63 |
68 | {% else %} 69 |
70 | No MCP tools are currently registered. 71 |
72 | {% endif %} 73 |
74 |
75 |
76 |
77 | {% endif %} 78 | {% endblock %} -------------------------------------------------------------------------------- /nautobot_mcp/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for nautobot_mcp app.""" 2 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools module for Nautobot MCP. 2 | 3 | This module contains tools that can be registered with the MCP server 4 | for use by LLM agents. 5 | """ 6 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/circuit_tools.py: -------------------------------------------------------------------------------- 1 | """Circuit tools for Nautobot MCP.""" 2 | 3 | from asgiref.sync import sync_to_async 4 | from nautobot_mcp.tools.registry import register_tool 5 | from nautobot.circuits.models import Circuit 6 | 7 | 8 | @register_tool 9 | async def get_circuit_by_id(cid: str) -> str: 10 | """Get circuit by CID.""" 11 | 12 | @sync_to_async 13 | def get_circuit_sync(cid): 14 | try: 15 | circuit = Circuit.objects.get(cid=cid) 16 | report = [ 17 | f"Circuit ID: {circuit.cid}", 18 | f"Provider: {circuit.provider}", 19 | f"Type: {circuit.circuit_type}", 20 | f"Status: {circuit.status}", 21 | ] 22 | 23 | if circuit.tenant: 24 | report.append(f"Tenant: {circuit.tenant}") 25 | if circuit.commit_rate: 26 | report.append(f"Commit Rate: {circuit.commit_rate} Kbps") 27 | if circuit.install_date: 28 | report.append(f"Install Date: {circuit.install_date}") 29 | if circuit.description: 30 | report.append(f"Description: {circuit.description}") 31 | 32 | return "\n".join(report) 33 | except Circuit.DoesNotExist: 34 | return f"Circuit with ID '{cid}' not found." 35 | 36 | return await get_circuit_sync(cid) 37 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/device_tools.py: -------------------------------------------------------------------------------- 1 | """Device tools for Nautobot MCP.""" 2 | 3 | from asgiref.sync import sync_to_async 4 | from nautobot_mcp.tools.registry import register_tool 5 | from nautobot.dcim.models import Device 6 | from nautobot.circuits.models import CircuitTermination 7 | 8 | 9 | @register_tool 10 | async def get_device_details(device_name: str) -> str: 11 | """Get device details by name.""" 12 | 13 | @sync_to_async 14 | def get_device_details_sync(device_name): 15 | try: 16 | device = Device.objects.get(name=device_name) 17 | 18 | primary_ip4_info = f"{device.primary_ip4}" if device.primary_ip4 else "None" 19 | primary_ip6_info = f"{device.primary_ip6}" if device.primary_ip6 else "None" 20 | primary_ip_info = ( 21 | f"{device.primary_ip}" 22 | if hasattr(device, "primary_ip") and device.primary_ip 23 | else "None" 24 | ) 25 | 26 | location_info = device.location.name if device.location else "None" 27 | rack_info = ( 28 | f"{device.rack.name} (Position: {device.position})" 29 | if device.rack 30 | else "None" 31 | ) 32 | 33 | interface_count = device.all_interfaces.count() 34 | console_port_count = device.all_console_ports.count() 35 | console_server_port_count = device.all_console_server_ports.count() 36 | power_port_count = device.all_power_ports.count() 37 | power_outlet_count = device.all_power_outlets.count() 38 | front_port_count = device.all_front_ports.count() 39 | rear_port_count = device.all_rear_ports.count() 40 | 41 | child_devices = device.get_children() 42 | child_device_count = child_devices.count() 43 | child_device_names = ( 44 | ", ".join([child.name for child in child_devices[:5]]) 45 | if child_devices 46 | else "None" 47 | ) 48 | if child_devices.count() > 5: 49 | child_device_names += f" and {child_devices.count() - 5} more" 50 | 51 | vc_master = device.get_vc_master() 52 | vc_master_info = vc_master.name if vc_master else "None" 53 | 54 | cable_count = len(device.get_cables(pk_list=True)) 55 | 56 | device_info = ( 57 | f"Name: {device.name}\n" 58 | f"Type: {device.device_type}\n" 59 | f"Status: {device.status}\n" 60 | f"Role: {device.role.name if device.role else 'None'}\n" 61 | f"Platform: {device.platform.name if device.platform else 'None'}\n" 62 | f"Tenant: {device.tenant.name if device.tenant else 'None'}\n" 63 | f"Serial: {device.serial or 'None'}\n" 64 | f"Asset Tag: {device.asset_tag or 'None'}\n" 65 | f"Location: {location_info}\n" 66 | f"Rack: {rack_info}\n" 67 | f"Primary IP: {primary_ip_info}\n" 68 | f"Primary IPv4: {primary_ip4_info}\n" 69 | f"Primary IPv6: {primary_ip6_info}\n" 70 | f"Virtual Chassis: {device.virtual_chassis.name if device.virtual_chassis else 'None'}\n" 71 | f"VC Position: {device.vc_position or 'None'}\n" 72 | f"VC Priority: {device.vc_priority or 'None'}\n" 73 | f"VC Master: {vc_master_info}\n" 74 | f"Cluster: {device.cluster.name if device.cluster else 'None'}\n" 75 | f"Software Version: {device.software_version or 'None'}\n" 76 | f"Created: {device.created}\n" 77 | f"Last Updated: {device.last_updated}\n" 78 | f"Comments: {device.comments or 'None'}" 79 | ) 80 | 81 | interfaces = device.all_interfaces.all() 82 | interface_details = [] 83 | if interfaces: 84 | for iface in interfaces: 85 | ip_assignments = ( 86 | iface.ip_addresses.all() 87 | if hasattr(iface, "ip_addresses") 88 | else [] 89 | ) 90 | ip_addresses = [str(ip.address) for ip in ip_assignments] 91 | 92 | connected_to = "" 93 | if hasattr(iface, "cable") and iface.cable: 94 | far_end = ( 95 | iface.cable.termination_b 96 | if iface.cable.termination_a == iface 97 | else iface.cable.termination_a 98 | ) 99 | if far_end: 100 | if hasattr(far_end, "device"): 101 | connected_device = far_end.device 102 | elif hasattr(far_end, "module") and hasattr( 103 | far_end.module, "device" 104 | ): 105 | connected_device = far_end.module.device 106 | else: 107 | connected_device = None 108 | 109 | device_name = ( 110 | connected_device.name 111 | if connected_device 112 | else "Unknown device" 113 | ) 114 | 115 | far_end_identifier = "" 116 | if isinstance(far_end, CircuitTermination): 117 | far_end_identifier = f"Circuit {far_end.circuit.cid} (Term {far_end.term_side})" 118 | elif hasattr(far_end, "name"): 119 | far_end_identifier = far_end.name 120 | else: 121 | far_end_identifier = str(far_end) 122 | 123 | connected_to = ( 124 | f", connected to: {device_name} ({far_end_identifier})" 125 | ) 126 | 127 | module_info = ( 128 | f" [Module: {iface.module.name}]" 129 | if hasattr(iface, "module") and iface.module 130 | else "" 131 | ) 132 | 133 | lag_info = f" (LAG member: {iface.lag.name})" if iface.lag else "" 134 | 135 | vlan_info = "" 136 | if hasattr(iface, "untagged_vlan") and iface.untagged_vlan: 137 | vlan_info += f", Untagged VLAN: {iface.untagged_vlan}" 138 | 139 | if hasattr(iface, "tagged_vlans") and iface.tagged_vlans.exists(): 140 | tagged_vlans = iface.tagged_vlans.all()[:5] 141 | if tagged_vlans: 142 | vlan_list = ", ".join([str(vlan) for vlan in tagged_vlans]) 143 | vlan_info += f", Tagged VLANs: {vlan_list}" 144 | if iface.tagged_vlans.count() > 5: 145 | vlan_info += ( 146 | f" (+{iface.tagged_vlans.count() - 5} more)" 147 | ) 148 | 149 | interface_details.append( 150 | f" - {iface.name}{module_info}{lag_info}: {iface.type}, " 151 | f"MTU: {iface.mtu or 'Auto'}, " 152 | f"MAC: {iface.mac_address or 'N/A'}, " 153 | f"Status: {'Enabled' if getattr(iface, 'enabled', True) else 'Disabled'}, " 154 | f"IPs: {', '.join(ip_addresses) or 'None'}" 155 | f"{vlan_info}" 156 | f"{connected_to}" 157 | ) 158 | 159 | interface_summary = "\n".join(interface_details) 160 | else: 161 | interface_summary = " No interfaces found" 162 | 163 | component_summary = ( 164 | f"\nComponent Summary:\n" 165 | f" Interfaces: {interface_count}\n" 166 | f" Console Ports: {console_port_count}\n" 167 | f" Console Server Ports: {console_server_port_count}\n" 168 | f" Power Ports: {power_port_count}\n" 169 | f" Power Outlets: {power_outlet_count}\n" 170 | f" Front Ports: {front_port_count}\n" 171 | f" Rear Ports: {rear_port_count}\n" 172 | f" Connected Cables: {cable_count}\n" 173 | f" Child Devices: {child_device_count} ({child_device_names})" 174 | ) 175 | 176 | interface_detail_section = f"\nInterface Details:\n{interface_summary}" 177 | 178 | return device_info + component_summary + interface_detail_section 179 | 180 | except Device.DoesNotExist: 181 | return f"Device with name '{device_name}' not found." 182 | 183 | return await get_device_details_sync(device_name) 184 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/interface_tools.py: -------------------------------------------------------------------------------- 1 | """Interface and connectivity tools for Nautobot MCP. 2 | 3 | These tools allow an LLM agent to query interface and connection data from Nautobot. 4 | """ 5 | 6 | from asgiref.sync import sync_to_async 7 | from nautobot_mcp.tools.registry import register_tool 8 | from nautobot.dcim.models.devices import Device 9 | from nautobot.ipam.models import IPAddressToInterface 10 | 11 | 12 | @register_tool 13 | async def get_device_interfaces(device_name: str) -> str: 14 | """Get all interfaces for a specific device.""" 15 | 16 | @sync_to_async 17 | def get_interfaces_sync(device_name): 18 | try: 19 | device = Device.objects.get(name=device_name) 20 | interfaces = device.interfaces.all() 21 | 22 | result = [f"Interfaces for device '{device_name}':"] 23 | 24 | for iface in interfaces: 25 | ip_assignments = IPAddressToInterface.objects.filter(interface=iface) 26 | ip_addresses = [str(ia.ip_address.address) for ia in ip_assignments] 27 | ip_str = f", IPs: {', '.join(ip_addresses)}" if ip_addresses else "" 28 | 29 | connected_to = "" 30 | if iface.cable: 31 | far_end = ( 32 | iface.cable.termination_b 33 | if iface.cable.termination_a == iface 34 | else iface.cable.termination_a 35 | ) 36 | if far_end: 37 | if hasattr(far_end, "device"): 38 | connected_device = far_end.device 39 | far_end_name = getattr(far_end, "name", str(far_end)) 40 | elif hasattr(far_end, "module") and hasattr( 41 | far_end.module, "device" 42 | ): 43 | connected_device = far_end.module.device 44 | far_end_name = getattr(far_end, "name", str(far_end)) 45 | elif hasattr(far_end, "circuit"): 46 | connected_device = None 47 | circuit = far_end.circuit 48 | far_end_name = ( 49 | f"Circuit {circuit.cid} Term {far_end.term_side}" 50 | ) 51 | else: 52 | connected_device = None 53 | far_end_name = str(far_end) 54 | 55 | device_name = ( 56 | connected_device.name 57 | if connected_device 58 | else "Unknown device" 59 | ) 60 | 61 | if connected_device: 62 | connected_to = ( 63 | f", connected to: {device_name} ({far_end_name})" 64 | ) 65 | else: 66 | connected_to = f", connected to: {far_end_name}" 67 | 68 | enabled = " [DISABLED]" if not iface.enabled else "" 69 | result.append( 70 | f"- {iface.name} ({iface.type}){enabled}{ip_str}{connected_to}" 71 | ) 72 | 73 | return "\n".join(result) 74 | except Device.DoesNotExist: 75 | return f"Device '{device_name}' not found." 76 | 77 | return await get_interfaces_sync(device_name) 78 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/ipam_tools.py: -------------------------------------------------------------------------------- 1 | """IPAM tools for Nautobot MCP. 2 | 3 | These tools allow an LLM agent to query IP Address Management data from Nautobot. 4 | """ 5 | 6 | from asgiref.sync import sync_to_async 7 | from nautobot_mcp.tools.registry import register_tool 8 | from nautobot.ipam.models import IPAddress, Prefix 9 | 10 | 11 | @register_tool 12 | async def get_ip_by_host(host: str) -> str: 13 | """Get IP address details by its host (e.g., '192.168.1.1').""" 14 | 15 | @sync_to_async 16 | def get_ip_sync(host): 17 | try: 18 | ip = IPAddress.objects.get(host=host) 19 | interfaces_info = ( 20 | ", ".join(str(i.interface) for i in ip.interface_assignments.all()) 21 | if ip.interface_assignments.all() 22 | else "None" 23 | ) 24 | vm_interfaces_info = ( 25 | ", ".join( 26 | str(vmi.vm_interface) for vmi in ip.vm_interface_assignments.all() 27 | ) 28 | if hasattr(ip, "vm_interface_assignments") 29 | and ip.vm_interface_assignments.all() 30 | else "None" 31 | ) 32 | tenant_info = ip.tenant.name if ip.tenant else "None" 33 | nat_inside_info = str(ip.nat_inside) if ip.nat_inside else "None" 34 | nat_outside_list = ( 35 | ", ".join(str(nat) for nat in ip.nat_outside.all()) 36 | if hasattr(ip, "nat_outside") and ip.nat_outside.all() 37 | else "None" 38 | ) 39 | parent_info = str(ip.parent) if ip.parent else "None" 40 | 41 | return ( 42 | f"IP: {ip.address}\n" 43 | f"Host: {ip.host}\n" 44 | f"Mask Length: {ip.mask_length}\n" 45 | f"Type: {ip.type}\n" 46 | f"IP Version: {ip.ip_version}\n" 47 | f"DNS Name: {ip.dns_name or 'None'}\n" 48 | f"Status: {ip.status}\n" 49 | f"Role: {ip.role or 'None'}\n" 50 | f"Description: {ip.description or 'None'}\n" 51 | f"Parent Prefix: {parent_info}\n" 52 | f"Tenant: {tenant_info}\n" 53 | f"NAT Inside: {nat_inside_info}\n" 54 | f"NAT Outside IPs: {nat_outside_list}\n" 55 | f"Assigned Device Interfaces: {interfaces_info}\n" 56 | f"Assigned VM Interfaces: {vm_interfaces_info}\n" 57 | f"Created: {ip.created}\n" 58 | f"Last Updated: {ip.last_updated}\n" 59 | f"Tags: {', '.join(str(tag) for tag in ip.tags.all()) or 'None'}" 60 | ) 61 | except IPAddress.DoesNotExist: 62 | return f"IP address '{host}' not found." 63 | 64 | return await get_ip_sync(host) 65 | 66 | 67 | @register_tool 68 | async def get_prefix_details(prefix: str) -> str: 69 | """Get prefix details by prefix (e.g., '10.0.0.0/24').""" 70 | 71 | @sync_to_async 72 | def get_prefix_sync(prefix): 73 | try: 74 | prefix = Prefix.objects.get(prefix=prefix) 75 | if prefix.locations: 76 | prefix_locations = ", ".join( 77 | str(location) for location in prefix.locations.all() 78 | ) 79 | else: 80 | prefix_locations = "None" 81 | return ( 82 | f"Prefix: {prefix.prefix}\n" 83 | f"Type: {prefix.type}\n" 84 | f"Status: {prefix.status}\n" 85 | f"Role: {prefix.role or 'None'}\n" 86 | f"VLAN: {prefix.vlan.name if prefix.vlan else 'None'}\n" 87 | f"Description: {prefix.description or 'None'}\n" 88 | f"Tenant: {prefix.tenant.name if prefix.tenant else 'None'}\n" 89 | f"Location: {prefix_locations}\n" 90 | f"Tags: {', '.join(str(tag) for tag in prefix.tags.all()) or 'None'}" 91 | ) 92 | except Prefix.DoesNotExist: 93 | return f"Prefix '{prefix}' not found." 94 | 95 | return await get_prefix_sync(prefix) 96 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/location_tools.py: -------------------------------------------------------------------------------- 1 | """Location tools for Nautobot MCP. 2 | 3 | These tools allow an LLM agent to query location-related data from Nautobot. 4 | """ 5 | 6 | from asgiref.sync import sync_to_async 7 | from nautobot_mcp.tools.registry import register_tool 8 | from nautobot.dcim.models.locations import Location 9 | from nautobot.dcim.models.racks import Rack 10 | 11 | 12 | @register_tool 13 | async def get_location_by_name(name: str) -> str: 14 | """Get location details by name.""" 15 | 16 | @sync_to_async 17 | def get_location_sync(name): 18 | try: 19 | location = Location.objects.get(name=name) 20 | parent = location.parent.name if location.parent else "None" 21 | 22 | children = Location.objects.filter(parent=location) 23 | child_names = [child.name for child in children] 24 | 25 | racks = Rack.objects.filter(location=location) 26 | rack_count = racks.count() 27 | 28 | device_count = location.devices.count() 29 | 30 | return ( 31 | f"Location: {location.name}\n" 32 | f"Type: {location.location_type.name}\n" 33 | f"Status: {location.status}\n" 34 | f"Parent: {parent}\n" 35 | f"Child Locations: {', '.join(child_names) if child_names else 'None'}\n" 36 | f"Rack Count: {rack_count}\n" 37 | f"Device Count: {device_count}\n" 38 | f"Description: {location.description or 'None'}" 39 | ) 40 | except Location.DoesNotExist: 41 | return f"Location '{name}' not found." 42 | 43 | return await get_location_sync(name) 44 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/platform_tools.py: -------------------------------------------------------------------------------- 1 | """Platform tools for Nautobot MCP. 2 | 3 | These tools allow an LLM agent to query platform-related data from Nautobot. 4 | """ 5 | 6 | from asgiref.sync import sync_to_async 7 | from nautobot_mcp.tools.registry import register_tool 8 | from nautobot.dcim.models import Platform, Device 9 | from nautobot.virtualization.models import VirtualMachine 10 | 11 | 12 | @register_tool 13 | async def get_platform_by_name(name: str) -> str: 14 | """Get platform details by name.""" 15 | 16 | @sync_to_async 17 | def get_platform_sync(name): 18 | try: 19 | platform = Platform.objects.get(name=name) 20 | 21 | # Get count of devices and VMs using this platform 22 | device_count = Device.objects.filter(platform=platform).count() 23 | vm_count = VirtualMachine.objects.filter(platform=platform).count() 24 | 25 | # Get manufacturer details 26 | manufacturer_info = ( 27 | platform.manufacturer.name if platform.manufacturer else "None" 28 | ) 29 | 30 | # Format network driver mappings 31 | driver_mappings = platform.network_driver_mappings 32 | driver_mapping_lines = [] 33 | if driver_mappings: 34 | for library, driver in driver_mappings.items(): 35 | driver_mapping_lines.append(f" - {library}: {driver}") 36 | driver_mapping_str = "\n".join(driver_mapping_lines) 37 | else: 38 | driver_mapping_str = " None" 39 | 40 | return ( 41 | f"Platform: {platform.name}\n" 42 | f"Manufacturer: {manufacturer_info}\n" 43 | f"Network Driver: {platform.network_driver or 'None'}\n" 44 | f"NAPALM Driver: {platform.napalm_driver or 'None'}\n" 45 | f"NAPALM Args: {platform.napalm_args or 'None'}\n" 46 | f"Description: {platform.description or 'None'}\n" 47 | f"Device Count: {device_count}\n" 48 | f"Virtual Machine Count: {vm_count}\n" 49 | f"Network Driver Mappings:\n{driver_mapping_str}\n\n" 50 | ) 51 | except Platform.DoesNotExist: 52 | return f"Platform with name '{name}' not found." 53 | 54 | return await get_platform_sync(name) 55 | -------------------------------------------------------------------------------- /nautobot_mcp/tools/registry.py: -------------------------------------------------------------------------------- 1 | """Tool registry for Nautobot MCP. 2 | 3 | This module provides the registry for MCP tools and the functionality 4 | to register and manage them. 5 | """ 6 | 7 | from typing import Dict, Callable, Any 8 | import inspect 9 | import pkgutil 10 | import importlib 11 | import sys 12 | from pathlib import Path 13 | from functools import wraps 14 | from django.db import transaction 15 | 16 | from nautobot_mcp.utilities.logger import get_logger 17 | from nautobot_mcp.models import MCPTool 18 | 19 | logger = get_logger("tools.registry") 20 | 21 | # Dict to store all registered tools 22 | _tool_registry: Dict[str, Callable] = {} 23 | 24 | 25 | def register_tool(func: Callable) -> Callable: 26 | """Decorator to register a function as an MCP tool. 27 | 28 | This doesn't attach it to an MCP server instance directly, but rather 29 | registers it in the global registry for later use and persists it to the database. 30 | """ 31 | 32 | @wraps(func) 33 | def wrapper(*args, **kwargs): 34 | return func(*args, **kwargs) 35 | 36 | _tool_registry[func.__name__] = func 37 | 38 | module_path = func.__module__ 39 | 40 | description = func.__doc__ or "" 41 | 42 | try: 43 | sig = inspect.signature(func) 44 | parameters = {} 45 | for name, param in sig.parameters.items(): 46 | param_info = { 47 | "name": name, 48 | "required": param.default == inspect.Parameter.empty, 49 | } 50 | 51 | if param.annotation != inspect.Parameter.empty: 52 | if hasattr(param.annotation, "__name__"): 53 | param_info["type"] = param.annotation.__name__ 54 | 55 | if param.default != inspect.Parameter.empty: 56 | param_info["default"] = str(param.default) 57 | 58 | parameters[name] = param_info 59 | except Exception as e: 60 | logger.warning(f"Error extracting parameters for {func.__name__}: {e}") 61 | parameters = {} 62 | 63 | try: 64 | with transaction.atomic(): 65 | tool, created = MCPTool.objects.get_or_create( 66 | name=func.__name__, 67 | defaults={ 68 | "description": description, 69 | "module_path": module_path, 70 | "parameters": parameters, 71 | }, 72 | ) 73 | 74 | if not created: 75 | tool.description = description 76 | tool.module_path = module_path 77 | tool.parameters = parameters 78 | tool.save() 79 | 80 | logger.info(f"Updated existing tool in database: {func.__name__}") 81 | else: 82 | logger.info(f"Registered new tool in database: {func.__name__}") 83 | except Exception as e: 84 | logger.error(f"Failed to register tool {func.__name__} in database: {e}") 85 | 86 | return wrapper 87 | 88 | 89 | def get_all_tools() -> Dict[str, Callable]: 90 | """Return all registered tools.""" 91 | return _tool_registry.copy() 92 | 93 | 94 | def discover_tools() -> None: 95 | """Automatically discover and import tool modules in the tools directory. 96 | 97 | This will look for any Python modules in the tools directory and import them, 98 | which will trigger registration of any tools decorated with @register_tool. 99 | """ 100 | tools_dir = Path(__file__).parent 101 | 102 | for _, module_name, is_pkg in pkgutil.iter_modules([str(tools_dir)]): 103 | if module_name != "registry" and not is_pkg: 104 | importlib.import_module(f"nautobot_mcp.tools.{module_name}") 105 | 106 | 107 | def discover_tools_from_directory(mcp_instance: Any, tools_dir: str) -> None: 108 | """Discover and register tools from a custom directory. 109 | 110 | Args: 111 | mcp_instance: An instance of the MCP server class 112 | tools_dir: Path to directory containing Python modules with tools 113 | """ 114 | tools_path = Path(tools_dir) 115 | 116 | if not tools_path.exists() or not tools_path.is_dir(): 117 | logger.warning( 118 | f"Custom tools directory {tools_dir} does not exist or is not a directory" 119 | ) 120 | return 121 | 122 | parent_dir = str(tools_path.parent) 123 | tools_dir_name = tools_path.name 124 | 125 | if parent_dir not in sys.path: 126 | sys.path.insert(0, parent_dir) 127 | 128 | discovered_custom_tools = set() 129 | 130 | try: 131 | for _, module_name, is_pkg in pkgutil.iter_modules([str(tools_path)]): 132 | if not is_pkg: 133 | try: 134 | module = importlib.import_module(f"{tools_dir_name}.{module_name}") 135 | 136 | for name, obj in inspect.getmembers(module, inspect.isfunction): 137 | discovered_custom_tools.add(name) 138 | decorated_tool = register_tool(obj) 139 | mcp_instance.tool()(decorated_tool) 140 | logger.info(f"Registered custom tool: {name}") 141 | except Exception as e: 142 | logger.error(f"Error loading custom tool module {module_name}: {e}") 143 | try: 144 | with transaction.atomic(): 145 | custom_tools_query = MCPTool.objects.filter( 146 | module_path__startswith=f"{tools_dir_name}." 147 | ) 148 | removed_tools = custom_tools_query.exclude( 149 | name__in=discovered_custom_tools 150 | ) 151 | 152 | if removed_tools.exists(): 153 | removed_count = removed_tools.delete()[0] 154 | logger.info( 155 | f"Deleted {removed_count} custom tools that no longer exist" 156 | ) 157 | except Exception as e: 158 | logger.error(f"Failed to delete obsolete custom tools: {e}") 159 | 160 | finally: 161 | if parent_dir in sys.path: 162 | sys.path.remove(parent_dir) 163 | 164 | 165 | def register_all_tools_with_mcp(mcp_instance: Any) -> None: 166 | """Register all tools with the given MCP server instance. 167 | 168 | Args: 169 | mcp_instance: An instance of the MCP server class 170 | """ 171 | discover_tools() 172 | 173 | for tool_name, tool_func in _tool_registry.items(): 174 | mcp_instance.tool()(tool_func) 175 | logger.info(f"Registered core tool: {tool_name}") 176 | 177 | try: 178 | active_tool_names = set(_tool_registry.keys()) 179 | MCPTool.objects.exclude(name__in=active_tool_names).delete() 180 | logger.info(f"Deleted tools not in registry") 181 | except Exception as e: 182 | logger.error(f"Failed to delete obsolete tools: {e}") 183 | -------------------------------------------------------------------------------- /nautobot_mcp/urls.py: -------------------------------------------------------------------------------- 1 | """Django urlpatterns declaration for nautobot_mcp app.""" 2 | 3 | from django.urls import path 4 | from nautobot_mcp import views 5 | 6 | app_name = "nautobot_mcp" 7 | 8 | urlpatterns = [ 9 | path("tools/", views.MCPToolsView.as_view(), name="mcp_tools"), 10 | ] 11 | -------------------------------------------------------------------------------- /nautobot_mcp/utilities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gt732/nautobot-app-mcp/2afce2714681b7d2cb9c29635f69c6291ef26519/nautobot_mcp/utilities/__init__.py -------------------------------------------------------------------------------- /nautobot_mcp/utilities/logger.py: -------------------------------------------------------------------------------- 1 | """Logging utilities for Nautobot MCP. 2 | 3 | This module provides a standardized way to get loggers throughout the MCP app. 4 | """ 5 | 6 | import logging 7 | 8 | LOGGER_NAME = "nautobot.mcp" 9 | 10 | 11 | def get_logger(name=None): 12 | """Get a logger instance. 13 | 14 | Args: 15 | name: Optional name to append to the base logger name 16 | (e.g. "tools" becomes "nautobot.mcp.tools") 17 | 18 | Returns: 19 | A configured logger instance 20 | """ 21 | if name: 22 | logger_name = f"{LOGGER_NAME}.{name}" 23 | else: 24 | logger_name = LOGGER_NAME 25 | 26 | return logging.getLogger(logger_name) 27 | -------------------------------------------------------------------------------- /nautobot_mcp/views.py: -------------------------------------------------------------------------------- 1 | """Views for Nautobot MCP. 2 | 3 | This module provides views for the Nautobot MCP app. 4 | """ 5 | 6 | from django.shortcuts import render 7 | from django.views import View 8 | from django.contrib.auth.mixins import LoginRequiredMixin 9 | import json 10 | import requests 11 | from django.conf import settings 12 | from nautobot_mcp.models import MCPTool 13 | 14 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get("nautobot_mcp", {}) 15 | HOST = PLUGIN_SETTINGS.get("MCP_HOST", "127.0.0.1") 16 | PORT = PLUGIN_SETTINGS.get("MCP_PORT", 8005) 17 | 18 | 19 | class MCPToolsView(LoginRequiredMixin, View): 20 | """View for displaying a list of all available MCP tools.""" 21 | 22 | def check_mcp_server_status(self): 23 | """Check if the MCP server is running by making a connection attempt.""" 24 | 25 | server_url = f"http://{HOST}:{PORT}/sse" 26 | 27 | try: 28 | response = requests.get(server_url, timeout=1, stream=True) 29 | 30 | if response.status_code == 200: 31 | response.close() 32 | return True, None 33 | else: 34 | return False, f"MCP server returned status code: {response.status_code}" 35 | except requests.exceptions.ConnectionError: 36 | return False, f"Could not connect to MCP server at {HOST}:{PORT}" 37 | except requests.exceptions.Timeout: 38 | return False, f"Connection to MCP server at {HOST}:{PORT} timed out" 39 | except Exception as e: 40 | return False, f"Error checking MCP server status: {str(e)}" 41 | 42 | def get(self, request): 43 | """Handle GET requests to display MCP tools.""" 44 | 45 | tools = MCPTool.objects.all().order_by("name") 46 | 47 | is_running, error_message = self.check_mcp_server_status() 48 | 49 | tools_list = [] 50 | for tool in tools: 51 | parameters_formatted = None 52 | if tool.parameters: 53 | try: 54 | parameters_formatted = json.dumps(tool.parameters, indent=2) 55 | except: 56 | parameters_formatted = str(tool.parameters) 57 | 58 | tools_list.append( 59 | { 60 | "name": tool.name, 61 | "description": tool.description or "No description available", 62 | "module_path": tool.module_path, 63 | "parameters": parameters_formatted, 64 | } 65 | ) 66 | mcp_status = { 67 | "running": is_running, 68 | "error": ( 69 | None if is_running else (error_message or "MCP server is not running") 70 | ), 71 | "host": HOST, 72 | "port": PORT, 73 | } 74 | 75 | context = { 76 | "tools": tools_list, 77 | "mcp_status": mcp_status, 78 | } 79 | 80 | return render(request, "nautobot_mcp/tools.html", context) 81 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. 2 | package = [] 3 | 4 | [metadata] 5 | lock-version = "2.0" 6 | python-versions = "*" 7 | content-hash = "115cf985d932e9bf5f540555bbdd75decbb62cac81e399375fc19f6277f8c1d8" 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "nautobot-mcp" 3 | version = "0.1.2" 4 | description = "Nautobot MCP" 5 | authors = ["Geury Torres "] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | packages = [{ include = "nautobot_mcp" }] 9 | repository = "https://github.com/gt732/nautobot-app-mcp" 10 | classifiers = [ 11 | "License :: OSI Approved :: Apache Software License", 12 | "Programming Language :: Python :: 3", 13 | "Programming Language :: Python :: 3.8", 14 | "Framework :: Django", 15 | "Intended Audience :: System Administrators" 16 | ] 17 | 18 | [tool.poetry.dependencies] 19 | python = "^3.8" 20 | mcp = "^1.6.0" 21 | 22 | [build-system] 23 | requires = ["poetry-core>=1.0.0"] 24 | build-backend = "poetry.core.masonry.api" 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mcp==1.6.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="nautobot-mcp", 5 | version="0.1.2", 6 | description="Nautobot MCP", 7 | long_description=open("README.md").read(), 8 | long_description_content_type="text/markdown", 9 | author="Geury Torres", 10 | author_email="geuryt@yahoo.com", 11 | license="Apache-2.0", 12 | packages=find_packages(), 13 | python_requires=">=3.8", 14 | install_requires=[ 15 | "mcp>=1.6.0", 16 | ], 17 | url="https://github.com/gt732/nautobot-app-mcp", 18 | ) 19 | -------------------------------------------------------------------------------- /static/mcp_tools_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gt732/nautobot-app-mcp/2afce2714681b7d2cb9c29635f69c6291ef26519/static/mcp_tools_example.PNG -------------------------------------------------------------------------------- /static/nautobot_mcp.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gt732/nautobot-app-mcp/2afce2714681b7d2cb9c29635f69c6291ef26519/static/nautobot_mcp.mp4 --------------------------------------------------------------------------------