diff --git a/.env.example b/.env.example index 06a5c0c75..ed2a311c8 100644 --- a/.env.example +++ b/.env.example @@ -131,6 +131,12 @@ MCPGATEWAY_ADMIN_API_ENABLED=true # Enable bulk import endpoint for tools (true/false) MCPGATEWAY_BULK_IMPORT_ENABLED=true +# Maximum number of tools allowed per bulk import request +MCPGATEWAY_BULK_IMPORT_MAX_TOOLS=200 + +# Rate limiting for bulk import endpoint (requests per minute) +MCPGATEWAY_BULK_IMPORT_RATE_LIMIT=10 + ##################################### # Header Passthrough Configuration ##################################### diff --git a/CLAUDE.md b/CLAUDE.md index eabefb8e6..99d92ef6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,11 @@ AUTH_REQUIRED=true MCPGATEWAY_UI_ENABLED=true MCPGATEWAY_ADMIN_API_ENABLED=true +# Bulk Import (Admin UI feature) +MCPGATEWAY_BULK_IMPORT_ENABLED=true # Enable/disable bulk import endpoint +MCPGATEWAY_BULK_IMPORT_MAX_TOOLS=200 # Maximum tools per import batch +MCPGATEWAY_BULK_IMPORT_RATE_LIMIT=10 # Requests per minute limit + # Federation MCPGATEWAY_ENABLE_MDNS_DISCOVERY=true MCPGATEWAY_ENABLE_FEDERATION=true diff --git a/docs/docs/manage/bulk-import.md b/docs/docs/manage/bulk-import.md index 17117d3be..6b4af7e74 100644 --- a/docs/docs/manage/bulk-import.md +++ b/docs/docs/manage/bulk-import.md @@ -2,34 +2,89 @@ The MCP Gateway provides a bulk import endpoint for efficiently loading multiple tools in a single request, perfect for migrations, environment setup, and team onboarding. -!!! info "Feature Flag Required" - This feature is controlled by the `MCPGATEWAY_BULK_IMPORT_ENABLED` environment variable. - Default: `true` (enabled). Set to `false` to disable this endpoint. +!!! info "Configuration Options" + This feature is controlled by several environment variables: + + - `MCPGATEWAY_BULK_IMPORT_ENABLED=true` - Enable/disable the endpoint (default: true) + - `MCPGATEWAY_BULK_IMPORT_MAX_TOOLS=200` - Maximum tools per batch (default: 200) + - `MCPGATEWAY_BULK_IMPORT_RATE_LIMIT=10` - Requests per minute limit (default: 10) --- ## 🚀 Overview -The `/admin/tools/import` endpoint allows you to register multiple tools at once, providing: +The bulk import feature allows you to register multiple tools at once through both the Admin UI and API, providing: - **Per-item validation** - One invalid tool won't fail the entire batch - **Detailed reporting** - Know exactly which tools succeeded or failed - **Rate limiting** - Protected against abuse (10 requests/minute) - **Batch size limits** - Maximum 200 tools per request -- **Multiple input formats** - JSON payload or form data +- **Multiple input formats** - JSON payload, form data, or file upload +- **User-friendly UI** - Modal dialog with drag-and-drop file support + +--- + +## 🎨 Admin UI Usage + +### Accessing the Bulk Import Modal + +1. **Navigate to Admin UI** - Open your gateway's admin interface at `http://localhost:4444/admin` +2. **Go to Tools Tab** - Click on the "Tools" tab in the main navigation +3. **Open Bulk Import** - Click the "+ Bulk Import Tools" button next to "Add New Tool" + +### Using the Modal + +The bulk import modal provides two ways to input tool data: + +#### Option 1: JSON Textarea +1. **Paste JSON directly** into the text area +2. **Validate format** - The modal will check JSON syntax before submission +3. **Click Import Tools** to process + +#### Option 2: File Upload +1. **Prepare a JSON file** with your tools array +2. **Click "Choose File"** and select your `.json` file +3. **Click Import Tools** to process + +### UI Features + +- **Real-time validation** - JSON syntax checking before submission +- **Loading indicators** - Progress spinner during import +- **Detailed results** - Success/failure counts with error details +- **Auto-refresh** - Page reloads automatically after successful import +- **Modal controls** - Close with button, backdrop click, or ESC key --- ## 📡 API Endpoint -### Request +### Request Methods +#### Method 1: JSON Body ```http POST /admin/tools/import Authorization: Bearer Content-Type: application/json ``` +#### Method 2: Form Data (JSON String) +```http +POST /admin/tools/import +Authorization: Bearer +Content-Type: multipart/form-data + +Form field: tools_json= +``` + +#### Method 3: File Upload +```http +POST /admin/tools/import +Authorization: Bearer +Content-Type: multipart/form-data + +Form field: tools_file= +``` + ### Payload Structure ```json diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index e36fdb7b7..a77c0860b 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -1568,6 +1568,7 @@ async def admin_ui( "root_path": root_path, "max_name_length": max_name_length, "gateway_tool_name_separator": settings.gateway_tool_name_separator, + "bulk_import_max_tools": settings.mcpgateway_bulk_import_max_tools, }, ) @@ -4375,7 +4376,7 @@ async def admin_list_tags( @admin_router.post("/tools/import/") @admin_router.post("/tools/import") -@rate_limit(requests_per_minute=10) +@rate_limit(requests_per_minute=settings.mcpgateway_bulk_import_rate_limit) async def admin_import_tools( request: Request, db: Session = Depends(get_db), @@ -4418,19 +4419,33 @@ async def admin_import_tools( except Exception as ex: LOGGER.exception("Invalid form body") return JSONResponse({"success": False, "message": f"Invalid form data: {ex}"}, status_code=422) - raw = form.get("tools_json") or form.get("json") or form.get("payload") - if not raw: - return JSONResponse({"success": False, "message": "Missing tools_json/json/payload form field."}, status_code=422) - try: - payload = json.loads(raw) - except Exception as ex: - LOGGER.exception("Invalid JSON in form field") - return JSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422) + # Check for file upload first + if "tools_file" in form: + file = form["tools_file"] + if hasattr(file, "file"): + content = await file.read() + try: + payload = json.loads(content.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as ex: + LOGGER.exception("Invalid JSON file") + return JSONResponse({"success": False, "message": f"Invalid JSON file: {ex}"}, status_code=422) + else: + return JSONResponse({"success": False, "message": "Invalid file upload"}, status_code=422) + else: + # Check for JSON in form fields + raw = form.get("tools") or form.get("tools_json") or form.get("json") or form.get("payload") + if not raw: + return JSONResponse({"success": False, "message": "Missing tools/tools_json/json/payload form field."}, status_code=422) + try: + payload = json.loads(raw) + except Exception as ex: + LOGGER.exception("Invalid JSON in form field") + return JSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422) if not isinstance(payload, list): return JSONResponse({"success": False, "message": "Payload must be a JSON array of tools."}, status_code=422) - max_batch = 200 + max_batch = settings.mcpgateway_bulk_import_max_tools if len(payload) > max_batch: return JSONResponse({"success": False, "message": f"Too many tools ({len(payload)}). Max {max_batch}."}, status_code=413) @@ -4463,15 +4478,33 @@ async def admin_import_tools( LOGGER.exception("Unexpected error importing tool %r at index %d", name, i) errors.append({"index": i, "name": name, "error": {"message": str(ex)}}) - return JSONResponse( - { - "success": len(errors) == 0, - "created_count": len(created), - "failed_count": len(errors), - "created": created, - "errors": errors, + # Format response to match both frontend and test expectations + response_data = { + "success": len(errors) == 0, + # New format for frontend + "imported": len(created), + "failed": len(errors), + "total": len(payload), + # Original format for tests + "created_count": len(created), + "failed_count": len(errors), + "created": created, + "errors": errors, + # Detailed format for frontend + "details": { + "success": [item["name"] for item in created if item.get("name")], + "failed": [{"name": item["name"], "error": item["error"].get("message", str(item["error"]))} for item in errors], }, - status_code=200, + } + + if len(errors) == 0: + response_data["message"] = f"Successfully imported all {len(created)} tools" + else: + response_data["message"] = f"Imported {len(created)} of {len(payload)} tools. {len(errors)} failed." + + return JSONResponse( + response_data, + status_code=200, # Always return 200, success field indicates if all succeeded ) except HTTPException: diff --git a/mcpgateway/config.py b/mcpgateway/config.py index eb7920629..0b3523b3f 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -150,6 +150,8 @@ class Settings(BaseSettings): mcpgateway_ui_enabled: bool = False mcpgateway_admin_api_enabled: bool = False mcpgateway_bulk_import_enabled: bool = True + mcpgateway_bulk_import_max_tools: int = 200 + mcpgateway_bulk_import_rate_limit: int = 10 # Security skip_ssl_verify: bool = False diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index f6dfa8183..6667d3f62 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -5939,6 +5939,16 @@ document.addEventListener("DOMContentLoaded", () => { // 4. Handle initial tab/state initializeTabState(); + // 5. Set up form validation + setupFormValidation(); + + // 6. Setup bulk import modal + try { + setupBulkImportModal(); + } catch (error) { + console.error("Error setting up bulk import modal:", error); + } + // // ✅ 4.1 Set up tab button click handlers // document.querySelectorAll('.tab-button').forEach(button => { // button.addEventListener('click', () => { @@ -5952,9 +5962,6 @@ document.addEventListener("DOMContentLoaded", () => { // }); // }); - // 5. Set up form validation - setupFormValidation(); - // Mark as initialized AppState.isInitialized = true; @@ -6905,3 +6912,224 @@ window.updateAuthHeadersJSON = updateAuthHeadersJSON; window.loadAuthHeaders = loadAuthHeaders; console.log("🛡️ ContextForge MCP Gateway admin.js initialized"); + +// =================================================================== +// BULK IMPORT TOOLS — MODAL WIRING +// =================================================================== + +function setupBulkImportModal() { + const openBtn = safeGetElement("open-bulk-import", true); + const modalId = "bulk-import-modal"; + const modal = safeGetElement(modalId, true); + + if (!openBtn || !modal) { + console.warn( + "Bulk Import modal wiring skipped (missing button or modal).", + ); + return; + } + + // avoid double-binding if admin.js gets evaluated more than once + if (openBtn.dataset.wired === "1") { + return; + } + openBtn.dataset.wired = "1"; + + const closeBtn = safeGetElement("close-bulk-import", true); + const backdrop = safeGetElement("bulk-import-backdrop", true); + const resultEl = safeGetElement("import-result", true); + + const focusTarget = + modal?.querySelector("#tools_json") || + modal?.querySelector("#tools_file") || + modal?.querySelector("[data-autofocus]"); + + // helpers + const open = (e) => { + if (e) { + e.preventDefault(); + } + // clear previous results each time we open + if (resultEl) { + resultEl.innerHTML = ""; + } + openModal(modalId); + // prevent background scroll + document.documentElement.classList.add("overflow-hidden"); + document.body.classList.add("overflow-hidden"); + if (focusTarget) { + setTimeout(() => focusTarget.focus(), 0); + } + return false; + }; + + const close = () => { + // also clear results on close to keep things tidy + closeModal(modalId, "import-result"); + document.documentElement.classList.remove("overflow-hidden"); + document.body.classList.remove("overflow-hidden"); + }; + + // wire events + openBtn.addEventListener("click", open); + + if (closeBtn) { + closeBtn.addEventListener("click", (e) => { + e.preventDefault(); + close(); + }); + } + + // click on backdrop only (not the dialog content) closes the modal + if (backdrop) { + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) { + close(); + } + }); + } + + // ESC to close + modal.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + e.stopPropagation(); + close(); + } + }); + + // FORM SUBMISSION → handle bulk import + const form = safeGetElement("bulk-import-form", true); + if (form) { + form.addEventListener("submit", async (e) => { + e.preventDefault(); + e.stopPropagation(); + const resultEl = safeGetElement("import-result", true); + const indicator = safeGetElement("bulk-import-indicator", true); + + try { + const formData = new FormData(); + + // Get JSON from textarea or file + const jsonTextarea = form?.querySelector('[name="tools_json"]'); + const fileInput = form?.querySelector('[name="tools_file"]'); + + let hasData = false; + + // Check for file upload first (takes precedence) + if (fileInput && fileInput.files.length > 0) { + formData.append("tools_file", fileInput.files[0]); + hasData = true; + } else if (jsonTextarea && jsonTextarea.value.trim()) { + // Validate JSON before sending + try { + const toolsData = JSON.parse(jsonTextarea.value); + if (!Array.isArray(toolsData)) { + throw new Error("JSON must be an array of tools"); + } + formData.append("tools", jsonTextarea.value); + hasData = true; + } catch (err) { + if (resultEl) { + resultEl.innerHTML = ` +
+

