├── .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 | 
8 | 
9 | 
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 | 
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 | Tool Name |
46 | Description |
47 | Module |
48 | Parameters |
49 |
50 |
51 |
52 | {% for tool in tools %}
53 |
54 | {{ tool.name }} |
55 | {{ tool.description|truncatechars:200 }} |
56 | {{ tool.module_path }} |
57 |
58 | {% if tool.parameters %}
59 | {{ tool.parameters }}
60 | {% else %}
61 | No parameters defined
62 | {% endif %}
63 | |
64 |
65 | {% endfor %}
66 |
67 |
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
--------------------------------------------------------------------------------