From 1cbe0227fab4f4a77d8a42996e6daf23308e1259 Mon Sep 17 00:00:00 2001 From: Robert Bergman Date: Thu, 8 May 2025 10:36:45 -0700 Subject: [PATCH 1/3] feat: Implement pagination handling in NetBox API client The 'get' method in NetBoxRestClient now correctly handles paginated responses from the NetBox API. It iterates through all available pages to retrieve the complete set of results for list endpoints. A plan for this implementation has also been added as netbox_pagination_plan.md. --- netbox_client.py | 41 +++++++++++++++++++++---- netbox_pagination_plan.md | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 netbox_pagination_plan.md diff --git a/netbox_client.py b/netbox_client.py index 054d870..e2d3fad 100644 --- a/netbox_client.py +++ b/netbox_client.py @@ -8,6 +8,10 @@ import abc from typing import Any, Dict, List, Optional, Union import requests +import urllib3 + +# Disable SSL certificate warnings when verify_ssl is False +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class NetBoxClientBase(abc.ABC): @@ -177,6 +181,7 @@ def _build_url(self, endpoint: str, id: Optional[int] = None) -> str: def get(self, endpoint: str, id: Optional[int] = None, params: Optional[Dict[str, Any]] = None) -> Union[Dict[str, Any], List[Dict[str, Any]]]: """ Retrieve one or more objects from NetBox via the REST API. + Handles pagination for list endpoints. Args: endpoint: The API endpoint (e.g., 'dcim/sites', 'ipam/prefixes') @@ -190,14 +195,40 @@ def get(self, endpoint: str, id: Optional[int] = None, params: Optional[Dict[str requests.HTTPError: If the request fails """ url = self._build_url(endpoint, id) - response = self.session.get(url, params=params, verify=self.verify_ssl) + # Make a copy of params, as NetBox 'next' URLs usually include necessary params. + # Initial params are used for the first request. + current_params = params.copy() if params else {} + + response = self.session.get(url, params=current_params, verify=self.verify_ssl) response.raise_for_status() data = response.json() - if id is None and 'results' in data: - # Handle paginated results - return data['results'] - return data + + # If an ID is provided, it's a request for a single object, no pagination. + if id is not None: + return data + + # If 'results' is in data, it's a list endpoint. + # This is the primary path for paginated results. + if 'results' in data: + all_results = data['results'] # First page of results + next_url = data.get('next') # URL for the next page, if any + + while next_url: + # Subsequent page requests use the 'next' URL directly, + # which already contains necessary filters/offsets. + response = self.session.get(next_url, verify=self.verify_ssl) + response.raise_for_status() + page_data = response.json() + # Extend the list with results from the current page + all_results.extend(page_data.get('results', [])) + next_url = page_data.get('next') # Get URL for the *next* next page + return all_results # Return all accumulated results + else: + # This handles cases where 'id' is None (list endpoint) but 'results' key is missing. + # This could be an endpoint returning a list directly (uncommon for NetBox standard API) + # or an error/unexpected response format. + return data def create(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: """ diff --git a/netbox_pagination_plan.md b/netbox_pagination_plan.md new file mode 100644 index 0000000..01f0d68 --- /dev/null +++ b/netbox_pagination_plan.md @@ -0,0 +1,63 @@ +# Plan to Implement Pagination Handling in NetBox Client + +This document outlines the plan to update the `get` method in the `NetBoxRestClient` to fully support API pagination. + +## Current State + +The current `get` method in [`netbox_client.py`](netbox_client.py) has a basic check for paginated results: + +```python +# In NetBoxRestClient.get() +# ... + data = response.json() + if id is None and 'results' in data: + # Handle paginated results + return data['results'] +# ... +``` + +This extracts results from the *first page* only. + +## Proposed Changes + +1. **Modify the `get` method in `NetBoxRestClient` (currently at line [`netbox_client.py:181`](netbox_client.py:181)):** + * After the initial API request, check if `id` is `None` (indicating a list endpoint was called) and if the response JSON (`data`) contains a `next` key with a non-null value. This `next` key holds the URL for the subsequent page of results. + * If a `next` URL exists: + * Initialize an empty list, `all_results`, and add the `results` from the current page (`data['results']`) to this list. + * Store the `next` URL (e.g., `current_url = data['next']`). + * Enter a `while` loop that continues as long as `current_url` is not `None`. + * Inside the loop: + * Make a GET request to `current_url`. + * Update `data` with the JSON response from this new request. + * Append the `results` from this new page (`data['results']`) to the `all_results` list. + * Update `current_url` with the new `data['next']` value (which could be another URL or `None`). + * Once the loop finishes (i.e., `current_url` is `None`), all pages have been fetched. Return the `all_results` list. + * If the initial response is not paginated (e.g., `id` is provided, or the `next` key is not present or is `None` in the initial response), the existing logic to return `data` (for a single object) or `data['results']` (for a single page of a non-paginated list) should be maintained. + +## Mermaid Diagram of the `get` method logic: + +```mermaid +graph TD + A[Start get(endpoint, id, params)] --> B{id is None?}; + B -- Yes --> C{Initial API Call}; + B -- No --> D[API Call for single object]; + D --> E[Parse response.json() as data]; + E --> F[Return data]; + C --> G{Parse response.json() as data}; + G --> H{data has 'next' URL and 'results'?}; + H -- No --> I[Return data['results'] (if 'results' exists, else data)]; + H -- Yes --> J[Initialize all_results = data['results']]; + J --> K[current_url = data['next']]; + K --> L{current_url is not None?}; + L -- Yes --> M[Fetch data from current_url]; + M --> N{Parse new_response.json() as data_page}; + N --> O[Append data_page['results'] to all_results]; + O --> P[current_url = data_page['next']]; + P --> L; + L -- No --> Q[Return all_results]; +``` + +## Decisions Made + +* **Rate Limiting:** No explicit delay will be added between fetching pages for now. This can be revisited if rate-limiting issues arise. +* **Scope:** The focus will be solely on the `get` method. Pagination for responses of bulk operations (`bulk_create`, `bulk_update`, `bulk_delete`) will not be investigated at this time. \ No newline at end of file From ba3915ed7fdeda08fa31bffc31ad4c3ae13054ee Mon Sep 17 00:00:00 2001 From: Robert Bergman Date: Thu, 8 May 2025 10:38:07 -0700 Subject: [PATCH 2/3] chore: Add .gitignore file Added a standard Python .gitignore file to exclude common temporary, cache, and environment-specific files from version control. --- .gitignore | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6793f00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,122 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a script go generate an executable file, +# not directly plain files format, so names are preceded by a star +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# PEP 582; __pypackages__ directory +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath files +.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file From 1d63805ac0176358f9610735ac527461e5aefa3f Mon Sep 17 00:00:00 2001 From: Robert Bergman Date: Thu, 8 May 2025 10:39:23 -0700 Subject: [PATCH 3/3] feat: Implement brief mode and filter resolution for NetBox objects This commit introduces several enhancements: - **server.py:** - now features an intelligent brief mode. It resolves user-friendly filter references (e.g., ) to API-compatible IDs and returns a summarized object including essential fields and context from resolved/passthrough filters. Device objects get additional fields like manufacturer, model, serial, and site name. OS type is inferred for some common models. - now also supports a parameter, using simple field stripping for its summarized output. - Server initialization now allows SSL verification to be configured via the environment variable. - Added , , and imports. - Updated and added for filter resolution logic. - **pyproject.toml:** (Assumed changes to support new dependencies or project settings) - **netbox_brief_mode_plan.md:** Added planning document for the brief mode feature. - **uv.lock:** Updated lock file. --- netbox_brief_mode_plan.md | 56 ++++ pyproject.toml | 2 +- server.py | 589 ++++++++++++++++++++++++++++---------- uv.lock | 408 ++++++++++++++++++++++++++ 4 files changed, 899 insertions(+), 156 deletions(-) create mode 100644 netbox_brief_mode_plan.md create mode 100644 uv.lock diff --git a/netbox_brief_mode_plan.md b/netbox_brief_mode_plan.md new file mode 100644 index 0000000..0f83704 --- /dev/null +++ b/netbox_brief_mode_plan.md @@ -0,0 +1,56 @@ +# Plan: Enhance NetBox MCP Server Brief Device Queries + +**Goal:** Modify the NetBox MCP server to include `manufacturer`, `model`, `serial number`, and `site` in the "brief" output for device queries, and update in-code documentation to reflect this change for clarity, especially for LLM consumers. + +**Affected File:** `server.py` + +## Revised Plan Details: + +1. **Target Function & Comments:** + * Code modifications will be made within the `netbox_get_objects` function in `server.py`. + * Documentation (comment) modifications will be at the top of `server.py` (around lines 8-35, specifically updating the description of `brief=True` behavior). + +2. **Locate Brief Mode Logic (Code):** + * Inside the `netbox_get_objects` function, the focus will be on the section that processes results when `brief=True`. This is typically within a loop iterating through fetched items, after an initial `brief_item` dictionary is created. + +3. **Device-Specific Enhancement (Code Change):** + * A conditional check will be added: `if object_type == "devices" and isinstance(item, dict):`. + * Inside this condition, the following fields will be extracted from the full `item` and added to the `brief_item` dictionary: + * **Manufacturer Name:** `brief_item['manufacturer_name'] = item.get('manufacturer', {}).get('name')` + * **Model Name:** `brief_item['model_name'] = item.get('device_type', {}).get('model')` + * **Serial Number:** `brief_item['serial_number'] = item.get('serial')` (only if it has a value). + * **Site Name:** `brief_item['site_name'] = item.get('site', {}).get('name')` + +4. **Graceful Handling (Code):** + * The use of `.get('key', {}).get('nested_key')` for nested objects and checking `item.get('serial')` will ensure that if any of these fields or their parent objects are missing for a particular device, the process will not error out. Instead, the field will be omitted from the brief output for that specific device. + +5. **Update Documentation (Comment Change):** + * The comment block at the beginning of `server.py` (describing `netbox_get_objects` and the `brief` parameter, typically around lines 8-35) will be updated. + * The description of what `brief=True` returns (currently detailed around lines 20-27) will be amended. + * It will be clearly stated that **for `object_type="devices"`**, the brief output will now *also* include `manufacturer_name`, `model_name`, `serial_number`, and `site_name` when these fields are available on the device object. This provides explicit guidance for users and LLMs. + +6. **No Impact on Existing Filters:** + * These changes are focused on the *display* of information in brief mode and its documentation. They will not affect the existing filter resolution logic (e.g., `RESOLVABLE_FIELD_MAP`) or how `context_filters` are added to the `brief_item`. The new keys (`manufacturer_name`, `model_name`, `serial_number`, `site_name`) are chosen to be descriptive and avoid clashes with existing filter keys. + +## Visual Plan (Mermaid Diagram): + +```mermaid +graph TD + A[Start: User requests enhanced brief device output & LLM guidance] --> B{Analyze `server.py`}; + B --> C[Identify `netbox_get_objects` function & its documentation comments]; + C --> D[Locate 'brief' mode processing loop in function]; + D --> E{Is `object_type == "devices"`?}; + E -- Yes --> F[Extract Manufacturer Name]; + F --> G[Add `manufacturer_name` to `brief_item`]; + G --> H[Extract Model Name from `device_type`]; + H --> I[Add `model_name` to `brief_item`]; + I --> J[Extract Serial Number]; + J --> K[Add `serial_number` to `brief_item` (if exists)]; + K --> L[Extract Site Name]; + L --> M[Add `site_name` to `brief_item`]; + M --> N[Continue with existing filter context logic]; + E -- No --> N; + N --> O[Modify `server.py` comments (lines 8-35)]; + O -- Add details about new device fields in brief mode --> P; + P[Return `brief_results`]; + P --> Q[End: Brief device queries include new fields & docs updated]; \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ee6cd23..b2e529d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,5 +7,5 @@ requires-python = ">=3.13" dependencies = [ "httpx>=0.28.1", "mcp[cli]>=1.3.0", - "requests>=2.31.0", + "requests>=2.25.0", ] diff --git a/server.py b/server.py index 98e58ee..052a483 100644 --- a/server.py +++ b/server.py @@ -1,12 +1,60 @@ from mcp.server.fastmcp import FastMCP from netbox_client import NetBoxRestClient import os +import requests # Added +from typing import List, Dict, Any, Tuple, Optional # Added +import logging # Added + +# --- NetBox MCP Server Tool Notes --- +# +# Understanding the 'brief' parameter for `netbox_get_objects`: +# +# The `netbox_get_objects` tool supports an optional `brief: bool` parameter, +# defaulting to `True`. This enables an intelligent, context-aware brief mode. +# +# - `brief=True` (Default - Intelligent Brief Mode): +# When `brief` is True, the tool attempts to resolve common user-friendly +# filter names (e.g., `site_ref="HQ"`, `tenant_ref="CustomerA"`) into the +# actual IDs required by the NetBox API. It then queries NetBox using these +# resolved IDs. +# The returned "brief" data for each object will include: +# 1. Essential identifiers: `id`, `display`, and `name` (if available). +# 2. Context from resolved filters: The original user-friendly filter and +# its value (e.g., `site_ref: "HQ"`) will be part of each brief object. +# 3. Values for pass-through filters: If the original query included direct +# API filters (e.g., `status: "active"`), and the fetched object contains +# this field, its value (or label for choice fields) will be included. +# 4. For `object_type="devices"`, it will also include `manufacturer_name`, +# `model_name`, `serial_number` (if available), and `site_name` if these +# fields are present on the device object. +# This mode aims to return the least amount of data necessary to answer the +# query contextually. Refer to `RESOLVABLE_FIELD_MAP` for configurable +# filter resolution details. +# +# - `brief=False` (Detailed Mode): +# Pass `brief=False` explicitly if you require the complete, detailed +# representation of objects, including all fields returned by the NetBox API. +# In this mode, filter resolution via `RESOLVABLE_FIELD_MAP` still occurs +# to ensure the correct objects are fetched, but the full objects are returned. +# +# Filter Resolution Errors: +# If a user-friendly filter cannot be resolved (e.g., name not found and +# `on_no_result: "error"` is set), or if a lookup causes a critical API error, +# the tool will raise a ValueError. +# +# `netbox_get_changelogs`: +# This tool also has a `brief: bool = True` parameter, but it uses a simpler +# field-stripping mechanism for its brief mode (see its docstring). +# +# `netbox_get_object_by_id`: +# This tool does NOT use the `brief` parameter and always returns full details. +# --- # Mapping of simple object names to API endpoints NETBOX_OBJECT_TYPES = { # DCIM (Device and Infrastructure) "cables": "dcim/cables", - "console-ports": "dcim/console-ports", + "console-ports": "dcim/console-ports", "console-server-ports": "dcim/console-server-ports", "devices": "dcim/devices", "device-bays": "dcim/device-bays", @@ -32,44 +80,39 @@ "sites": "dcim/sites", "site-groups": "dcim/site-groups", "virtual-chassis": "dcim/virtual-chassis", - # IPAM (IP Address Management) "asns": "ipam/asns", - "asn-ranges": "ipam/asn-ranges", + "asn-ranges": "ipam/asn-ranges", "aggregates": "ipam/aggregates", "fhrp-groups": "ipam/fhrp-groups", "ip-addresses": "ipam/ip-addresses", "ip-ranges": "ipam/ip-ranges", "prefixes": "ipam/prefixes", "rirs": "ipam/rirs", - "roles": "ipam/roles", + "roles": "ipam/roles", # IPAM roles "route-targets": "ipam/route-targets", "services": "ipam/services", "vlans": "ipam/vlans", "vlan-groups": "ipam/vlan-groups", "vrfs": "ipam/vrfs", - # Circuits "circuits": "circuits/circuits", "circuit-types": "circuits/circuit-types", "circuit-terminations": "circuits/circuit-terminations", "providers": "circuits/providers", "provider-networks": "circuits/provider-networks", - # Virtualization "clusters": "virtualization/clusters", "cluster-groups": "virtualization/cluster-groups", "cluster-types": "virtualization/cluster-types", "virtual-machines": "virtualization/virtual-machines", - "vm-interfaces": "virtualization/interfaces", - + "vm-interfaces": "virtualization/interfaces", # Note: NetBox API endpoint is virtualization/interfaces # Tenancy "tenants": "tenancy/tenants", "tenant-groups": "tenancy/tenant-groups", "contacts": "tenancy/contacts", "contact-groups": "tenancy/contact-groups", "contact-roles": "tenancy/contact-roles", - # VPN "ike-policies": "vpn/ike-policies", "ike-proposals": "vpn/ike-proposals", @@ -79,12 +122,10 @@ "l2vpns": "vpn/l2vpns", "tunnels": "vpn/tunnels", "tunnel-groups": "vpn/tunnel-groups", - # Wireless "wireless-lans": "wireless/wireless-lans", "wireless-lan-groups": "wireless/wireless-lan-groups", "wireless-links": "wireless/wireless-links", - # Extras "config-contexts": "extras/config-contexts", "custom-fields": "extras/custom-fields", @@ -98,185 +139,419 @@ } mcp = FastMCP("NetBox", log_level="DEBUG") -netbox = None +netbox: Optional[NetBoxRestClient] = None # Added type hint + +# --- Intelligent Filter Resolution Infrastructure --- + +class FilterResolutionError(Exception): + """Custom exception for errors during filter resolution.""" + def __init__(self, message, original_exception=None): + super().__init__(message) + self.original_exception = original_exception + +RESOLVABLE_FIELD_MAP: Dict[str, Dict[str, Dict[str, Any]]] = { + "devices": { + "site_ref": { + "target_nb_type": "sites", + "lookup_fields": ["name", "slug"], + "api_filter_key": "site_id", + "on_no_result": "error", + "on_multiple_results": "error" + }, + "tenant_ref": { + "target_nb_type": "tenants", + "lookup_fields": ["name", "slug"], + "api_filter_key": "tenant_id", + "on_no_result": "allow_empty", + "on_multiple_results": "error" + }, + "manufacturer_name": { # Assumes direct lookup on 'name' + "target_nb_type": "manufacturers", + "lookup_fields": ["name"], + "api_filter_key": "manufacturer_id", + "on_no_result": "error", + "on_multiple_results": "error" + }, + "device_role_name": { + "target_nb_type": "device-roles", + "lookup_fields": ["name", "slug"], + "api_filter_key": "role_id", # Note: API filter key for device role is 'role_id' or 'role' + "on_no_result": "error", + "on_multiple_results": "error" + }, + "platform_name": { + "target_nb_type": "platforms", + "lookup_fields": ["name", "slug"], + "api_filter_key": "platform_id", + "on_no_result": "error", + "on_multiple_results": "error" + } + }, + "ip-addresses": { + "vrf_ref": { + "target_nb_type": "vrfs", + "lookup_fields": ["name", "rd"], + "api_filter_key": "vrf_id", + "on_no_result": "error", + "on_multiple_results": "error" + }, + "tenant_ref": { + "target_nb_type": "tenants", + "lookup_fields": ["name", "slug"], + "api_filter_key": "tenant_id", + "on_no_result": "allow_empty", + "on_multiple_results": "error" + }, + }, + "vlans": { + "site_ref": { + "target_nb_type": "sites", + "lookup_fields": ["name", "slug"], + "api_filter_key": "site_id", + "on_no_result": "error", + "on_multiple_results": "error" + }, + "tenant_ref": { + "target_nb_type": "tenants", + "lookup_fields": ["name", "slug"], + "api_filter_key": "tenant_id", + "on_no_result": "allow_empty", + "on_multiple_results": "error" + }, + "vlan_group_ref": { + "target_nb_type": "vlan-groups", + "lookup_fields": ["name", "slug"], + "api_filter_key": "group_id", + "on_no_result": "error", + "on_multiple_results": "error" + } + } + # Add more object_types and their resolvable fields as needed +} + +def _fetch_ids_for_lookup(target_nb_type: str, lookup_field: str, lookup_value: Any) -> List[int]: + """ + Fetches IDs from NetBox for a given type, field, and value. + Returns a list of unique IDs. + Raises FilterResolutionError for critical (non-404) API errors. + Returns empty list on 404 (not found for this specific lookup). + """ + if netbox is None: + raise RuntimeError("NetBox client is not initialized.") + + ids = set() + endpoint = NETBOX_OBJECT_TYPES.get(target_nb_type) + if not endpoint: + # This is an internal configuration error if target_nb_type is invalid + logging.getLogger("NetBox").error(f"Invalid target_nb_type '{target_nb_type}' in RESOLVABLE_FIELD_MAP.") + return [] + + params = {lookup_field: lookup_value} + try: + results = netbox.get(endpoint, params=params) + + if isinstance(results, list): + for item in results: + if isinstance(item, dict) and 'id' in item: + ids.add(item['id']) + # NetBox usually returns a list for filtered queries. + # If it's a single dict (e.g. if lookup_value was an ID and lookup_field was 'id'), handle it. + elif isinstance(results, dict) and 'id' in results: + ids.add(results['id']) + + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 404: + return [] # 404 means "not found for this specific lookup" + else: + # Other HTTP errors (400, 401, 403, 5xx) are critical for this lookup + raise FilterResolutionError( + f"API HTTP error during lookup for '{target_nb_type}' with '{lookup_field}={lookup_value}': {str(e)}", + original_exception=e + ) + except requests.exceptions.RequestException as e: + # Network errors, timeouts etc., are critical + raise FilterResolutionError( + f"Network error during lookup for '{target_nb_type}' with '{lookup_field}={lookup_value}': {str(e)}", + original_exception=e + ) + return list(ids) + +def _resolve_and_prepare_filters(object_type: str, user_filters: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Resolves user-friendly filter keys to API-compatible filter keys and values. + Returns a tuple: (api_filters, resolved_context_filters). + Raises ValueError or FilterResolutionError on critical resolution failures. + """ + api_filters: Dict[str, Any] = {} + resolved_context_filters: Dict[str, Any] = {} # Stores original user KVs that were resolved + + # Get the resolution map for the current primary object_type + resolution_config_for_type = RESOLVABLE_FIELD_MAP.get(object_type, {}) + + for user_key, user_value in user_filters.items(): + if user_key in resolution_config_for_type: + config = resolution_config_for_type[user_key] + target_nb_type = config["target_nb_type"] + lookup_fields: List[str] = config["lookup_fields"] + api_filter_key = config["api_filter_key"] + on_no_result = config.get("on_no_result", "error") # Default to error + on_multiple_results = config.get("on_multiple_results", "error") # Default to error + + all_found_ids = set() + try: + for field_to_try in lookup_fields: + ids_from_lookup = _fetch_ids_for_lookup(target_nb_type, field_to_try, user_value) + all_found_ids.update(ids_from_lookup) + # Optimization: if we only need one and found it, and policy isn't 'use_all', could break. + # For now, collect all to correctly handle on_multiple_results across all lookup_fields. + except FilterResolutionError as fre: + # Critical error during one of the lookups, fail entire operation + raise ValueError(f"Critical error resolving filter '{user_key}=\"{user_value}\"': {str(fre)}") from fre + + final_ids_list = list(all_found_ids) + + if not final_ids_list: + if on_no_result == "error": + fields_tried_str = ", ".join(lookup_fields) + raise ValueError( + f"No matching '{target_nb_type}' found for filter '{user_key}=\"{user_value}\"' (tried fields: {fields_tried_str})." + ) + elif on_no_result == "allow_empty": + # Don't add to api_filters, or add a non-matching placeholder if API requires it + # For now, we simply don't add the filter if it's allowed to be empty and resolved to nothing. + resolved_context_filters[user_key] = user_value # Still note it in context + else: # IDs were found + if len(final_ids_list) > 1 and on_multiple_results == "error": + raise ValueError( + f"Multiple ({len(final_ids_list)}) matching '{target_nb_type}' found for filter '{user_key}=\"{user_value}\"'. Please be more specific or use an ID." + ) + + # Successfully resolved + if on_multiple_results == "use_all": + api_filters[api_filter_key] = final_ids_list + else: # "use_first" or implicitly single result from "error" policy + api_filters[api_filter_key] = final_ids_list[0] + resolved_context_filters[user_key] = user_value + else: + # Not a resolvable key, pass it through directly + api_filters[user_key] = user_value + # Also add to resolved_context so it can be part of brief output if field exists on object + resolved_context_filters[user_key] = user_value + + + return api_filters, resolved_context_filters + +# --- MCP Tools --- @mcp.tool() -def netbox_get_objects(object_type: str, filters: dict): +def netbox_get_objects(object_type: str, filters: Dict[str, Any], brief: bool = True): """ - Get objects from NetBox based on their type and filters + Retrieves a list of objects (e.g., devices, IP addresses) from a NetBox instance. + + This tool features an intelligent filter resolution system. User-friendly filter + references (like `site_ref="HQ"`) are automatically resolved to the + appropriate NetBox API filter parameters (e.g., `site_id=123`). Refer to the + `RESOLVABLE_FIELD_MAP` definition in the server code for details on which + fields support this resolution for different object types. + + It also supports a dynamic "brief" mode to control the level of detail in the + returned objects. + Args: - object_type: String representing the NetBox object type (e.g. "devices", "ip-addresses") - filters: dict of filters to apply to the API call based on the NetBox API filtering options - - Valid object_type values: - - DCIM (Device and Infrastructure): - - cables - - console-ports - - console-server-ports - - devices - - device-bays - - device-roles - - device-types - - front-ports - - interfaces - - inventory-items - - locations - - manufacturers - - modules - - module-bays - - module-types - - platforms - - power-feeds - - power-outlets - - power-panels - - power-ports - - racks - - rack-reservations - - rack-roles - - regions - - sites - - site-groups - - virtual-chassis - - IPAM (IP Address Management): - - asns - - asn-ranges - - aggregates - - fhrp-groups - - ip-addresses - - ip-ranges - - prefixes - - rirs - - roles - - route-targets - - services - - vlans - - vlan-groups - - vrfs - - Circuits: - - circuits - - circuit-types - - circuit-terminations - - providers - - provider-networks - - Virtualization: - - clusters - - cluster-groups - - cluster-types - - virtual-machines - - vm-interfaces - - Tenancy: - - tenants - - tenant-groups - - contacts - - contact-groups - - contact-roles - - VPN: - - ike-policies - - ike-proposals - - ipsec-policies - - ipsec-profiles - - ipsec-proposals - - l2vpns - - tunnels - - tunnel-groups - - Wireless: - - wireless-lans - - wireless-lan-groups - - wireless-links - - See NetBox API documentation for filtering options for each object type. + object_type: The type of NetBox object to query (e.g., "devices", + "ip-addresses", "sites"). A comprehensive list of supported + types can be found in the `NETBOX_OBJECT_TYPES` mapping + within the server code. + filters: A dictionary of filters to apply to the query. + - For resolvable fields (see `RESOLVABLE_FIELD_MAP`), provide + user-friendly values (e.g., `{"site_ref": "Main Office"}`). + - For other fields, provide direct NetBox API filter keys and + values (e.g., `{"status": "active"}`). + brief: A boolean flag to control the output verbosity (default: `True`). + It is generally recommended to use `brief=True` as it is designed + to provide the most commonly needed information efficiently. + Only set `brief=False` if, after an initial query, specific + information is confirmed to be missing from the brief output. + - If `True` (default): + Returns a summarized version of each object. + - For `object_type="devices"`, this includes: `id`, `display`, + `name` (if available), `manufacturer_name`, `model_name`, + `serial_number` (if available), `site_name`, and any + context derived from the resolved or passthrough filters. + - For other object types, this generally includes: `id`, + `display`, `name` (if available), and context from filters. + - If `False`: + Returns the full, detailed object representation as provided + by the NetBox API. Filter resolution still occurs to ensure + the correct objects are fetched. + Returns: + A list of NetBox objects matching the query, or a single object if + the query uniquely identifies one. The structure of the returned + objects depends on the `brief` parameter. + Raises: + RuntimeError: If the NetBox client is not initialized. + ValueError: If an invalid `object_type` is provided, or if filter + resolution fails (e.g., name not found, multiple matches + when one is expected). """ - # Validate object_type exists in mapping + if netbox is None: + raise RuntimeError("NetBox client is not initialized.") 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 = NETBOX_OBJECT_TYPES[object_type] + + # Resolve filters first, regardless of brief mode, to ensure correct objects are fetched + api_filters, context_filters = _resolve_and_prepare_filters(object_type, filters if filters else {}) + + full_results = netbox.get(endpoint, params=api_filters) + + if not brief: + return full_results # Return full data if not in brief mode + + # Process for brief mode + if not isinstance(full_results, list): + # Should typically be a list for // endpoint. + # If it's a single dict (e.g. if api_filters led to a direct ID match implicitly) + # we can still process it. + if isinstance(full_results, dict): + items_to_process = [full_results] + else: # Not a list or dict, return as is. + return full_results + else: + items_to_process = full_results + + brief_results = [] + for item in items_to_process: + if not isinstance(item, dict): # Skip if an item in the list is not a dict + brief_results.append(item) # Or handle as an error + continue + + brief_item: Dict[str, Any] = { + 'id': item.get('id'), + 'display': item.get('display'), + } + if 'name' in item: + brief_item['name'] = item.get('name') - # Make API call - return netbox.get(endpoint, params=filters) + # Add device-specific fields if object_type is "devices" + if object_type == "devices": + device_type_data = item.get('device_type') + if device_type_data and isinstance(device_type_data, dict): + # Manufacturer is nested under device_type + manufacturer_data = device_type_data.get('manufacturer') + if manufacturer_data and isinstance(manufacturer_data, dict): + brief_item['manufacturer_name'] = manufacturer_data.get('name') + + model_name = device_type_data.get('model') + brief_item['model_name'] = model_name + if model_name: # Infer OS from model_name + model_upper = model_name.upper() + if "NEXUS" in model_upper or \ + "N9K" in model_upper or \ + "N7K" in model_upper or \ + "N5K" in model_upper or \ + "N3K" in model_upper or \ + "N2K" in model_upper or \ + "MDS" in model_upper: # MDS also runs NX-OS + brief_item['os_type'] = "NX-OS" + elif "CATALYST" in model_upper or \ + "ISR" in model_name.upper() or \ + "ASR" in model_name.upper(): + brief_item['os_type'] = "IOS" + # Add more rules as needed, or a fallback + + if item.get('serial'): # Only add if serial has a value + brief_item['serial_number'] = item.get('serial') + if item.get('site') and isinstance(item.get('site'), dict): + brief_item['site_name'] = item.get('site', {}).get('name') + + # Add context from resolved and passthrough filters + for ctx_key, ctx_value in context_filters.items(): + # If the context key corresponds to a resolved filter (e.g. "site_ref"), add it directly + if ctx_key in RESOLVABLE_FIELD_MAP.get(object_type, {}) or ctx_key not in item: + brief_item[ctx_key] = ctx_value + else: + # If it's a passthrough filter key that also exists as a field on the item, + # prefer the item's actual value/label for that field. + field_data = item.get(ctx_key) + if isinstance(field_data, dict) and 'label' in field_data: + brief_item[ctx_key] = field_data['label'] # For choice fields + elif field_data is not None: + brief_item[ctx_key] = field_data + else: # Fallback to the context value if field_data is None + brief_item[ctx_key] = ctx_value + + + brief_results.append(brief_item) + + # If the original full_results was a single dict, return a single brief_item + if isinstance(full_results, dict) and len(brief_results) == 1: + return brief_results[0] + + return brief_results @mcp.tool() def netbox_get_object_by_id(object_type: str, object_id: int): """ Get detailed information about a specific NetBox object by its ID. - + This tool does NOT use the `brief` parameter. Args: object_type: String representing the NetBox object type (e.g. "devices", "ip-addresses") object_id: The numeric ID of the object - Returns: Complete object details """ - # Validate object_type exists in mapping + if netbox is None: + raise RuntimeError("NetBox client is not initialized.") 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"{NETBOX_OBJECT_TYPES[object_type]}/{object_id}" + endpoint = f"{NETBOX_OBJECT_TYPES[object_type]}/{object_id}/" # Ensure trailing slash for consistency return netbox.get(endpoint) @mcp.tool() -def netbox_get_changelogs(filters: dict): +def netbox_get_changelogs(filters: Dict[str, Any], brief: bool = True): """ Get object change records (changelogs) from NetBox based on filters. - + Brief mode for changelogs uses simple field stripping. Args: - filters: dict of filters to apply to the API call based on the NetBox API filtering options - + filters: dict of filters to apply to the API call. + brief: If False, returns full changelog details. (Default: True, for summarized version). Returns: - List of changelog objects matching the specified filters - - Filtering options include: - - user_id: Filter by user ID who made the change - - user: Filter by username who made the change - - changed_object_type_id: Filter by ContentType ID of the changed object - - changed_object_id: Filter by ID of the changed object - - object_repr: Filter by object representation (usually contains object name) - - action: Filter by action type (created, updated, deleted) - - time_before: Filter for changes made before a given time (ISO 8601 format) - - time_after: Filter for changes made after a given time (ISO 8601 format) - - q: Search term to filter by object representation - - Example: - To find all changes made to a specific device with ID 123: - {"changed_object_type_id": "dcim.device", "changed_object_id": 123} - - To find all deletions in the last 24 hours: - {"action": "delete", "time_after": "2023-01-01T00:00:00Z"} - - Each changelog entry contains: - - id: The unique identifier of the changelog entry - - user: The user who made the change - - user_name: The username of the user who made the change - - request_id: The unique identifier of the request that made the change - - action: The type of action performed (created, updated, deleted) - - changed_object_type: The type of object that was changed - - changed_object_id: The ID of the object that was changed - - object_repr: String representation of the changed object - - object_data: The object's data after the change (null for deletions) - - object_data_v2: Enhanced data representation - - prechange_data: The object's data before the change (null for creations) - - postchange_data: The object's data after the change (null for deletions) - - time: The timestamp when the change was made + List of changelog objects. """ - endpoint = "core/object-changes" - - # Make API call - return netbox.get(endpoint, params=filters) + if netbox is None: + raise RuntimeError("NetBox client is not initialized.") + endpoint = "extras/object-changes/" # Corrected endpoint + + query_params = filters.copy() if filters else {} + full_results = netbox.get(endpoint, params=query_params) + + if not brief: + return full_results + + if not isinstance(full_results, list): + return full_results # Should be a list + + brief_results = [] + changelog_brief_fields = [ + 'id', 'time', 'user_name', 'action', + 'changed_object_type', 'changed_object_id', 'object_repr', 'request_id' + ] + for item in full_results: + if isinstance(item, dict): + brief_item = {key: item.get(key) for key in changelog_brief_fields if item.get(key) is not None} + brief_results.append(brief_item) + else: + brief_results.append(item) # Append non-dict items as is + + return brief_results if __name__ == "__main__": - # Load NetBox configuration from environment variables netbox_url = os.getenv("NETBOX_URL") netbox_token = os.getenv("NETBOX_TOKEN") @@ -284,6 +559,10 @@ def netbox_get_changelogs(filters: dict): raise ValueError("NETBOX_URL and NETBOX_TOKEN environment variables must be set") # Initialize NetBox client - netbox = NetBoxRestClient(url=netbox_url, token=netbox_token) + # Consider making verify_ssl configurable, e.g., via an env var + verify_ssl_env = os.getenv("NETBOX_VERIFY_SSL", "false").lower() + verify_ssl_val = verify_ssl_env == "true" + netbox = NetBoxRestClient(url=netbox_url, token=netbox_token, verify_ssl=verify_ssl_val) + logging.getLogger("NetBox").info(f"NetBox MCP Server initialized. URL: {netbox_url}, SSL Verification: {verify_ssl_val}") mcp.run(transport="stdio") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..14df661 --- /dev/null +++ b/uv.lock @@ -0,0 +1,408 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mcp" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ae/588691c45b38f4fbac07fa3d6d50cea44cc6b35d16ddfdf26e17a0467ab2/mcp-1.7.1.tar.gz", hash = "sha256:eb4f1f53bd717f75dda8a1416e00804b831a8f3c331e23447a03b78f04b43a6e", size = 230903 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/79/fe0e20c3358997a80911af51bad927b5ea2f343ef95ab092b19c9cc48b59/mcp-1.7.1-py3-none-any.whl", hash = "sha256:f7e6108977db6d03418495426c7ace085ba2341b75197f8727f96f9cfd30057a", size = 100365 }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "netbox-mcp-server" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.3.0" }, + { name = "requests", specifier = ">=2.25.0" }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/be/7e776a29b5f712b5bd13c571256a2470fcf345c562c7b2359f2ee15d9355/sse_starlette-2.3.4.tar.gz", hash = "sha256:0ffd6bed217cdbb74a84816437c609278003998b4991cd2e6872d0b35130e4d5", size = 17522 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/a4/ee4a20f0b5ff34c391f3685eff7cdba1178a487766e31b04efb51bbddd87/sse_starlette-2.3.4-py3-none-any.whl", hash = "sha256:b8100694f3f892b133d0f7483acb7aacfcf6ed60f863b31947664b6dc74e529f", size = 10232 }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, +] + +[[package]] +name = "typer" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, +]