diff --git a/.devcontainer/scripts/generate-configs.sh b/.devcontainer/scripts/generate-configs.sh index 745f9633..987137ed 100755 --- a/.devcontainer/scripts/generate-configs.sh +++ b/.devcontainer/scripts/generate-configs.sh @@ -31,4 +31,17 @@ cat "${DEVCONTAINER_DIR}/resources/devcontainer-Dockerfile" echo "Generated $OUT_FILE using root dir $ROOT_DIR" +# Passive Gemini MCP config +TOKEN=$(grep '^API_TOKEN=' /data/config/app.conf 2>/dev/null | cut -d"'" -f2) +if [ -n "${TOKEN}" ]; then + mkdir -p "${ROOT_DIR}/.gemini" + [ -f "${ROOT_DIR}/.gemini/settings.json" ] || echo "{}" > "${ROOT_DIR}/.gemini/settings.json" + jq --arg t "$TOKEN" '.mcpServers["netalertx-devcontainer"] = {url: "http://127.0.0.1:20212/mcp/sse", headers: {Authorization: ("Bearer " + $t)}}' "${ROOT_DIR}/.gemini/settings.json" > "${ROOT_DIR}/.gemini/settings.json.tmp" && mv "${ROOT_DIR}/.gemini/settings.json.tmp" "${ROOT_DIR}/.gemini/settings.json" + + # VS Code MCP config + mkdir -p "${ROOT_DIR}/.vscode" + [ -f "${ROOT_DIR}/.vscode/mcp.json" ] || echo "{}" > "${ROOT_DIR}/.vscode/mcp.json" + jq --arg t "$TOKEN" '.servers["netalertx-devcontainer"] = {type: "sse", url: "http://127.0.0.1:20212/mcp/sse", headers: {Authorization: ("Bearer " + $t)}}' "${ROOT_DIR}/.vscode/mcp.json" > "${ROOT_DIR}/.vscode/mcp.json.tmp" && mv "${ROOT_DIR}/.vscode/mcp.json.tmp" "${ROOT_DIR}/.vscode/mcp.json" +fi + echo "Done." \ No newline at end of file diff --git a/.gemini/skills/testing-workflow/SKILL.md b/.gemini/skills/testing-workflow/SKILL.md index a81c8bb4..debf7983 100644 --- a/.gemini/skills/testing-workflow/SKILL.md +++ b/.gemini/skills/testing-workflow/SKILL.md @@ -1,6 +1,6 @@ --- name: testing-workflow -description: Read before running tests. Detailed instructions for single, astandard unit tests (fast), full suites (slow), and handling authentication. Tests must be run when a job is complete. +description: Read before running tests. Detailed instructions for single, standard unit tests (fast), full suites (slow), handling authentication, and obtaining the API Token. Tests must be run when a job is complete. --- # Testing Workflow @@ -8,6 +8,19 @@ After code is developed, tests must be run to ensure the integrity of the final **Crucial:** Tests MUST be run inside the container to access the correct runtime environment (DB, Config, Dependencies). +## 0. Pre-requisites: Environment Check + +Before running any tests, verify you are inside the development container: + +```bash +ls -d /workspaces/NetAlertX +``` + +**IF** this directory does not exist, you are likely on the host machine. You **MUST** immediately activate the `devcontainer-management` skill to enter the container or run commands inside it. + +```text +activate_skill("devcontainer-management") +``` ## 1. Full Test Suite (MANDATORY DEFAULT) @@ -38,13 +51,24 @@ cd /workspaces/NetAlertX; pytest test/ cd /workspaces/NetAlertX; pytest test/api_endpoints/test_mcp_extended_endpoints.py ``` -## Authentication in Tests +## Authentication & Environment Reset -The test environment uses `API_TOKEN`. The most reliable way to retrieve the current token from a running container is: +Authentication tokens are required to perform certain operations such as manual testing or crafting expressions to work with the web APIs. After making code changes, you MUST reset the environment to ensure the new code is running and verify you have the latest `API_TOKEN`. -```bash -python3 -c "from helper import get_setting_value; print(get_setting_value('API_TOKEN'))" -``` +1. **Reset Environment:** Run the setup script inside the container. + ```bash + bash /workspaces/NetAlertX/.devcontainer/scripts/setup.sh + ``` +2. **Wait for Stabilization:** Wait at least 5 seconds for services (nginx, python server, etc.) to start. + ```bash + sleep 5 + ``` +3. **Obtain Token:** Retrieve the current token from the container. + ```bash + python3 -c "from helper import get_setting_value; print(get_setting_value('API_TOKEN'))" + ``` + +The retrieved token MUST be used in all subsequent API or test calls requiring authentication. ### Troubleshooting diff --git a/.gitignore b/.gitignore index 760bb78f..dea40523 100755 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ docker-compose.yml.ffsb42 .env.omada.ffsb42 .venv test_mounts/ +.gemini/settings.json +.vscode/mcp.json diff --git a/docs/API_MCP.md b/docs/API_MCP.md index 344f163a..387facef 100644 --- a/docs/API_MCP.md +++ b/docs/API_MCP.md @@ -49,7 +49,7 @@ sequenceDiagram API-->>MCP: 5. Available tools spec MCP-->>AI: 6. Tool definitions AI->>MCP: 7. tools/call: search_devices - MCP->>API: 8. POST /mcp/sse/devices/search + MCP->>API: 8. POST /devices/search API->>DB: 9. Query devices DB-->>API: 10. Device data API-->>MCP: 11. JSON response @@ -72,9 +72,9 @@ graph LR end subgraph "NetAlertX API Server (:20211)" - F[Device APIs
/mcp/sse/devices/*] - G[Network Tools
/mcp/sse/nettools/*] - H[Events API
/mcp/sse/events/*] + F[Device APIs
/devices/*] + G[Network Tools
/nettools/*] + H[Events API
/events/*] end subgraph "Backend" @@ -182,27 +182,28 @@ eventSource.onmessage = function(event) { | Tool | Endpoint | Description | |------|----------|-------------| -| `list_devices` | `/mcp/sse/devices/by-status` | List devices by online status | -| `get_device_info` | `/mcp/sse/device/` | Get detailed device information | -| `search_devices` | `/mcp/sse/devices/search` | Search devices by MAC, name, or IP | -| `get_latest_device` | `/mcp/sse/devices/latest` | Get most recently connected device | -| `set_device_alias` | `/mcp/sse/device//set-alias` | Set device friendly name | +| `list_devices` | `/devices/by-status` | List devices by online status | +| `get_device_info` | `/device/{mac}` | Get detailed device information | +| `search_devices` | `/devices/search` | Search devices by MAC, name, or IP | +| `get_latest_device` | `/devices/latest` | Get most recently connected device | +| `set_device_alias` | `/device/{mac}/set-alias` | Set device friendly name | ### Network Tools | Tool | Endpoint | Description | |------|----------|-------------| -| `trigger_scan` | `/mcp/sse/nettools/trigger-scan` | Trigger network discovery scan | -| `get_open_ports` | `/mcp/sse/device/open_ports` | Get stored NMAP open ports for device | -| `wol_wake_device` | `/mcp/sse/nettools/wakeonlan` | Wake device using Wake-on-LAN | -| `get_network_topology` | `/mcp/sse/devices/network/topology` | Get network topology map | +| `trigger_scan` | `/nettools/trigger-scan` | Trigger network discovery scan to find new devices. | +| `run_nmap_scan` | `/nettools/nmap` | Perform NMAP scan on a target to identify open ports. | +| `get_open_ports` | `/device/open_ports` | Get stored NMAP open ports. Use `run_nmap_scan` first if empty. | +| `wol_wake_device` | `/nettools/wakeonlan` | Wake device using Wake-on-LAN | +| `get_network_topology` | `/devices/network/topology` | Get network topology map | ### Event & Monitoring Tools | Tool | Endpoint | Description | |------|----------|-------------| -| `get_recent_alerts` | `/mcp/sse/events/recent` | Get events from last 24 hours | -| `get_last_events` | `/mcp/sse/events/last` | Get 10 most recent events | +| `get_recent_alerts` | `/events/recent` | Get events from last 24 hours | +| `get_last_events` | `/events/last` | Get 10 most recent events | --- diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 56ce651b..38c4c009 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -414,8 +414,8 @@ "Maintenance_Tool_ImportPastedConfig": "Import des paramètres (coller)", "Maintenance_Tool_ImportPastedConfig_noti_text": "Êtes-vous sûr de vouloir importer les paramètres de configuration copiés ? Cela va complètement remplacer le fichier app.conf.", "Maintenance_Tool_ImportPastedConfig_text": "Importe le fichier app.conf, qui contient tous les paramètres de l'application. Vous devriez commencer par télécharger le fichier actuelapp.conf avec la fonctionnalité Export des paramètres.", - "Maintenance_Tool_UnlockFields": "Supprimer toutes les sources des appareils", - "Maintenance_Tool_UnlockFields_noti": "Supprimer toutes les sources des appareils", + "Maintenance_Tool_UnlockFields": "Déverrouiller les champs de l'appareil", + "Maintenance_Tool_UnlockFields_noti": "Déverrouiller les champs de l'appareil", "Maintenance_Tool_UnlockFields_noti_text": "Êtes-vous sûr de vouloir supprimer toutes les valeurs de source (verrouillés par l'utilisateur LOCKED/USER) pour tous les champs d'appareil de tous les appareils ? Cette action ne peut pas être annulée.", "Maintenance_Tool_UnlockFields_text": "Cet outil va supprimer toutes les valeurs de source pour chaque champ suivi de tous les appareils, ce qui déverrouillera tous les champs pour les plugins et les utilisateurs. Utilisez-lebm avec précaution, cela impactera l'ensemble de l'inventaire des appareils.", "Maintenance_Tool_arpscansw": "Basculer l'arp-Scan (activé/désactivé)", @@ -427,9 +427,9 @@ "Maintenance_Tool_backup_noti_text": "Êtes-vous sûr de vouloir lancer la sauvegarde de la base de données ? Assurez-vous de ne pas avoir de scan en cours.", "Maintenance_Tool_backup_text": "Les sauvegardes de base de données sont situées dans le répertoire de la base de données, soir forme d'archive ZIP, nommé selon la date de création. Il n'y a pas de limite de nombre de sauvegarde.", "Maintenance_Tool_check_visible": "Décocher pour masquer la colonne.", - "Maintenance_Tool_clearSourceFields_selected": "", - "Maintenance_Tool_clearSourceFields_selected_noti": "", - "Maintenance_Tool_clearSourceFields_selected_text": "", + "Maintenance_Tool_clearSourceFields_selected": "Supprimer les champs source", + "Maintenance_Tool_clearSourceFields_selected_noti": "Supprimer les sources", + "Maintenance_Tool_clearSourceFields_selected_text": "Cela va supprimer tous les champs de sources des appareils sélectionnés. Cette action est irréversible.", "Maintenance_Tool_darkmode": "Basculer de mode (clair/sombre)", "Maintenance_Tool_darkmode_noti": "Basculer de mode", "Maintenance_Tool_darkmode_noti_text": "Après le changement de thème, la page tente de se rafraîchir pour activer le changement. Si besoin, le cache doit être supprimé.", @@ -789,4 +789,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} \ No newline at end of file +} diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index c3edccb7..f4cc17bb 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -414,8 +414,8 @@ "Maintenance_Tool_ImportPastedConfig": "Importa impostazioni (incolla)", "Maintenance_Tool_ImportPastedConfig_noti_text": "Vuoi davvero importare le impostazioni di configurazione incollate? Questo sovrascriverà completamente il file app.conf.", "Maintenance_Tool_ImportPastedConfig_text": "Importa il file app.conf contenente tutte le impostazioni dell'applicazione. Potresti voler scaricare prima il file app.conf corrente con Esporta impostazioni.", - "Maintenance_Tool_UnlockFields": "Cancella tutte le sorgenti del dispositivo", - "Maintenance_Tool_UnlockFields_noti": "Cancella tutte le sorgenti del dispositivo", + "Maintenance_Tool_UnlockFields": "Sblocca campi del dispositivo", + "Maintenance_Tool_UnlockFields_noti": "Sblocca campi del dispositivo", "Maintenance_Tool_UnlockFields_noti_text": "Vuoi davvero cancellare tutti i valori sorgente (BLOCCATO/UTENTE) per tutti i campi dispositivo su tutti i dispositivi? Questa azione non può essere annullata.", "Maintenance_Tool_UnlockFields_text": "Questo strumento rimuoverà tutti i valori sorgente da ogni campo tracciato per tutti i dispositivi, sbloccando di fatto tutti i campi per plugin e utenti. Usalo con cautela, poiché influirà sull'intero inventario dei dispositivi.", "Maintenance_Tool_arpscansw": "Attiva/disattiva arp-Scan", @@ -427,9 +427,9 @@ "Maintenance_Tool_backup_noti_text": "Sei sicuro di voler eseguire il backup del DB? Assicurati che nessuna scansione sia attualmente in esecuzione.", "Maintenance_Tool_backup_text": "I backup del database si trovano nella directory del database come archivio zip, denominato con la data di creazione. Non esiste un numero massimo di backup.", "Maintenance_Tool_check_visible": "Deseleziona per nascondere la colonna.", - "Maintenance_Tool_clearSourceFields_selected": "", - "Maintenance_Tool_clearSourceFields_selected_noti": "", - "Maintenance_Tool_clearSourceFields_selected_text": "", + "Maintenance_Tool_clearSourceFields_selected": "Cancella campi sorgente", + "Maintenance_Tool_clearSourceFields_selected_noti": "Cancella sorgenti", + "Maintenance_Tool_clearSourceFields_selected_text": "Questa operazione cancellerà tutti i campi sorgente dei dispositivi selezionati. Questa azione non può essere annullata.", "Maintenance_Tool_darkmode": "Alterna modalità (Scuro/Chiaro)", "Maintenance_Tool_darkmode_noti": "Alterna modalità", "Maintenance_Tool_darkmode_noti_text": "Dopo il cambio di tema, la pagina tenta di ricaricarsi per attivare la modifica. Potrebbe essere necessaria la cancellazione della cache.", @@ -789,4 +789,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} \ No newline at end of file +} diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 493c55ca..e644cfe1 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -789,4 +789,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.", "test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки." -} \ No newline at end of file +} diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 8c5efe4f..fffbddc8 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -789,4 +789,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Оновіть значення нижче. Слідкуйте за попереднім форматом. Перевірка не виконана.", "test_event_tooltip": "Перш ніж перевіряти налаштування, збережіть зміни." -} \ No newline at end of file +} diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 0d46621b..4d3b2683 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -789,4 +789,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} \ No newline at end of file +} diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 221d57be..17c68a1b 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -72,6 +72,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression] DeviceUpdateRequest, DeviceInfo, BaseResponse, DeviceTotalsResponse, + DeviceTotalsNamedResponse, DeleteDevicesRequest, DeviceImportRequest, DeviceImportResponse, UpdateDeviceColumnRequest, LockDeviceFieldRequest, UnlockDeviceFieldsRequest, @@ -289,7 +290,6 @@ def api_get_setting(setKey): # -------------------------- # Device Endpoints # -------------------------- -@app.route('/mcp/sse/device/', methods=['GET', 'POST']) @app.route("/device/", methods=["GET"]) @validate_request( operation_id="get_device_info", @@ -432,7 +432,7 @@ def api_device_copy(payload=None): @validate_request( operation_id="update_device_column", summary="Update Device Column", - description="Update a specific database column for a device.", + description="Update a specific database column for a device. Use this to mark devices as favorites (columnName='devFavorite', columnValue=1). See `get_favorite_devices` to retrieve them.", path_params=[{ "name": "mac", "description": "Device MAC address", @@ -554,7 +554,6 @@ def api_device_fields_unlock(payload=None): # Devices Collections # -------------------------- -@app.route('/mcp/sse/device//set-alias', methods=['POST']) @app.route('/device//set-alias', methods=['POST']) @validate_request( operation_id="set_device_alias", @@ -582,16 +581,25 @@ def api_device_set_alias(mac, payload=None): return jsonify(result) -@app.route('/mcp/sse/device/open_ports', methods=['POST']) @app.route('/device/open_ports', methods=['POST']) @validate_request( operation_id="get_open_ports", summary="Get Open Ports", - description="Retrieve open ports for a target IP or MAC address. Returns cached NMAP scan results.", + description="Retrieve open ports for a target IP or MAC address. Returns cached NMAP scan results. If no ports are found, run a scan first using `run_nmap_scan`.", request_model=OpenPortsRequest, response_model=OpenPortsResponse, tags=["nettools"], - auth_callable=is_authorized + auth_callable=is_authorized, + links={ + "RunNmapScan": { + "operationId": "run_nmap_scan", + "parameters": { + "scan": "$response.body#/target", + "mode": "fast" + }, + "description": "Refresh the open ports data by running a new NMAP scan on this target." + } + } ) def api_device_open_ports(payload=None): """Get stored NMAP open ports for a target IP or MAC.""" @@ -606,7 +614,7 @@ def api_device_open_ports(payload=None): open_ports = device_handler.getOpenPorts(target) if not open_ports: - return jsonify({"success": False, "error": f"No stored open ports for {target}. Run a scan with `/nettools/trigger-scan`"}), 404 + return jsonify({"success": False, "error": f"No stored open ports for {target}. Run a scan with the 'run_nmap_scan' tool (or /nettools/nmap)."}), 404 return jsonify({"success": True, "target": target, "open_ports": open_ports}) @@ -674,7 +682,6 @@ def api_delete_unknown_devices(payload=None): return jsonify(device_handler.deleteUnknownDevices()) -@app.route('/mcp/sse/devices/export', methods=['GET']) @app.route("/devices/export", methods=["GET"]) @app.route("/devices/export/", methods=["GET"]) @validate_request( @@ -716,7 +723,6 @@ def api_export_devices(format=None, payload=None): ) -@app.route('/mcp/sse/devices/import', methods=['POST']) @app.route("/devices/import", methods=["POST"]) @validate_request( operation_id="import_devices", @@ -746,12 +752,11 @@ def api_import_csv(payload=None): return jsonify(result) -@app.route('/mcp/sse/devices/totals', methods=['GET']) @app.route("/devices/totals", methods=["GET"]) @validate_request( operation_id="get_device_totals", - summary="Get Device Totals", - description="Get device statistics including total count, online/offline counts, new devices, and archived devices.", + summary="Get Device Totals (Deprecated)", + description="Get device statistics including total count, online/offline counts, new devices, and archived devices. Deprecated: use /devices/totals/named instead.", response_model=DeviceTotalsResponse, tags=["devices"], auth_callable=is_authorized @@ -761,7 +766,30 @@ def api_devices_totals(payload=None): return jsonify(device_handler.getTotals()) -@app.route('/mcp/sse/devices/by-status', methods=['GET', 'POST']) +@app.route("/devices/totals/named", methods=["GET"]) +@validate_request( + operation_id="get_device_totals_named", + summary="Get Named Device Totals", + description="Get device statistics with named fields including total count, online/offline counts, new devices, and archived devices.", + response_model=DeviceTotalsNamedResponse, + tags=["devices"], + auth_callable=is_authorized +) +def api_devices_totals_named(payload=None): + device_handler = DeviceInstance() + totals_list = device_handler.getTotals() + # totals_list order: [devices, connected, favorites, new, down, archived] + totals_dict = { + "devices": totals_list[0] if len(totals_list) > 0 else 0, + "connected": totals_list[1] if len(totals_list) > 1 else 0, + "favorites": totals_list[2] if len(totals_list) > 2 else 0, + "new": totals_list[3] if len(totals_list) > 3 else 0, + "down": totals_list[4] if len(totals_list) > 4 else 0, + "archived": totals_list[5] if len(totals_list) > 5 else 0 + } + return jsonify({"success": True, "totals": totals_dict}) + + @app.route("/devices/by-status", methods=["GET", "POST"]) @validate_request( operation_id="list_devices_by_status_api", @@ -811,12 +839,11 @@ def api_devices_by_status(payload: DeviceListRequest = None): return jsonify(device_handler.getByStatus(status)) -@app.route('/mcp/sse/devices/search', methods=['POST']) @app.route('/devices/search', methods=['POST']) @validate_request( operation_id="search_devices_api", summary="Search Devices", - description="Search for devices based on various criteria like name, IP, MAC, or vendor.", + description="Search for devices based on various criteria like name, IP, MAC, or vendor. Use this to find MAC addresses for other tools.", request_model=DeviceSearchRequest, response_model=DeviceSearchResponse, tags=["devices"], @@ -878,7 +905,6 @@ def api_devices_search(payload=None): return jsonify({"success": True, "devices": matches}) -@app.route('/mcp/sse/devices/latest', methods=['GET']) @app.route('/devices/latest', methods=['GET']) @validate_request( operation_id="get_latest_device", @@ -899,12 +925,11 @@ def api_devices_latest(payload=None): return jsonify([latest]) -@app.route('/mcp/sse/devices/favorite', methods=['GET']) @app.route('/devices/favorite', methods=['GET']) @validate_request( operation_id="get_favorite_devices", summary="Get Favorite Devices", - description="Get list of devices marked as favorites.", + description="Get list of devices marked as favorites. Use `update_device_column` with 'devFavorite' to add devices.", response_model=DeviceListResponse, tags=["devices"], auth_callable=is_authorized @@ -916,11 +941,10 @@ def api_devices_favorite(payload=None): favorite = device_handler.getFavorite() if not favorite: - return jsonify({"success": False, "message": "No devices found", "error": "No devices found"}), 404 + return jsonify({"success": False, "message": "No devices found", "error": "No favorite devices found. Mark devices using `update_device_column`."}), 404 return jsonify([favorite]) -@app.route('/mcp/sse/devices/network/topology', methods=['GET']) @app.route('/devices/network/topology', methods=['GET']) @validate_request( operation_id="get_network_topology", @@ -942,7 +966,6 @@ def api_devices_network_topology(payload=None): # -------------------------- # Net tools # -------------------------- -@app.route('/mcp/sse/nettools/wakeonlan', methods=['POST']) @app.route("/nettools/wakeonlan", methods=["POST"]) @validate_request( operation_id="wake_on_lan", @@ -979,7 +1002,6 @@ def api_wakeonlan(payload=None): return wakeonlan(mac) -@app.route('/mcp/sse/nettools/traceroute', methods=['POST']) @app.route("/nettools/traceroute", methods=["POST"]) @validate_request( operation_id="perform_traceroute", @@ -1036,11 +1058,20 @@ def api_nslookup(payload: NslookupRequest = None): @validate_request( operation_id="run_nmap_scan", summary="NMAP Scan", - description="Perform an NMAP scan on a target IP.", + description="Perform an NMAP scan on a target IP to identify open ports. This data is used by `get_open_ports`.", request_model=NmapScanRequest, response_model=NmapScanResponse, tags=["nettools"], - auth_callable=is_authorized + auth_callable=is_authorized, + links={ + "GetOpenPorts": { + "operationId": "get_open_ports", + "parameters": { + "target": "$response.body#/ip" + }, + "description": "View the open ports discovered by this scan." + } + } ) def api_nmap(payload: NmapScanRequest = None): """ @@ -1084,7 +1115,6 @@ def api_network_interfaces(payload=None): return network_interfaces() -@app.route('/mcp/sse/nettools/trigger-scan', methods=['POST']) @app.route("/nettools/trigger-scan", methods=["GET", "POST"]) @validate_request( operation_id="trigger_network_scan", @@ -1139,7 +1169,6 @@ def api_trigger_scan(payload=None): # MCP Server # -------------------------- @app.route('/openapi.json', methods=['GET']) -@app.route('/mcp/sse/openapi.json', methods=['GET']) def serve_openapi_spec(): # Allow unauthenticated access to the spec itself so Swagger UI can load. # The actual API endpoints remain protected. @@ -1356,7 +1385,7 @@ def api_add_to_execution_queue(payload=None): path_params=[{ "name": "mac", "description": "Device MAC address", - "schema": {"type": "string"} + "schema": {"type": "string", "pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"} }], request_model=CreateEventRequest, response_model=BaseResponse, @@ -1498,7 +1527,6 @@ def api_get_events_totals(payload=None): return jsonify(totals) -@app.route('/mcp/sse/events/recent', methods=['GET', 'POST']) @app.route('/events/recent', methods=['GET', 'POST']) @validate_request( operation_id="get_recent_events", @@ -1518,7 +1546,6 @@ def api_events_default_24h(payload=None): return api_events_recent(hours) -@app.route('/mcp/sse/events/last', methods=['GET', 'POST']) @app.route('/events/last', methods=['GET', 'POST']) @validate_request( operation_id="get_last_events", @@ -1707,7 +1734,7 @@ def api_get_session_events(payload=None): auth_callable=is_authorized ) def metrics(payload=None): - # Return Prometheus metrics as plain text + # Return Prometheus metrics as plain text (not JSON) return Response(get_metric_stats(), mimetype="text/plain") diff --git a/server/api_server/mcp_endpoint.py b/server/api_server/mcp_endpoint.py index 1aa3ba49..db65ebe3 100644 --- a/server/api_server/mcp_endpoint.py +++ b/server/api_server/mcp_endpoint.py @@ -749,7 +749,8 @@ def _execute_tool(route: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any] "type": "text", "text": json.dumps(json_content, indent=2) }) - except json.JSONDecodeError: + except (json.JSONDecodeError, ValueError): + # Fallback for endpoints that return plain text instead of JSON (e.g., /metrics) content.append({ "type": "text", "text": api_response.text diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index 96f862de..b711e8ea 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -39,8 +39,7 @@ ALLOWED_DEVICE_COLUMNS = Literal[ ] ALLOWED_NMAP_MODES = Literal[ - "quick", "intense", "ping", "comprehensive", "fast", "normal", "detail", "skipdiscovery", - "-sS", "-sT", "-sU", "-sV", "-O" + "fast", "normal", "detail", "skipdiscovery" ] NOTIFICATION_LEVELS = Literal["info", "warning", "error", "alert", "interrupt"] @@ -301,6 +300,24 @@ class DeviceTotalsResponse(RootModel): root: List[int] = Field(default_factory=list, description="List of counts: [all, online, favorites, new, offline, archived]") +class DeviceTotalsNamedResponse(BaseResponse): + """Response with named device statistics.""" + totals: Dict[str, int] = Field( + ..., + description="Dictionary of counts", + json_schema_extra={ + "examples": [{ + "devices": 10, + "connected": 5, + "favorites": 2, + "new": 1, + "down": 0, + "archived": 2 + }] + } + ) + + class DeviceExportRequest(BaseModel): """Request for exporting devices.""" format: Literal["csv", "json"] = Field( @@ -702,7 +719,7 @@ class SessionInfo(BaseModel): class CreateSessionRequest(BaseModel): """Request to create a session.""" - mac: str = Field(..., description="Device MAC") + mac: str = Field(..., description="Device MAC", pattern=MAC_PATTERN) ip: str = Field(..., description="Device IP") start_time: str = Field(..., description="Start time") end_time: Optional[str] = Field(None, description="End time") diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index 55362bbf..c18c0195 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -54,7 +54,7 @@ def test_trigger_scan_ARPSCAN(mock_queue_class, client, api_token): mock_queue_class.return_value = mock_queue payload = {"type": "ARPSCAN"} - response = client.post("/mcp/sse/nettools/trigger-scan", json=payload, headers=auth_headers(api_token)) + response = client.post("/nettools/trigger-scan", json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() @@ -71,7 +71,7 @@ def test_trigger_scan_invalid_type(mock_queue_class, client, api_token): mock_queue_class.return_value = mock_queue payload = {"type": "invalid_type", "target": "192.168.1.0/24"} - response = client.post("/mcp/sse/nettools/trigger-scan", json=payload, headers=auth_headers(api_token)) + response = client.post("/nettools/trigger-scan", json=payload, headers=auth_headers(api_token)) assert response.status_code == 400 data = response.get_json() @@ -267,7 +267,7 @@ def test_get_latest_device(mock_db_conn, client, api_token): def test_openapi_spec(client, api_token): """Test openapi_spec endpoint contains MCP tool paths.""" - response = client.get("/mcp/sse/openapi.json", headers=auth_headers(api_token)) + response = client.get("/openapi.json", headers=auth_headers(api_token)) assert response.status_code == 200 spec = response.get_json() @@ -297,7 +297,7 @@ def test_mcp_devices_export_csv(mock_db_conn, client, api_token): mock_conn.execute.return_value = mock_execute_result mock_db_conn.return_value = mock_conn - response = client.get("/mcp/sse/devices/export", headers=auth_headers(api_token)) + response = client.get("/devices/export", headers=auth_headers(api_token)) assert response.status_code == 200 # CSV response should have content-type header @@ -314,7 +314,7 @@ def test_mcp_devices_export_json(mock_export, client, api_token): "columns": ["devMac", "devName", "devLastIP"], } - response = client.get("/mcp/sse/devices/export?format=json", headers=auth_headers(api_token)) + response = client.get("/devices/export?format=json", headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() @@ -339,7 +339,7 @@ def test_mcp_devices_import_json(mock_db_conn, client, api_token): mock_import.return_value = {"success": True, "message": "Imported 2 devices"} payload = {"content": "bW9ja2VkIGNvbnRlbnQ="} # base64 encoded content - response = client.post("/mcp/sse/devices/import", json=payload, headers=auth_headers(api_token)) + response = client.post("/devices/import", json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() @@ -362,7 +362,7 @@ def test_mcp_devices_totals(mock_db_conn, client, api_token): mock_conn.cursor.return_value = mock_sql mock_db_conn.return_value = mock_conn - response = client.get("/mcp/sse/devices/totals", headers=auth_headers(api_token)) + response = client.get("/devices/totals", headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() @@ -380,7 +380,7 @@ def test_mcp_traceroute(mock_traceroute, client, api_token): mock_traceroute.return_value = ({"success": True, "output": "traceroute output"}, 200) payload = {"devLastIP": "8.8.8.8"} - response = client.post("/mcp/sse/nettools/traceroute", json=payload, headers=auth_headers(api_token)) + response = client.post("/nettools/traceroute", json=payload, headers=auth_headers(api_token)) assert response.status_code == 200 data = response.get_json() @@ -395,7 +395,7 @@ def test_mcp_traceroute_missing_ip(mock_traceroute, client, api_token): mock_traceroute.return_value = ({"success": False, "error": "Invalid IP: None"}, 400) payload = {} # Missing devLastIP - response = client.post("/mcp/sse/nettools/traceroute", json=payload, headers=auth_headers(api_token)) + response = client.post("/nettools/traceroute", json=payload, headers=auth_headers(api_token)) assert response.status_code == 422 data = response.get_json()