From 6dd44df6384c33bcafc807dd0f0d6825546b9f72 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Fri, 20 Mar 2026 21:59:07 +0000 Subject: [PATCH 1/2] feat(ui): add refresh tools button and overflow menu to gateways table Closes #3765 Closes #3519 Adds a Fetch/Refresh Tools action to the gateways table for all gateway auth types, not just OAuth. The label reads "Fetch Tools" on first use (no tools registered yet) and switches to "Refresh Tools" once tools exist, using the existing POST /gateways/{id}/tools/refresh endpoint. On success, a toast shows the delta counts (added / updated / removed) and the table reloads via HTMX. A matching "Refresh from MCPs" button is added to the virtual server edit form so users can pull the latest tools from selected gateways without leaving the modal. To support the contextual label, tool_count is added to GatewayRead (serialised as toolCount) and DbGateway.tools is eagerly loaded in the gateways partial query to avoid N+1 queries. As a piggyback improvement, the stacked action buttons in the gateways table are replaced with an Alpine.js overflow menu (...), housing all actions including the new refresh button. Signed-off-by: Gabriel Costa --- mcpgateway/admin.py | 2 +- mcpgateway/schemas.py | 3 + mcpgateway/services/gateway_service.py | 4 + mcpgateway/static/admin.js | 117 +++++++++++++++++++ mcpgateway/templates/admin.html | 20 +++- mcpgateway/templates/gateways_partial.html | 126 +++++++++++++++------ 6 files changed, 229 insertions(+), 43 deletions(-) diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 2a30ff566..d88b19c64 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -9191,7 +9191,7 @@ async def admin_gateways_partial_html( team_ids = await _get_user_team_ids(user, db) # Build base query - query = select(DbGateway).options(joinedload(DbGateway.email_team)) + query = select(DbGateway).options(joinedload(DbGateway.email_team), selectinload(DbGateway.tools)) if not include_inactive: query = query.where(DbGateway.enabled.is_(True)) diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 7ed788b18..600d81e2d 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -3402,6 +3402,9 @@ class GatewayRead(BaseModelWithConfigDict): _normalize_visibility = field_validator("visibility", mode="before")(classmethod(lambda cls, v: _coerce_visibility(v))) + # Tool count (populated from the tools relationship; 0 when not loaded) + tool_count: int = Field(default=0, description="Number of tools registered for this gateway") + @model_validator(mode="before") @classmethod def _mask_query_param_auth(cls, data: Any) -> Any: diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index 832f3cf97..2866838d3 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -4176,6 +4176,10 @@ def convert_gateway_to_read(self, gateway: DbGateway) -> GatewayRead: gateway_dict["version"] = getattr(gateway, "version", None) gateway_dict["team"] = getattr(gateway, "team", None) + # Populate tool count from the eagerly-loaded tools relationship when available + tools_rel = gateway.__dict__.get("tools") + gateway_dict["tool_count"] = len(tools_rel) if tools_rel is not None else 0 + return GatewayRead.model_validate(gateway_dict).masked() def _create_db_tool( diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index d503e682d..66d6e8946 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -20829,6 +20829,123 @@ async function fetchToolsForGateway(gatewayId, gatewayName) { // Expose fetch tools function to global scope window.fetchToolsForGateway = fetchToolsForGateway; +/** + * Refresh (or first-time fetch) tools for a gateway via the unified refresh endpoint. + * Works for all auth types. Shows a toast with delta counts on success. + * + * @param {string} gatewayId - ID of the gateway + * @param {string} gatewayName - Display name for toast messages + * @param {HTMLElement|null} buttonEl - Optional button element for loading-state feedback + */ +async function refreshGatewayTools(gatewayId, gatewayName, buttonEl) { + const origText = buttonEl ? buttonEl.textContent : ""; + if (buttonEl) { + buttonEl.disabled = true; + buttonEl.textContent = "⏳ Refreshing..."; + } + + try { + const response = await fetch(`${window.ROOT_PATH}/gateways/${gatewayId}/tools/refresh`, { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || data.message || "Refresh failed"); + } + + // Check if the refresh operation itself succeeded (even if HTTP 200) + if (data.success === false || data.error) { + throw new Error(data.error || "Refresh failed on the server"); + } + + showSuccessMessage( + `${gatewayName}: ${data.toolsAdded ?? 0} added, ${data.toolsUpdated ?? 0} updated, ${data.toolsRemoved ?? 0} removed`, + ); + + // Reload the gateways partial table via HTMX to reflect updated tool counts / button labels + htmx.ajax("GET", `${window.ROOT_PATH}/admin/gateways/partial`, { + target: "#gateways-table", + swap: "outerHTML", + }); + } catch (err) { + console.error("refreshGatewayTools error:", err); + showErrorMessage(`Failed to refresh tools for ${gatewayName}: ${err.message}`); + if (buttonEl) { + buttonEl.disabled = false; + buttonEl.textContent = origText; + } + } +} + +window.refreshGatewayTools = refreshGatewayTools; + +/** + * Refresh tools for all currently selected gateways in the virtual server edit form. + * After completion, triggers an HTMX reload of the tools selector list. + * + * @param {HTMLElement} buttonEl - The button element clicked + */ +async function refreshToolsForSelectedGateways(buttonEl) { + const gwIds = + typeof getSelectedGatewayIds === "function" ? getSelectedGatewayIds() : []; + if (!gwIds.length) { + showErrorMessage("Select at least one MCP gateway first."); + return; + } + + const origText = buttonEl.textContent; + buttonEl.disabled = true; + buttonEl.textContent = "⏳ Refreshing..."; + + let added = 0, + updated = 0, + removed = 0, + failed = 0; + + await Promise.allSettled( + gwIds.map(async (gid) => { + try { + const res = await fetch(`${window.ROOT_PATH}/gateways/${gid}/tools/refresh`, { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }); + const data = await res.json(); + if (res.ok && data.success !== false) { + added += data.toolsAdded ?? 0; + updated += data.toolsUpdated ?? 0; + removed += data.toolsRemoved ?? 0; + } else { + failed++; + } + } catch (_) { + failed++; + } + }), + ); + + buttonEl.disabled = false; + buttonEl.textContent = origText; + + const msg = `${added} added, ${updated} updated, ${removed} removed`; + if (failed > 0) { + showErrorMessage(`Refresh completed with ${failed} error(s). ${msg}`); + } else { + showSuccessMessage(`Tools refreshed: ${msg}`); + } + + // Reload the tools selector via HTMX to pick up newly discovered tools + const container = document.getElementById("edit-server-tools"); + if (container) { + htmx.trigger(container, "load"); + } +} + +window.refreshToolsForSelectedGateways = refreshToolsForSelectedGateways; + console.log("πŸ›‘οΈ ContextForge AI Gateway admin.js initialized"); // =================================================================== diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 9a81825e2..9fdc2de6d 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -11134,12 +11134,20 @@