Invalid JSON

+

${escapeHtml(err.message)}

+
+ `; + } + return; + } + } + + if (!hasData) { + if (resultEl) { + resultEl.innerHTML = ` +
+

Please provide JSON data or upload a file

+
+ `; + } + return; + } + + // Show loading state + if (indicator) { + indicator.style.display = "flex"; + } + + // Submit to backend + const response = await fetchWithTimeout( + `${window.ROOT_PATH}/admin/tools/import`, + { + method: "POST", + body: formData, + }, + ); + + const result = await response.json(); + + // Display results + if (resultEl) { + if (result.success) { + resultEl.innerHTML = ` +
+

Import Successful

+

${escapeHtml(result.message)}

+
+ `; + + // Close modal and refresh page after delay + setTimeout(() => { + closeModal("bulk-import-modal"); + window.location.reload(); + }, 2000); + } else if (result.imported > 0) { + // Partial success + let detailsHtml = ""; + if (result.details && result.details.failed) { + detailsHtml = + '
    '; + result.details.failed.forEach((item) => { + detailsHtml += `
  • ${escapeHtml(item.name)}: ${escapeHtml(item.error)}
  • `; + }); + detailsHtml += "
"; + } + + resultEl.innerHTML = ` +
+

Partial Import

+

${escapeHtml(result.message)}

+ ${detailsHtml} +
+ `; + } else { + // Complete failure + resultEl.innerHTML = ` +
+

Import Failed

+

${escapeHtml(result.message)}

+
+ `; + } + } + } catch (error) { + console.error("Bulk import error:", error); + if (resultEl) { + resultEl.innerHTML = ` +
+

Import Error

+

${escapeHtml(error.message || "An unexpected error occurred")}

+
+ `; + } + } finally { + // Hide loading state + if (indicator) { + indicator.style.display = "none"; + } + } + + return false; + }); + } +} diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index d4955113b..541ed2f90 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -47,6 +47,11 @@ rel="stylesheet" /> + @@ -1078,11 +1083,20 @@

-
-

- Add New Tool -

+
+

Add New Tool

+ + +
+ +
@@ -1312,6 +1326,58 @@

+ +
+