forked from netboxlabs/netbox-mcp-server
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
429 lines (354 loc) · 14.5 KB
/
Copy pathserver.py
File metadata and controls
429 lines (354 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
import argparse
import logging
import sys
from typing import Annotated, Any
from fastmcp import FastMCP
from pydantic import Field
from config import Settings, configure_logging
from netbox_client import NetBoxRestClient
from netbox_types import NETBOX_OBJECT_TYPES
mcp = FastMCP("NetBox")
netbox: NetBoxRestClient | None = None
def parse_cli_args() -> dict[str, Any]:
"""
Parse command-line arguments for configuration overrides.
Returns:
dict of configuration overrides (only includes explicitly set values)
"""
parser = argparse.ArgumentParser(
description="NetBox MCP Server - Model Context Protocol server for NetBox",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
# Core NetBox settings
parser.add_argument(
"--netbox-url",
type=str,
help="Base URL of the NetBox instance (e.g., https://netbox.example.com/)",
)
parser.add_argument(
"--netbox-token",
type=str,
help="API token for NetBox authentication",
)
# Transport settings
parser.add_argument(
"--transport",
type=str,
choices=["stdio", "http", "sse"],
help="MCP transport protocol (default: stdio). Use 'sse' for Docker.",
)
parser.add_argument(
"--host",
type=str,
help="Bind host for HTTP/SSE server (default: 127.0.0.1)",
)
parser.add_argument(
"--port",
type=int,
help="Port for HTTP/SSE server (default: 8000)",
)
# Security settings
ssl_group = parser.add_mutually_exclusive_group()
ssl_group.add_argument(
"--verify-ssl",
action="store_true",
dest="verify_ssl",
default=None,
help="Verify SSL certificates (default)",
)
ssl_group.add_argument(
"--no-verify-ssl",
action="store_false",
dest="verify_ssl",
help="Disable SSL certificate verification (not recommended)",
)
# Observability settings
parser.add_argument(
"--log-level",
type=str,
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging verbosity level (default: INFO)",
)
args: argparse.Namespace = parser.parse_args()
overlay: dict[str, Any] = {}
if args.netbox_url is not None:
overlay["netbox_url"] = args.netbox_url
if args.netbox_token is not None:
overlay["netbox_token"] = args.netbox_token
if args.transport is not None:
overlay["transport"] = args.transport
if args.host is not None:
overlay["host"] = args.host
if args.port is not None:
overlay["port"] = args.port
if args.verify_ssl is not None:
overlay["verify_ssl"] = args.verify_ssl
if args.log_level is not None:
overlay["log_level"] = args.log_level
return overlay
# Default object types for global search
DEFAULT_SEARCH_TYPES = [
"dcim.device", # Most common search target
"dcim.site", # Site names frequently searched
"ipam.ipaddress", # IP searches very common
"dcim.interface", # Interface names/descriptions
"dcim.rack", # Rack identifiers
"ipam.vlan", # VLAN names/IDs
"circuits.circuit", # Circuit identifiers
"virtualization.virtualmachine", # VM names
]
mcp = FastMCP("NetBox")
netbox = None
def validate_filters(filters: dict) -> None:
"""
Validate that filters don't use multi-hop relationship traversal.
"""
VALID_SUFFIXES = {
"n", "ic", "nic", "isw", "nisw", "iew", "niew", "ie", "nie",
"empty", "regex", "iregex", "lt", "lte", "gt", "gte", "in",
}
for filter_name in filters:
# Skip special parameters
if filter_name in ("limit", "offset", "fields", "q"):
continue
if "__" not in filter_name:
continue
parts = filter_name.split("__")
# Allow field__suffix pattern (e.g., name__ic, id__gt)
if len(parts) == 2 and parts[-1] in VALID_SUFFIXES:
continue
# Block multi-hop patterns and invalid suffixes
if len(parts) >= 2:
raise ValueError(
f"Invalid filter '{filter_name}': Multi-hop relationship "
f"traversal or invalid lookup suffix not supported. Use direct field filters like "
f"'site_id' or two-step queries."
)
@mcp.tool(
description="""
Get objects from NetBox based on their type and filters
Args:
object_type: String representing the NetBox object type (e.g. "dcim.device", "ipam.ipaddress")
filters: dict of filters to apply to the API call based on the NetBox API filtering options
FILTER RULES:
Valid: Direct fields like {'site_id': 1, 'name': 'router', 'status': 'active'}
Valid: Lookups like {'name__ic': 'switch', 'id__in': [1,2,3], 'vid__gte': 100}
Invalid: Multi-hop like {'device__site_id': 1} - NOT supported
Lookup suffixes: n, ic, nic, isw, nisw, iew, niew, ie, nie,
empty, regex, iregex, lt, lte, gt, gte, in
Two-step pattern for cross-relationship queries:
sites = netbox_get_objects('dcim.site', {'name': 'NYC'})
netbox_get_objects('dcim.device', {'site_id': sites[0]['id']})
fields: Optional list of specific fields to return
**IMPORTANT: ALWAYS USE THIS PARAMETER TO MINIMIZE TOKEN USAGE**
Field filtering significantly reduces response payload and is critical for performance.
- None or [] = returns all fields (NOT RECOMMENDED - use only when you need complete objects)
- ['id', 'name'] = returns only specified fields (RECOMMENDED)
Examples:
- For counting: ['id'] (minimal payload)
- For listings: ['id', 'name', 'status']
- For IP addresses: ['address', 'dns_name', 'description']
Uses NetBox's native field filtering via ?fields= parameter.
**Always specify only the fields you actually need.**
brief: returns only a minimal representation of each object in the response.
This is useful when you need only a list of available objects without any related data.
limit: Maximum results to return (default 5, max 100)
Start with default, increase only if needed
offset: Skip this many results for pagination (default 0)
Example: offset=0 (page 1), offset=5 (page 2), offset=10 (page 3)
ordering: Fields used to determine sort order of results.
Field names may be prefixed with '-' to invert the sort order.
Multiple fields may be specified with a list of strings.
Examples:
- 'name' (alphabetical by name)
- '-id' (ordered by ID descending)
- ['facility', '-name'] (by facility, then by name descending)
- None, '' or [] (default NetBox ordering)
Returns:
Paginated response dict with the following structure:
- count: Total number of objects matching the query
ALWAYS REFER TO THIS FIELD FOR THE TOTAL NUMBER OF OBJECTS MATCHING THE QUERY
- next: URL to next page (or null if no more pages)
ALWAYS REFER TO THIS FIELD FOR THE NEXT PAGE OF RESULTS
- previous: URL to previous page (or null if on first page)
ALWAYS REFER TO THIS FIELD FOR THE PREVIOUS PAGE OF RESULTS
- results: Array of objects for this page
ALWAYS REFER TO THIS FIELD FOR THE OBJECTS ON THIS PAGE
ENSURE YOU ARE AWARE THE RESULTS ARE PAGINATED BEFORE PROVIDING RESPONSE TO THE USER.
Valid object_type values:
""" +
"\n".join(f"- {t}" for t in sorted(NETBOX_OBJECT_TYPES.keys())) +
"""
See NetBox API documentation for filtering options for each object type.
"""
)
def netbox_get_objects(
object_type: str,
filters: dict,
fields: list[str] | None = None,
brief: bool = False,
limit: Annotated[int, Field(default=5, ge=1, le=100)] = 5,
offset: Annotated[int, Field(default=0, ge=0)] = 0,
ordering: str | list[str] | None = None,
):
"""
Get objects from NetBox based on their type and filters
"""
# Validate object_type exists in mapping
if object_type not in NETBOX_OBJECT_TYPES:
valid_types = "\n".join(f"- {t}" for t in sorted(NETBOX_OBJECT_TYPES.keys()))
raise ValueError(f"Invalid object_type. Must be one of:\n{valid_types}")
# Validate filter patterns
validate_filters(filters)
# Get API endpoint from mapping
endpoint = _endpoint_for_type(object_type)
# Build params with pagination (parameters override filters dict)
params = filters.copy()
params["limit"] = limit
params["offset"] = offset
if fields:
params["fields"] = ",".join(fields)
if brief:
params["brief"] = "1"
if ordering:
if isinstance(ordering, list):
ordering = ",".join(ordering)
if ordering.strip() != "":
params["ordering"] = ordering
# Make API call
return netbox.get(endpoint, params=params)
@mcp.tool
def netbox_get_object_by_id(
object_type: str,
object_id: int,
fields: list[str] | None = None,
brief: bool = False,
):
"""
Get detailed information about a specific NetBox object by its ID.
"""
# Validate object_type exists in mapping
if object_type not in NETBOX_OBJECT_TYPES:
valid_types = "\n".join(f"- {t}" for t in sorted(NETBOX_OBJECT_TYPES.keys()))
raise ValueError(f"Invalid object_type. Must be one of:\n{valid_types}")
# Get API endpoint from mapping
endpoint = f"{_endpoint_for_type(object_type)}/{object_id}"
params = {}
if fields:
params["fields"] = ",".join(fields)
if brief:
params["brief"] = "1"
return netbox.get(endpoint, params=params)
@mcp.tool
def netbox_get_changelogs(filters: dict):
"""
Get object change records (changelogs) from NetBox based on filters.
"""
endpoint = "core/object-changes"
return netbox.get(endpoint, params=filters)
@mcp.tool(
description="""
Perform global search across NetBox infrastructure.
Searches names, descriptions, IP addresses, serial numbers, asset tags,
and other key fields across multiple object types.
"""
)
def netbox_search_objects(
query: str,
object_types: list[str] | None = None,
fields: list[str] | None = None,
limit: Annotated[int, Field(default=5, ge=1, le=100)] = 5,
) -> dict[str, list[dict]]:
"""
Perform global search across NetBox infrastructure.
"""
if object_types is None:
search_types = DEFAULT_SEARCH_TYPES
else:
search_types = object_types
# Validate all object types exist in mapping
for obj_type in search_types:
if obj_type not in NETBOX_OBJECT_TYPES:
valid_types = "\n".join(
f"- {t}" for t in sorted(NETBOX_OBJECT_TYPES.keys())
)
raise ValueError(
f"Invalid object_type '{obj_type}'. Must be one of:\n{valid_types}"
)
results = {obj_type: [] for obj_type in search_types}
# Build results dictionary (error-resilient)
for obj_type in search_types:
try:
response = netbox.get(
_endpoint_for_type(obj_type),
params={
"q": query,
"limit": limit,
"fields": ",".join(fields) if fields else None,
},
)
# Extract results array from paginated response
results[obj_type] = response.get("results", [])
except Exception:
# Continue searching other types if one fails
# results[obj_type] already has empty list
continue
return results
def _endpoint_for_type(object_type: str) -> str:
"""
Returns partial API endpoint prefix for the given object type.
e.g., "dcim.device" -> "dcim/devices"
"""
return NETBOX_OBJECT_TYPES[object_type]['endpoint']
if __name__ == "__main__":
cli_overlay: dict[str, Any] = parse_cli_args()
try:
settings = Settings(**cli_overlay)
except Exception as e:
print(f"Configuration error: {e}", file=sys.stderr)
sys.exit(1)
configure_logging(settings.log_level)
logger = logging.getLogger(__name__)
logger.info("Starting NetBox MCP Server")
logger.info(f"Effective configuration: {settings.get_effective_config_summary()}")
if not settings.verify_ssl:
logger.warning(
"SSL certificate verification is DISABLED. "
"This is insecure and should only be used for testing."
)
if settings.transport in ("http", "sse") and settings.host in ["0.0.0.0", "::", "[::]"]:
logger.warning(
f"{settings.transport.upper()} transport is bound to {settings.host}:{settings.port}, which exposes the service to all network interfaces (IPv4/IPv6). "
"This is insecure and should only be used for testing. Ensure this is secured with TLS/reverse proxy if exposed to network."
)
elif settings.transport in ("http", "sse") and settings.host not in [
"127.0.0.1",
"localhost",
]:
logger.info(
f"{settings.transport.upper()} transport is bound to {settings.host}:{settings.port}. "
"Ensure this is secured with TLS/reverse proxy if exposed to network."
)
try:
netbox = NetBoxRestClient(
url=str(settings.netbox_url),
token=settings.netbox_token.get_secret_value(),
verify_ssl=settings.verify_ssl,
)
logger.debug("NetBox client initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize NetBox client: {e}")
sys.exit(1)
try:
if settings.transport == "stdio":
logger.info("Starting stdio transport")
mcp.run(transport="stdio")
elif settings.transport == "http":
logger.info(f"Starting HTTP transport on {settings.host}:{settings.port}")
mcp.run(transport="http", host=settings.host, port=settings.port)
elif settings.transport == "sse":
logger.info(f"Starting SSE transport on {settings.host}:{settings.port} (endpoint: /sse)")
mcp.run(transport="sse", host=settings.host, port=settings.port)
except Exception as e:
logger.error(f"Failed to start MCP server: {e}")
sys.exit(1)