- +
+ + +
-
- +
+ - {% if gateway.authType == 'oauth' and can_modify %} - - - πŸ” Authorize - - - - {% endif %} + +
{{ (pagination.page - 1) * pagination.per_page + loop.index }} From 14c44f785d529538fe347f9d1416e53c0163988d Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Fri, 20 Mar 2026 23:51:20 +0000 Subject: [PATCH 2/2] Adjust playwright tests to overflow menu Signed-off-by: Gabriel Costa --- CHANGELOG.md | 18 ++++ mcpgateway/admin.py | 30 +++---- mcpgateway/main.py | 4 +- mcpgateway/services/gateway_service.py | 8 +- mcpgateway/static/admin.js | 85 ++++++++++++++----- mcpgateway/templates/admin.html | 22 +++-- mcpgateway/templates/gateways_partial.html | 36 ++++++-- tests/playwright/pages/gateways_page.py | 38 ++++++++- .../test_iframe_embedding_security.py | 80 +++++++++++++---- tests/playwright/test_admin_url_context.py | 76 +++++++++++++---- tests/playwright/test_gateways.py | 26 ++++-- 11 files changed, 320 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4893ee44c..f401e9b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,24 @@ > **Migration**: Switch to PostgreSQL for production deployments. Update `DATABASE_URL` to a `postgresql+psycopg://` connection string. SQLite (`sqlite:///./mcp.db`) remains available for local development and testing. +### Added + +#### **Refresh Tools Button for All Gateway Types** ([#3765](https://github.com/IBM/mcp-context-forge/issues/3765)) + +Users can now pull the latest tools from any registered MCP without re-registering the gateway. + +* A **Fetch Tools** / **Refresh Tools** action is available for all gateway auth types (previously limited to OAuth only) via the new overflow menu in the gateways table +* The label reads **Fetch Tools** on first use (no tools registered yet) and automatically switches to **Refresh Tools** once tools exist, driven by the new `toolCount` field on `GatewayRead` +* On success, a toast notification shows the delta: tools added / updated / removed +* The table reloads via HTMX to reflect updated tool counts and button labels without a full page refresh +* A **Refresh from MCPs** button is also available in the virtual server edit form, allowing users to pull the latest tools from all currently selected gateways and reload the tools selector without leaving the modal +* Backed by the existing `POST /gateways/{id}/tools/refresh` API endpoint + +#### **Overflow Menu for Gateways Table** ([#3519](https://github.com/IBM/mcp-context-forge/issues/3519)) + +* Replaced the stacked action buttons in the gateways table with a single overflow menu (three-dot button), reducing visual clutter and aligning with the Carbon Design System overflow menu pattern +* All existing actions (Test, OAuth Authorize, Fetch/Refresh Tools, View, Edit, Activate/Deactivate, Delete) remain fully accessible inside the menu + ## [1.0.0-RC2] - 2026-03-09 - Hardening, Admin UI Polish, Plugin Framework & Quality ### Overview diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index d88b19c64..cf8a603cf 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -1379,19 +1379,6 @@ def _resolve_root_path(request: Request) -> str: return root_path.rstrip("/") -def _admin_cookie_path(request: Request) -> str: - """Build admin cookie path honoring ASGI root_path. - - Args: - request: Incoming request used to read ASGI ``root_path``. - - Returns: - Admin cookie path scoped under the deployed app root. - """ - root_path = _resolve_root_path(request) - return f"{root_path}/admin" if root_path else "/admin" - - def _normalize_origin_parts(scheme: str, netloc: str) -> tuple[str, str, int]: """Normalize origin components for exact same-origin comparisons. @@ -1495,11 +1482,12 @@ def _set_admin_csrf_cookie(request: Request, response: Response) -> str: use_secure = (settings.environment == "production") or settings.secure_cookies max_age = max(300, int(getattr(settings, "token_expiry", 60)) * 60) + cookie_path = _resolve_root_path(request) or "/" response.set_cookie( key=ADMIN_CSRF_COOKIE_NAME, value=csrf_token, max_age=max_age, - path=_admin_cookie_path(request), + path=cookie_path, httponly=False, secure=use_secure, samesite="strict", @@ -1517,7 +1505,7 @@ def _clear_admin_csrf_cookie(request: Request, response: Response) -> None: use_secure = (settings.environment == "production") or settings.secure_cookies response.delete_cookie( key=ADMIN_CSRF_COOKIE_NAME, - path=_admin_cookie_path(request), + path=_resolve_root_path(request) or "/", secure=use_secure, httponly=False, samesite="strict", @@ -7724,9 +7712,7 @@ async def admin_create_user( ) # If the user was created with the default password, optionally force password change - if ( - settings.password_change_enforcement_enabled and getattr(settings, "require_password_change_for_default_password", True) and password == settings.default_user_password.get_secret_value() - ): # nosec B105 + if settings.password_change_enforcement_enabled and getattr(settings, "require_password_change_for_default_password", True) and password == settings.default_user_password.get_secret_value(): # nosec B105 new_user.password_change_required = True db.commit() @@ -7856,12 +7842,16 @@ async def admin_get_user_edit(
- {"" if is_editing_self else f'''
+ { + "" + if is_editing_self + else f'''
-
'''} +
''' + }