diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 603985159..18c53a7cf 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -1888,6 +1888,35 @@ async def admin_list_gateways( return [gateway.model_dump(by_alias=True) for gateway in gateways] +@admin_router.get("/gateways/ids") +async def admin_list_gateway_ids( + include_inactive: bool = False, + db: Session = Depends(get_db), + user=Depends(get_current_user_with_permissions), +) -> Dict[str, Any]: + """ + Return a JSON object containing a list of all gateway IDs. + + This endpoint is used by the admin UI to support the "Select All" action + for gateways. It returns a simple JSON payload with a single key + `gateway_ids` containing an array of gateway identifiers. + + Args: + include_inactive (bool): Whether to include inactive gateways in the results. + db (Session): Database session dependency. + user: Authenticated user dependency. + + Returns: + Dict[str, Any]: JSON object containing the `gateway_ids` list and metadata. + """ + user_email = get_user_email(user) + LOGGER.debug(f"User {user_email} requested gateway ids list") + gateways = await gateway_service.list_gateways_for_user(db, user_email, include_inactive=include_inactive) + ids = [str(g.id) for g in gateways] + LOGGER.info(f"Gateway IDs retrieved: {ids}") + return {"gateway_ids": ids} + + @admin_router.post("/gateways/{gateway_id}/toggle") async def admin_toggle_gateway( gateway_id: str, @@ -2303,6 +2332,20 @@ def _matches_selected_team(item, tid: str) -> bool: """ if not tid: return True + # If an item is explicitly public, it should be visible to any team + try: + vis = getattr(item, "visibility", None) + if vis is None and isinstance(item, dict): + vis = item.get("visibility") + if isinstance(vis, str) and vis.lower() == "public": + return True + except Exception as exc: # pragma: no cover - defensive logging for unexpected types + LOGGER.debug( + "Error checking visibility on item (type=%s): %s", + type(item), + exc, + exc_info=True, + ) # item may be a pydantic model or dict-like # check common fields for team membership candidates = [] @@ -4920,6 +4963,7 @@ async def admin_tools_partial_html( per_page: int = Query(50, ge=1, le=500, description="Items per page"), include_inactive: bool = False, render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"), + gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions), ): @@ -4934,6 +4978,7 @@ async def admin_tools_partial_html( page (int): Page number (1-indexed). Default: 1. per_page (int): Items per page (1-500). Default: 50. include_inactive (bool): Whether to include inactive tools in the results. + gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. render (str): Render mode - 'controls' returns only pagination controls. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -4941,7 +4986,7 @@ async def admin_tools_partial_html( Returns: HTMLResponse with tools table rows and pagination controls. """ - LOGGER.debug(f"User {get_user_email(user)} requested tools HTML partial (page={page}, per_page={per_page}, render={render})") + LOGGER.debug(f"User {get_user_email(user)} requested tools HTML partial (page={page}, per_page={per_page}, render={render}, gateway_id={gateway_id})") # Get paginated data from the JSON endpoint logic user_email = get_user_email(user) @@ -4958,6 +5003,24 @@ async def admin_tools_partial_html( # Build query query = select(DbTool) + # Apply gateway filter if provided. Support special sentinel 'null' to + # request tools with NULL gateway_id (e.g., RestTool/no gateway). + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + # Treat literal 'null' (case-insensitive) as a request for NULL gateway_id + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None))) + LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL") + elif null_requested: + query = query.where(DbTool.gateway_id.is_(None)) + LOGGER.debug("Filtering tools by NULL gateway_id (RestTool)") + else: + query = query.where(DbTool.gateway_id.in_(non_null_ids)) + LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}") + # Apply active/inactive filter if not include_inactive: query = query.where(DbTool.enabled.is_(True)) @@ -4977,8 +5040,19 @@ async def admin_tools_partial_html( query = query.where(or_(*access_conditions)) - # Count total items + # Count total items - must include gateway filter for accurate count count_query = select(func.count()).select_from(DbTool).where(or_(*access_conditions)) # pylint: disable=not-callable + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + count_query = count_query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None))) + elif null_requested: + count_query = count_query.where(DbTool.gateway_id.is_(None)) + else: + count_query = count_query.where(DbTool.gateway_id.in_(non_null_ids)) if not include_inactive: count_query = count_query.where(DbTool.enabled.is_(True)) @@ -5051,6 +5125,7 @@ async def admin_tools_partial_html( "data": data, "pagination": pagination.model_dump(), "root_path": request.scope.get("root_path", ""), + "gateway_id": gateway_id, }, ) @@ -5071,6 +5146,7 @@ async def admin_tools_partial_html( @admin_router.get("/tools/ids", response_class=JSONResponse) async def admin_get_all_tool_ids( include_inactive: bool = False, + gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions), ): @@ -5081,6 +5157,7 @@ async def admin_get_all_tool_ids( Args: include_inactive (bool): Whether to include inactive tools in the results + gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local tools). db (Session): Database session dependency user: Current user making the request @@ -5099,6 +5176,23 @@ async def admin_get_all_tool_ids( if not include_inactive: query = query.where(DbTool.enabled.is_(True)) + # Apply optional gateway/server scoping (comma-separated ids). Accepts the + # literal value 'null' to indicate NULL gateway_id (local tools). + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None))) + LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL") + elif null_requested: + query = query.where(DbTool.gateway_id.is_(None)) + LOGGER.debug("Filtering tools by NULL gateway_id (local tools)") + else: + query = query.where(DbTool.gateway_id.in_(non_null_ids)) + LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}") + # Build access conditions access_conditions = [DbTool.owner_email == user_email, DbTool.visibility == "public"] if team_ids: @@ -5200,6 +5294,7 @@ async def admin_prompts_partial_html( per_page: int = Query(50, ge=1), include_inactive: bool = False, render: Optional[str] = Query(None), + gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions), ): @@ -5218,6 +5313,7 @@ async def admin_prompts_partial_html( per_page (int): Number of items per page (bounded by settings). include_inactive (bool): If True, include inactive prompts in results. render (Optional[str]): Render mode; one of None, "controls", "selector". + gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. db (Session): Database session (dependency-injected). user: Authenticated user object from dependency injection. @@ -5227,6 +5323,7 @@ async def admin_prompts_partial_html( items depending on ``render``. The response contains JSON-serializable encoded prompt data when templates expect it. """ + LOGGER.debug(f"User {get_user_email(user)} requested prompts HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, gateway_id={gateway_id})") # Normalize per_page within configured bounds per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) @@ -5239,6 +5336,23 @@ async def admin_prompts_partial_html( # Build base query query = select(DbPrompt) + + # Apply gateway filter if provided + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None))) + LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL") + elif null_requested: + query = query.where(DbPrompt.gateway_id.is_(None)) + LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)") + else: + query = query.where(DbPrompt.gateway_id.in_(non_null_ids)) + LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}") + if not include_inactive: query = query.where(DbPrompt.is_active.is_(True)) @@ -5250,8 +5364,19 @@ async def admin_prompts_partial_html( query = query.where(or_(*access_conditions)) - # Count total items + # Count total items - must include gateway filter for accurate count count_query = select(func.count()).select_from(DbPrompt).where(or_(*access_conditions)) # pylint: disable=not-callable + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + count_query = count_query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None))) + elif null_requested: + count_query = count_query.where(DbPrompt.gateway_id.is_(None)) + else: + count_query = count_query.where(DbPrompt.gateway_id.in_(non_null_ids)) if not include_inactive: count_query = count_query.where(DbPrompt.is_active.is_(True)) @@ -5318,6 +5443,7 @@ async def admin_prompts_partial_html( "data": data, "pagination": pagination.model_dump(), "root_path": request.scope.get("root_path", ""), + "gateway_id": gateway_id, }, ) @@ -5341,6 +5467,7 @@ async def admin_resources_partial_html( per_page: int = Query(50, ge=1, le=500, description="Items per page"), include_inactive: bool = False, render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"), + gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions), ): @@ -5358,6 +5485,7 @@ async def admin_resources_partial_html( render (Optional[str]): Render mode; when set to "controls" returns only pagination controls. Other supported value: "selector" for selector items used by infinite scroll selectors. + gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. db (Session): Database session (dependency-injected). user: Authenticated user object from dependency injection. @@ -5366,7 +5494,8 @@ async def admin_resources_partial_html( resources partial (rows + controls), pagination controls only, or selector items depending on the ``render`` parameter. """ - LOGGER.debug(f"User {get_user_email(user)} requested resources HTML partial (page={page}, per_page={per_page}, render={render})") + + LOGGER.debug(f"[RESOURCES FILTER DEBUG] User {get_user_email(user)} requested resources HTML partial (page={page}, per_page={per_page}, render={render}, gateway_id={gateway_id})") # Normalize per_page per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) @@ -5381,6 +5510,24 @@ async def admin_resources_partial_html( # Build base query query = select(DbResource) + # Apply gateway filter if provided + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None))) + LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL") + elif null_requested: + query = query.where(DbResource.gateway_id.is_(None)) + LOGGER.debug("[RESOURCES FILTER DEBUG] Filtering resources by NULL gateway_id (RestTool)") + else: + query = query.where(DbResource.gateway_id.in_(non_null_ids)) + LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs: {non_null_ids}") + else: + LOGGER.debug("[RESOURCES FILTER DEBUG] No gateway_id filter provided, showing all resources") + # Apply active/inactive filter if not include_inactive: query = query.where(DbResource.is_active.is_(True)) @@ -5393,8 +5540,19 @@ async def admin_resources_partial_html( query = query.where(or_(*access_conditions)) - # Count total items + # Count total items - must include gateway filter for accurate count count_query = select(func.count()).select_from(DbResource).where(or_(*access_conditions)) # pylint: disable=not-callable + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + count_query = count_query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None))) + elif null_requested: + count_query = count_query.where(DbResource.gateway_id.is_(None)) + else: + count_query = count_query.where(DbResource.gateway_id.in_(non_null_ids)) if not include_inactive: count_query = count_query.where(DbResource.is_active.is_(True)) @@ -5459,6 +5617,7 @@ async def admin_resources_partial_html( "data": data, "pagination": pagination.model_dump(), "root_path": request.scope.get("root_path", ""), + "gateway_id": gateway_id, }, ) @@ -5478,6 +5637,7 @@ async def admin_resources_partial_html( @admin_router.get("/prompts/ids", response_class=JSONResponse) async def admin_get_all_prompt_ids( include_inactive: bool = False, + gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions), ): @@ -5488,6 +5648,7 @@ async def admin_get_all_prompt_ids( Args: include_inactive (bool): When True include prompts that are inactive. + gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local prompts). db (Session): Database session (injected dependency). user: Authenticated user object from dependency injection. @@ -5502,6 +5663,23 @@ async def admin_get_all_prompt_ids( team_ids = [t.id for t in user_teams] query = select(DbPrompt.id) + + # Apply optional gateway/server scoping + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None))) + LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL") + elif null_requested: + query = query.where(DbPrompt.gateway_id.is_(None)) + LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)") + else: + query = query.where(DbPrompt.gateway_id.in_(non_null_ids)) + LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}") + if not include_inactive: query = query.where(DbPrompt.is_active.is_(True)) @@ -5517,6 +5695,7 @@ async def admin_get_all_prompt_ids( @admin_router.get("/resources/ids", response_class=JSONResponse) async def admin_get_all_resource_ids( include_inactive: bool = False, + gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions), ): @@ -5527,6 +5706,7 @@ async def admin_get_all_resource_ids( Args: include_inactive (bool): Whether to include inactive resources in the results. + gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local resources). db (Session): Database session dependency. user: Authenticated user object from dependency injection. @@ -5541,6 +5721,23 @@ async def admin_get_all_resource_ids( team_ids = [t.id for t in user_teams] query = select(DbResource.id) + + # Apply optional gateway/server scoping + if gateway_id: + gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] + if gateway_ids: + null_requested = any(gid.lower() == "null" for gid in gateway_ids) + non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] + if non_null_ids and null_requested: + query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None))) + LOGGER.debug(f"Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL") + elif null_requested: + query = query.where(DbResource.gateway_id.is_(None)) + LOGGER.debug("Filtering resources by NULL gateway_id (RestTool)") + else: + query = query.where(DbResource.gateway_id.in_(non_null_ids)) + LOGGER.debug(f"Filtering resources by gateway IDs: {non_null_ids}") + if not include_inactive: query = query.where(DbResource.is_active.is_(True)) diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index 1fae840d7..ba7b5be9d 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -1246,10 +1246,15 @@ async def list_gateways_for_user( access_conditions = [] # Filter by specific team + + # Team-owned gateways (team-scoped gateways) access_conditions.append(and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"]))) access_conditions.append(and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email)) + # Also include global public gateways (no team_id) so public gateways are visible regardless of selected team + access_conditions.append(DbGateway.visibility == "public") + query = query.where(or_(*access_conditions)) else: # Get user's accessible teams @@ -1411,7 +1416,8 @@ async def update_gateway( # FIX for Issue #1025: Determine if URL actually changed before we update it # We need this early because we update gateway.url below, and need to know # if it actually changed to decide whether to re-fetch tools - url_changed = gateway_update.url is not None and self.normalize_url(str(gateway_update.url)) != gateway.url + # tools/resoures/prompts are need to be re-fetched not only if URL changed , in case any update like authentication and visibility changed + # url_changed = gateway_update.url is not None and self.normalize_url(str(gateway_update.url)) != gateway.url # Update fields if provided if gateway_update.name is not None: @@ -1491,96 +1497,96 @@ async def update_gateway( gateway.auth_value = decoded_auth # Try to reinitialize connection if URL actually changed - if url_changed: - # Initialize empty lists in case initialization fails - tools_to_add = [] - resources_to_add = [] - prompts_to_add = [] - - try: - ca_certificate = getattr(gateway, "ca_certificate", None) - capabilities, tools, resources, prompts = await self._initialize_gateway( - gateway.url, gateway.auth_value, gateway.transport, gateway.auth_type, gateway.oauth_config, ca_certificate - ) - new_tool_names = [tool.name for tool in tools] - new_resource_uris = [resource.uri for resource in resources] - new_prompt_names = [prompt.name for prompt in prompts] - - if gateway_update.one_time_auth: - # For one-time auth, clear auth_type and auth_value after initialization - gateway.auth_type = "one_time_auth" - gateway.auth_value = None - gateway.oauth_config = None - - # Update tools using helper method - tools_to_add = self._update_or_create_tools(db, tools, gateway, "update") - - # Update resources using helper method - resources_to_add = self._update_or_create_resources(db, resources, gateway, "update") - - # Update prompts using helper method - prompts_to_add = self._update_or_create_prompts(db, prompts, gateway, "update") + # if url_changed: + # Initialize empty lists in case initialization fails + tools_to_add = [] + resources_to_add = [] + prompts_to_add = [] - # Log newly added items - items_added = len(tools_to_add) + len(resources_to_add) + len(prompts_to_add) - if items_added > 0: - if tools_to_add: - logger.info(f"Added {len(tools_to_add)} new tools during gateway update") - if resources_to_add: - logger.info(f"Added {len(resources_to_add)} new resources during gateway update") - if prompts_to_add: - logger.info(f"Added {len(prompts_to_add)} new prompts during gateway update") - logger.info(f"Total {items_added} new items added during gateway update") - - # Count items before cleanup for logging - - # Delete tools that are no longer available from the gateway - stale_tools = [tool for tool in gateway.tools if tool.original_name not in new_tool_names] - for tool in stale_tools: - db.delete(tool) - - # Delete resources that are no longer available from the gateway - stale_resources = [resource for resource in gateway.resources if resource.uri not in new_resource_uris] - for resource in stale_resources: - db.delete(resource) - - # Delete prompts that are no longer available from the gateway - stale_prompts = [prompt for prompt in gateway.prompts if prompt.name not in new_prompt_names] - for prompt in stale_prompts: - db.delete(prompt) + try: + ca_certificate = getattr(gateway, "ca_certificate", None) + capabilities, tools, resources, prompts = await self._initialize_gateway( + gateway.url, gateway.auth_value, gateway.transport, gateway.auth_type, gateway.oauth_config, ca_certificate + ) + new_tool_names = [tool.name for tool in tools] + new_resource_uris = [resource.uri for resource in resources] + new_prompt_names = [prompt.name for prompt in prompts] - gateway.capabilities = capabilities - gateway.tools = [tool for tool in gateway.tools if tool.original_name in new_tool_names] # keep only still-valid rows - gateway.resources = [resource for resource in gateway.resources if resource.uri in new_resource_uris] # keep only still-valid rows - gateway.prompts = [prompt for prompt in gateway.prompts if prompt.name in new_prompt_names] # keep only still-valid rows + if gateway_update.one_time_auth: + # For one-time auth, clear auth_type and auth_value after initialization + gateway.auth_type = "one_time_auth" + gateway.auth_value = None + gateway.oauth_config = None - # Log cleanup results - tools_removed = len(stale_tools) - resources_removed = len(stale_resources) - prompts_removed = len(stale_prompts) + # Update tools using helper method + tools_to_add = self._update_or_create_tools(db, tools, gateway, "update") - if tools_removed > 0: - logger.info(f"Removed {tools_removed} tools no longer available during gateway update") - if resources_removed > 0: - logger.info(f"Removed {resources_removed} resources no longer available during gateway update") - if prompts_removed > 0: - logger.info(f"Removed {prompts_removed} prompts no longer available during gateway update") + # Update resources using helper method + resources_to_add = self._update_or_create_resources(db, resources, gateway, "update") - gateway.last_seen = datetime.now(timezone.utc) + # Update prompts using helper method + prompts_to_add = self._update_or_create_prompts(db, prompts, gateway, "update") - # Add new items to database session + # Log newly added items + items_added = len(tools_to_add) + len(resources_to_add) + len(prompts_to_add) + if items_added > 0: if tools_to_add: - db.add_all(tools_to_add) + logger.info(f"Added {len(tools_to_add)} new tools during gateway update") if resources_to_add: - db.add_all(resources_to_add) + logger.info(f"Added {len(resources_to_add)} new resources during gateway update") if prompts_to_add: - db.add_all(prompts_to_add) - - # Update tracking with new URL - self._active_gateways.discard(gateway.url) - self._active_gateways.add(gateway.url) - except Exception as e: - logger.warning(f"Failed to initialize updated gateway: {e}") + logger.info(f"Added {len(prompts_to_add)} new prompts during gateway update") + logger.info(f"Total {items_added} new items added during gateway update") + + # Count items before cleanup for logging + + # Delete tools that are no longer available from the gateway + stale_tools = [tool for tool in gateway.tools if tool.original_name not in new_tool_names] + for tool in stale_tools: + db.delete(tool) + + # Delete resources that are no longer available from the gateway + stale_resources = [resource for resource in gateway.resources if resource.uri not in new_resource_uris] + for resource in stale_resources: + db.delete(resource) + + # Delete prompts that are no longer available from the gateway + stale_prompts = [prompt for prompt in gateway.prompts if prompt.name not in new_prompt_names] + for prompt in stale_prompts: + db.delete(prompt) + + gateway.capabilities = capabilities + gateway.tools = [tool for tool in gateway.tools if tool.original_name in new_tool_names] # keep only still-valid rows + gateway.resources = [resource for resource in gateway.resources if resource.uri in new_resource_uris] # keep only still-valid rows + gateway.prompts = [prompt for prompt in gateway.prompts if prompt.name in new_prompt_names] # keep only still-valid rows + + # Log cleanup results + tools_removed = len(stale_tools) + resources_removed = len(stale_resources) + prompts_removed = len(stale_prompts) + + if tools_removed > 0: + logger.info(f"Removed {tools_removed} tools no longer available during gateway update") + if resources_removed > 0: + logger.info(f"Removed {resources_removed} resources no longer available during gateway update") + if prompts_removed > 0: + logger.info(f"Removed {prompts_removed} prompts no longer available during gateway update") + + gateway.last_seen = datetime.now(timezone.utc) + + # Add new items to database session + if tools_to_add: + db.add_all(tools_to_add) + if resources_to_add: + db.add_all(resources_to_add) + if prompts_to_add: + db.add_all(prompts_to_add) + + # Update tracking with new URL + self._active_gateways.discard(gateway.url) + self._active_gateways.add(gateway.url) + except Exception as e: + logger.warning(f"Failed to initialize updated gateway: {e}") # Update tags if provided if gateway_update.tags is not None: @@ -2998,7 +3004,6 @@ def _update_or_create_tools(self, db: Session, tools: List[Any], gateway: DbGate try: # Check if tool already exists for this gateway existing_tool = db.execute(select(DbTool).where(DbTool.original_name == tool.name).where(DbTool.gateway_id == gateway.id)).scalar_one_or_none() - if existing_tool: # Update existing tool if there are changes fields_to_update = False @@ -3016,7 +3021,6 @@ def _update_or_create_tools(self, db: Session, tools: List[Any], gateway: DbGate if basic_fields_changed or schema_fields_changed or auth_fields_changed: fields_to_update = True - if fields_to_update: existing_tool.url = gateway.url existing_tool.description = tool.description diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index 18eafce25..f93c21df3 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -264,6 +264,7 @@ def _assemble_associated_items( resources: Optional[List[str]], prompts: Optional[List[str]], a2a_agents: Optional[List[str]] = None, + gateways: Optional[List[str]] = None, ) -> Dict[str, Any]: """ Assemble the associated items dictionary from the separate fields. @@ -273,37 +274,39 @@ def _assemble_associated_items( resources: List of resource IDs. prompts: List of prompt IDs. a2a_agents: List of A2A agent IDs. + gateways: List of gateway IDs. Returns: - A dictionary with keys "tools", "resources", "prompts", and "a2a_agents". + A dictionary with keys "tools", "resources", "prompts", "a2a_agents", and "gateways". Examples: >>> service = ServerService() >>> # Test with all None values >>> result = service._assemble_associated_items(None, None, None) >>> result - {'tools': [], 'resources': [], 'prompts': [], 'a2a_agents': []} + {'tools': [], 'resources': [], 'prompts': [], 'a2a_agents': [], 'gateways': []} >>> # Test with empty lists >>> result = service._assemble_associated_items([], [], []) >>> result - {'tools': [], 'resources': [], 'prompts': [], 'a2a_agents': []} + {'tools': [], 'resources': [], 'prompts': [], 'a2a_agents': [], 'gateways': []} >>> # Test with actual values >>> result = service._assemble_associated_items(['tool1', 'tool2'], ['res1'], ['prompt1']) >>> result - {'tools': ['tool1', 'tool2'], 'resources': ['res1'], 'prompts': ['prompt1'], 'a2a_agents': []} + {'tools': ['tool1', 'tool2'], 'resources': ['res1'], 'prompts': ['prompt1'], 'a2a_agents': [], 'gateways': []} >>> # Test with mixed None and values >>> result = service._assemble_associated_items(['tool1'], None, ['prompt1']) >>> result - {'tools': ['tool1'], 'resources': [], 'prompts': ['prompt1'], 'a2a_agents': []} + {'tools': ['tool1'], 'resources': [], 'prompts': ['prompt1'], 'a2a_agents': [], 'gateways': []} """ return { "tools": tools or [], "resources": resources or [], "prompts": prompts or [], "a2a_agents": a2a_agents or [], + "gateways": gateways or [], } def _get_team_name(self, db: Session, team_id: Optional[str]) -> Optional[str]: diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index 7516ec2d2..e1794918f 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -6685,25 +6685,51 @@ function initToolSelect( newSelectBtn.textContent = "Selecting all tools..."; try { - // Fetch all tool IDs from the server - const response = await fetch( - `${window.ROOT_PATH}/admin/tools/ids`, - ); - if (!response.ok) { - throw new Error("Failed to fetch tool IDs"); - } - - const data = await response.json(); - const allToolIds = data.tool_ids || []; - - // Check all currently loaded checkboxes + // Prefer full-set selection when pagination/infinite-scroll is present const loadedCheckboxes = container.querySelectorAll( 'input[type="checkbox"]', ); - loadedCheckboxes.forEach((cb) => (cb.checked = true)); + const visibleCheckboxes = Array.from(loadedCheckboxes).filter( + (cb) => cb.offsetParent !== null, + ); + + // Detect pagination/infinite-scroll controls for tools + const hasPaginationControls = !!document.getElementById( + "tools-pagination-controls", + ); + const hasScrollTrigger = !!document.querySelector( + "[id^='tools-scroll-trigger']", + ); + const isPaginated = hasPaginationControls || hasScrollTrigger; + + let allToolIds = []; + + if (!isPaginated && visibleCheckboxes.length > 0) { + // No pagination and some visible items => select visible set + allToolIds = visibleCheckboxes.map((cb) => cb.value); + visibleCheckboxes.forEach((cb) => (cb.checked = true)); + } else { + // Paginated (or no visible items) => fetch full set from server + const selectedGatewayIds = getSelectedGatewayIds + ? getSelectedGatewayIds() + : []; + const gatewayParam = + selectedGatewayIds && selectedGatewayIds.length + ? `?gateway_id=${encodeURIComponent(selectedGatewayIds.join(","))}` + : ""; + const response = await fetch( + `${window.ROOT_PATH}/admin/tools/ids${gatewayParam}`, + ); + if (!response.ok) { + throw new Error("Failed to fetch tool IDs"); + } + const data = await response.json(); + allToolIds = data.tool_ids || []; + // Check loaded checkboxes so UI shows selection where possible + loadedCheckboxes.forEach((cb) => (cb.checked = true)); + } // Add a hidden input to indicate "select all" mode - // Remove any existing one first let selectAllInput = container.querySelector( 'input[name="selectAllTools"]', ); @@ -6966,20 +6992,49 @@ function initResourceSelect( newSelectBtn.textContent = "Selecting all resources..."; try { - const resp = await fetch( - `${window.ROOT_PATH}/admin/resources/ids`, - ); - if (!resp.ok) { - throw new Error("Failed to fetch resource IDs"); - } - const data = await resp.json(); - const allIds = data.resource_ids || []; - - // Check all currently loaded checkboxes + // Prefer full-set selection when pagination/infinite-scroll is present const loadedCheckboxes = container.querySelectorAll( 'input[type="checkbox"]', ); - loadedCheckboxes.forEach((cb) => (cb.checked = true)); + const visibleCheckboxes = Array.from(loadedCheckboxes).filter( + (cb) => cb.offsetParent !== null, + ); + + // Detect pagination/infinite-scroll controls for resources + const hasPaginationControls = !!document.getElementById( + "resources-pagination-controls", + ); + const hasScrollTrigger = !!document.querySelector( + "[id^='resources-scroll-trigger']", + ); + const isPaginated = hasPaginationControls || hasScrollTrigger; + + let allIds = []; + + if (!isPaginated && visibleCheckboxes.length > 0) { + // No pagination and some visible items => select visible set + allIds = visibleCheckboxes.map((cb) => cb.value); + visibleCheckboxes.forEach((cb) => (cb.checked = true)); + } else { + // Paginated (or no visible items) => fetch full set from server + const selectedGatewayIds = getSelectedGatewayIds + ? getSelectedGatewayIds() + : []; + const gatewayParam = + selectedGatewayIds && selectedGatewayIds.length + ? `?gateway_id=${encodeURIComponent(selectedGatewayIds.join(","))}` + : ""; + const resp = await fetch( + `${window.ROOT_PATH}/admin/resources/ids${gatewayParam}`, + ); + if (!resp.ok) { + throw new Error("Failed to fetch resource IDs"); + } + const data = await resp.json(); + allIds = data.resource_ids || []; + // If nothing visible (paginated), check loaded checkboxes + loadedCheckboxes.forEach((cb) => (cb.checked = true)); + } // Add hidden select-all flag let selectAllInput = container.querySelector( @@ -7190,20 +7245,49 @@ function initPromptSelect( newSelectBtn.textContent = "Selecting all prompts..."; try { - const resp = await fetch( - `${window.ROOT_PATH}/admin/prompts/ids`, - ); - if (!resp.ok) { - throw new Error("Failed to fetch prompt IDs"); - } - const data = await resp.json(); - const allIds = data.prompt_ids || []; - - // Check all currently loaded checkboxes + // Prefer full-set selection when pagination/infinite-scroll is present const loadedCheckboxes = container.querySelectorAll( 'input[type="checkbox"]', ); - loadedCheckboxes.forEach((cb) => (cb.checked = true)); + const visibleCheckboxes = Array.from(loadedCheckboxes).filter( + (cb) => cb.offsetParent !== null, + ); + + // Detect pagination/infinite-scroll controls for prompts + const hasPaginationControls = !!document.getElementById( + "prompts-pagination-controls", + ); + const hasScrollTrigger = !!document.querySelector( + "[id^='prompts-scroll-trigger']", + ); + const isPaginated = hasPaginationControls || hasScrollTrigger; + + let allIds = []; + + if (!isPaginated && visibleCheckboxes.length > 0) { + // No pagination and some visible items => select visible set + allIds = visibleCheckboxes.map((cb) => cb.value); + visibleCheckboxes.forEach((cb) => (cb.checked = true)); + } else { + // Paginated (or no visible items) => fetch full set from server + const selectedGatewayIds = getSelectedGatewayIds + ? getSelectedGatewayIds() + : []; + const gatewayParam = + selectedGatewayIds && selectedGatewayIds.length + ? `?gateway_id=${encodeURIComponent(selectedGatewayIds.join(","))}` + : ""; + const resp = await fetch( + `${window.ROOT_PATH}/admin/prompts/ids${gatewayParam}`, + ); + if (!resp.ok) { + throw new Error("Failed to fetch prompt IDs"); + } + const data = await resp.json(); + allIds = data.prompt_ids || []; + // If nothing visible (paginated), check loaded checkboxes + loadedCheckboxes.forEach((cb) => (cb.checked = true)); + } // Add hidden select-all flag let selectAllInput = container.querySelector( @@ -7286,6 +7370,636 @@ function initPromptSelect( } } +// =================================================================== +// GATEWAY SELECT (Associated MCP Servers) - search/select/clear +// =================================================================== +function initGatewaySelect( + selectId = "associatedGateways", + pillsId = "selectedGatewayPills", + warnId = "selectedGatewayWarning", + max = 12, + selectBtnId = "selectAllGatewayBtn", + clearBtnId = "clearAllGatewayBtn", + searchInputId = "searchGateways", +) { + const container = document.getElementById(selectId); + const pillsBox = document.getElementById(pillsId); + const warnBox = document.getElementById(warnId); + const clearBtn = clearBtnId ? document.getElementById(clearBtnId) : null; + const selectBtn = selectBtnId ? document.getElementById(selectBtnId) : null; + const searchInput = searchInputId + ? document.getElementById(searchInputId) + : null; + + if (!container || !pillsBox || !warnBox) { + console.warn( + `Gateway select elements not found: ${selectId}, ${pillsId}, ${warnId}`, + ); + return; + } + + const pillClasses = + "inline-block bg-indigo-100 text-indigo-800 text-xs px-2 py-1 rounded-full dark:bg-indigo-900 dark:text-indigo-200"; + + // Search functionality + function applySearch() { + if (!searchInput) { + return; + } + + try { + const query = searchInput.value.toLowerCase().trim(); + const items = container.querySelectorAll(".tool-item"); + let visibleCount = 0; + + items.forEach((item) => { + const text = item.textContent.toLowerCase(); + if (!query || text.includes(query)) { + item.style.display = ""; + visibleCount++; + } else { + item.style.display = "none"; + } + }); + + // Update "no results" message if it exists + const noMsg = document.getElementById("noGatewayMessage"); + const searchQuerySpan = + document.getElementById("searchQueryServers"); + + if (noMsg) { + if (query && visibleCount === 0) { + noMsg.style.display = "block"; + if (searchQuerySpan) { + searchQuerySpan.textContent = query; + } + } else { + noMsg.style.display = "none"; + } + } + } catch (error) { + console.error("Error applying gateway search:", error); + } + } + + // Bind search input + if (searchInput && !searchInput.dataset.searchBound) { + searchInput.addEventListener("input", applySearch); + searchInput.dataset.searchBound = "true"; + } + + function update() { + try { + const checkboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + const checked = Array.from(checkboxes).filter((cb) => cb.checked); + + // Check if "Select All" mode is active + const selectAllInput = container.querySelector( + 'input[name="selectAllGateways"]', + ); + const allIdsInput = container.querySelector( + 'input[name="allGatewayIds"]', + ); + + let count = checked.length; + + // If Select All mode is active, use the count from allGatewayIds + if ( + selectAllInput && + selectAllInput.value === "true" && + allIdsInput + ) { + try { + const allIds = JSON.parse(allIdsInput.value); + count = allIds.length; + } catch (e) { + console.error("Error parsing allGatewayIds:", e); + } + } + + // Rebuild pills safely - show first 3, then summarize the rest + pillsBox.innerHTML = ""; + const maxPillsToShow = 3; + + checked.slice(0, maxPillsToShow).forEach((cb) => { + const span = document.createElement("span"); + span.className = pillClasses; + span.textContent = + cb.nextElementSibling?.textContent?.trim() || "Unnamed"; + pillsBox.appendChild(span); + }); + + // If more than maxPillsToShow, show a summary pill + if (count > maxPillsToShow) { + const span = document.createElement("span"); + span.className = pillClasses + " cursor-pointer"; + span.title = "Click to see all selected gateways"; + const remaining = count - maxPillsToShow; + span.textContent = `+${remaining} more`; + pillsBox.appendChild(span); + } + + // Warning when > max + if (count > max) { + warnBox.textContent = `Selected ${count} MCP servers. Selecting more than ${max} servers may impact performance.`; + } else { + warnBox.textContent = ""; + } + } catch (error) { + console.error("Error updating gateway select:", error); + } + } + + // Remove old event listeners by cloning and replacing (preserving ID) + if (clearBtn && !clearBtn.dataset.listenerAttached) { + clearBtn.dataset.listenerAttached = "true"; + const newClearBtn = clearBtn.cloneNode(true); + newClearBtn.dataset.listenerAttached = "true"; + clearBtn.parentNode.replaceChild(newClearBtn, clearBtn); + + newClearBtn.addEventListener("click", () => { + const checkboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + checkboxes.forEach((cb) => (cb.checked = false)); + + // Clear the "select all" flag + const selectAllInput = container.querySelector( + 'input[name="selectAllGateways"]', + ); + if (selectAllInput) { + selectAllInput.remove(); + } + const allIdsInput = container.querySelector( + 'input[name="allGatewayIds"]', + ); + if (allIdsInput) { + allIdsInput.remove(); + } + + update(); + + // Reload associated items after clearing selection + reloadAssociatedItems(); + }); + } + + if (selectBtn && !selectBtn.dataset.listenerAttached) { + selectBtn.dataset.listenerAttached = "true"; + const newSelectBtn = selectBtn.cloneNode(true); + newSelectBtn.dataset.listenerAttached = "true"; + selectBtn.parentNode.replaceChild(newSelectBtn, selectBtn); + + newSelectBtn.addEventListener("click", async () => { + // Disable button and show loading state + const originalText = newSelectBtn.textContent; + newSelectBtn.disabled = true; + newSelectBtn.textContent = "Selecting all gateways..."; + + try { + // Fetch all gateway IDs from the server + const response = await fetch( + `${window.ROOT_PATH}/admin/gateways/ids`, + ); + if (!response.ok) { + throw new Error("Failed to fetch gateway IDs"); + } + + const data = await response.json(); + const allGatewayIds = data.gateway_ids || []; + + // Apply search filter first to determine which items are visible + applySearch(); + + // Check only currently visible checkboxes + const loadedCheckboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + loadedCheckboxes.forEach((cb) => { + const parent = cb.closest(".tool-item") || cb.parentElement; + const isVisible = + parent && getComputedStyle(parent).display !== "none"; + if (isVisible) { + cb.checked = true; + } + }); + + // Add a hidden input to indicate "select all" mode + // Remove any existing one first + let selectAllInput = container.querySelector( + 'input[name="selectAllGateways"]', + ); + if (!selectAllInput) { + selectAllInput = document.createElement("input"); + selectAllInput.type = "hidden"; + selectAllInput.name = "selectAllGateways"; + container.appendChild(selectAllInput); + } + selectAllInput.value = "true"; + + // Also store the IDs as a JSON array for the backend + // Ensure the special 'null' sentinel is included when selecting all + try { + const nullCheckbox = container.querySelector( + 'input[data-gateway-null="true"]', + ); + if (nullCheckbox) { + // Include the literal string "null" so server-side + // `any(gid.lower() == 'null' ...)` evaluates to true. + if (!allGatewayIds.includes("null")) { + allGatewayIds.push("null"); + } + } + } catch (err) { + console.error( + "Error ensuring null sentinel in gateway IDs:", + err, + ); + } + + let allIdsInput = container.querySelector( + 'input[name="allGatewayIds"]', + ); + if (!allIdsInput) { + allIdsInput = document.createElement("input"); + allIdsInput.type = "hidden"; + allIdsInput.name = "allGatewayIds"; + container.appendChild(allIdsInput); + } + allIdsInput.value = JSON.stringify(allGatewayIds); + + update(); + + newSelectBtn.textContent = `✓ All ${allGatewayIds.length} gateways selected`; + setTimeout(() => { + newSelectBtn.textContent = originalText; + }, 2000); + + // Reload associated items after selecting all + reloadAssociatedItems(); + } catch (error) { + console.error("Error in Select All:", error); + alert("Failed to select all gateways. Please try again."); + newSelectBtn.disabled = false; + newSelectBtn.textContent = originalText; + } finally { + newSelectBtn.disabled = false; + } + }); + } + + update(); // Initial render + + // Attach change listeners to checkboxes (using delegation for dynamic content) + if (!container.dataset.changeListenerAttached) { + container.dataset.changeListenerAttached = "true"; + container.addEventListener("change", (e) => { + if (e.target.type === "checkbox") { + // Log gateway_id when checkbox is clicked + const gatewayId = e.target.value; + const gatewayName = + e.target.nextElementSibling?.textContent?.trim() || + "Unknown"; + const isChecked = e.target.checked; + + console.log( + `[MCP Server Selection] Gateway ID: ${gatewayId}, Name: ${gatewayName}, Checked: ${isChecked}`, + ); + + // Check if we're in "Select All" mode + const selectAllInput = container.querySelector( + 'input[name="selectAllGateways"]', + ); + const allIdsInput = container.querySelector( + 'input[name="allGatewayIds"]', + ); + + if ( + selectAllInput && + selectAllInput.value === "true" && + allIdsInput + ) { + // User is manually checking/unchecking after Select All + // Update the allGatewayIds array to reflect the change + try { + let allIds = JSON.parse(allIdsInput.value); + + if (e.target.checked) { + // Add the ID if it's not already there + if (!allIds.includes(gatewayId)) { + allIds.push(gatewayId); + } + } else { + // Remove the ID from the array + allIds = allIds.filter((id) => id !== gatewayId); + } + + // Update the hidden field + allIdsInput.value = JSON.stringify(allIds); + } catch (error) { + console.error("Error updating allGatewayIds:", error); + } + } + + // No exclusivity: allow the special 'null' gateway (RestTool/Prompts/Resources) to be + // selected together with real gateways. Server-side filtering already + // supports mixed lists like `gateway_id=abc,null`. + + update(); + + // Trigger reload of associated tools, resources, and prompts with selected gateway filter + reloadAssociatedItems(); + } + }); + } + + // Initial render + applySearch(); + update(); +} + +/** + * Get all selected gateway IDs from the gateway selection container + * @returns {string[]} Array of selected gateway IDs + */ +function getSelectedGatewayIds() { + const container = document.getElementById("associatedGateways"); + console.log("[Gateway Selection DEBUG] Container found:", !!container); + + if (!container) { + console.warn( + "[Gateway Selection DEBUG] associatedGateways container not found", + ); + return []; + } + + // Check if "Select All" mode is active + const selectAllInput = container.querySelector( + "input[name='selectAllGateways']", + ); + const allIdsInput = container.querySelector("input[name='allGatewayIds']"); + + console.log( + "[Gateway Selection DEBUG] Select All mode:", + selectAllInput?.value === "true", + ); + if (selectAllInput && selectAllInput.value === "true" && allIdsInput) { + try { + const allIds = JSON.parse(allIdsInput.value); + console.log( + `[Gateway Selection DEBUG] Returning all gateway IDs (${allIds.length} total)`, + ); + return allIds; + } catch (error) { + console.error( + "[Gateway Selection DEBUG] Error parsing allGatewayIds:", + error, + ); + } + } + + // Otherwise, get all checked checkboxes. If the special 'null' gateway + // checkbox is selected, include the sentinel 'null' alongside any real + // gateway ids. This allows requests like `gateway_id=abc,null` which the + // server interprets as (gateway_id = abc) OR (gateway_id IS NULL). + const checkboxes = container.querySelectorAll( + "input[type='checkbox']:checked", + ); + + const selectedIds = Array.from(checkboxes) + .map((cb) => { + // Convert the special null-gateway checkbox to the literal 'null' + if (cb.dataset?.gatewayNull === "true") { + return "null"; + } + return cb.value; + }) + // Filter out any empty values to avoid sending empty CSV entries + .filter((id) => id !== "" && id !== null && id !== undefined); + + console.log( + `[Gateway Selection DEBUG] Found ${selectedIds.length} checked gateway checkboxes`, + ); + console.log("[Gateway Selection DEBUG] Selected gateway IDs:", selectedIds); + + return selectedIds; +} + +/** + * Reload associated tools, resources, and prompts filtered by selected gateway IDs + */ +function reloadAssociatedItems() { + const selectedGatewayIds = getSelectedGatewayIds(); + // Join all selected IDs (including the special 'null' sentinel if present) + // so the server receives a combined filter like `gateway_id=abc,null`. + let gatewayIdParam = ""; + if (selectedGatewayIds.length > 0) { + gatewayIdParam = selectedGatewayIds.join(","); + } + + console.log( + `[Filter Update] Reloading associated items for gateway IDs: ${gatewayIdParam || "none (showing all)"}`, + ); + console.log( + "[Filter Update DEBUG] Selected gateway IDs array:", + selectedGatewayIds, + ); + + // Reload tools + const toolsContainer = document.getElementById("associatedTools"); + if (toolsContainer) { + const toolsUrl = gatewayIdParam + ? `${window.ROOT_PATH}/admin/tools/partial?page=1&per_page=50&render=selector&gateway_id=${encodeURIComponent(gatewayIdParam)}` + : `${window.ROOT_PATH}/admin/tools/partial?page=1&per_page=50&render=selector`; + + console.log("[Filter Update DEBUG] Tools URL:", toolsUrl); + + // Use HTMX to reload the content + if (window.htmx) { + htmx.ajax("GET", toolsUrl, { + target: "#associatedTools", + swap: "innerHTML", + }) + .then(() => { + console.log( + "[Filter Update DEBUG] Tools reloaded successfully", + ); + // Re-initialize the tool select after content is loaded + initToolSelect( + "associatedTools", + "selectedToolsPills", + "selectedToolsWarning", + 6, + "selectAllToolsBtn", + "clearAllToolsBtn", + ); + }) + .catch((err) => { + console.error( + "[Filter Update DEBUG] Tools reload failed:", + err, + ); + }); + } else { + console.error( + "[Filter Update DEBUG] HTMX not available for tools reload", + ); + } + } else { + console.warn("[Filter Update DEBUG] Tools container not found"); + } + + // Reload resources - use fetch directly to avoid HTMX race conditions + const resourcesContainer = document.getElementById("associatedResources"); + if (resourcesContainer) { + const resourcesUrl = gatewayIdParam + ? `${window.ROOT_PATH}/admin/resources/partial?page=1&per_page=50&render=selector&gateway_id=${encodeURIComponent(gatewayIdParam)}` + : `${window.ROOT_PATH}/admin/resources/partial?page=1&per_page=50&render=selector`; + + console.log("[Filter Update DEBUG] Resources URL:", resourcesUrl); + + // Use fetch() directly instead of htmx.ajax() to avoid race conditions + fetch(resourcesUrl, { + method: "GET", + headers: { + "HX-Request": "true", + "HX-Current-URL": window.location.href, + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error( + `HTTP ${response.status}: ${response.statusText}`, + ); + } + return response.text(); + }) + .then((html) => { + console.log( + "[Filter Update DEBUG] Resources fetch successful, HTML length:", + html.length, + ); + resourcesContainer.innerHTML = html; + // If HTMX is available, process the newly-inserted HTML so hx-* + // triggers (like the infinite-scroll 'intersect' trigger) are + // initialized. To avoid HTMX re-triggering the container's + // own `hx-get`/`hx-trigger="load"` (which would issue a second + // request without the gateway filter), temporarily remove those + // attributes from the container while we call `htmx.process`. + if (window.htmx && typeof window.htmx.process === "function") { + try { + // Backup and remove attributes that could auto-fire + const hadHxGet = + resourcesContainer.hasAttribute("hx-get"); + const hadHxTrigger = + resourcesContainer.hasAttribute("hx-trigger"); + const oldHxGet = + resourcesContainer.getAttribute("hx-get"); + const oldHxTrigger = + resourcesContainer.getAttribute("hx-trigger"); + + if (hadHxGet) { + resourcesContainer.removeAttribute("hx-get"); + } + if (hadHxTrigger) { + resourcesContainer.removeAttribute("hx-trigger"); + } + + // Process only the newly-inserted inner nodes to initialize + // any hx-* behavior (infinite scroll, after-swap hooks, etc.) + window.htmx.process(resourcesContainer); + + // Restore original attributes so the container retains its + // declarative behavior for future operations, but don't + // re-process (we already processed child nodes). + if (hadHxGet && oldHxGet !== null) { + resourcesContainer.setAttribute("hx-get", oldHxGet); + } + if (hadHxTrigger && oldHxTrigger !== null) { + resourcesContainer.setAttribute( + "hx-trigger", + oldHxTrigger, + ); + } + + console.log( + "[Filter Update DEBUG] htmx.process called on resources container (attributes temporarily removed)", + ); + } catch (e) { + console.warn( + "[Filter Update DEBUG] htmx.process failed:", + e, + ); + } + } + + // Re-initialize the resource select after content is loaded + initResourceSelect( + "associatedResources", + "selectedResourcesPills", + "selectedResourcesWarning", + 6, + "selectAllResourcesBtn", + "clearAllResourcesBtn", + ); + console.log( + "[Filter Update DEBUG] Resources reloaded successfully via fetch", + ); + }) + .catch((err) => { + console.error( + "[Filter Update DEBUG] Resources reload failed:", + err, + ); + }); + } else { + console.warn("[Filter Update DEBUG] Resources container not found"); + } + + // Reload prompts + const promptsContainer = document.getElementById("associatedPrompts"); + if (promptsContainer) { + const promptsUrl = gatewayIdParam + ? `${window.ROOT_PATH}/admin/prompts/partial?page=1&per_page=50&render=selector&gateway_id=${encodeURIComponent(gatewayIdParam)}` + : `${window.ROOT_PATH}/admin/prompts/partial?page=1&per_page=50&render=selector`; + + if (window.htmx) { + htmx.ajax("GET", promptsUrl, { + target: "#associatedPrompts", + swap: "innerHTML", + }).then(() => { + // Re-initialize the prompt select after content is loaded + initPromptSelect( + "associatedPrompts", + "selectedPromptsPills", + "selectedPromptsWarning", + 6, + "selectAllPromptsBtn", + "clearAllPromptsBtn", + ); + }); + } + } +} + +// Initialize gateway select on page load +document.addEventListener("DOMContentLoaded", function () { + // Initialize for the create server form + if (document.getElementById("associatedGateways")) { + initGatewaySelect( + "associatedGateways", + "selectedGatewayPills", + "selectedGatewayWarning", + 12, + "selectAllGatewayBtn", + "clearAllGatewayBtn", + "searchGateways", + ); + } +}); + // =================================================================== // INACTIVE ITEMS HANDLING // =================================================================== @@ -10461,7 +11175,7 @@ if (window.performance && window.performance.mark) { // Tool Tips for components with Alpine.js // =================================================================== -/* global Alpine */ +/* global Alpine, htmx */ function setupTooltipsWithAlpine() { document.addEventListener("alpine:init", () => { console.log("Initializing Alpine tooltip directive..."); @@ -17987,7 +18701,7 @@ function initializeChatInputResize() { async function serverSideToolSearch(searchTerm) { const container = document.getElementById("associatedTools"); const noResultsMessage = safeGetElement("noToolsMessage", true); - const searchQuerySpan = safeGetElement("searchQuery", true); + const searchQuerySpan = safeGetElement("searchQueryTools", true); if (!container) { console.error("associatedTools container not found"); diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 4e3fd64bc..00b2ee752 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -2034,79 +2034,177 @@

class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" /> -
- - +
- -
- - - - - Loading tools... + class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-300 dark:border-gray-700" + > + + +
+ {% for gateway in gateways %} + + {% endfor %} + + + + +
+
+ + +
+ +

+ Choose an MCP server, then select the tools, resources, and prompts to configure your virtual server. +

+ + +
+ + +
- -
- - - -
+ +
+ + + + + Loading tools... +
+
+ +
+ - -
+ +
- -
+ +
+ + +
+
-
+
+
+
diff --git a/mcpgateway/templates/prompts_selector_items.html b/mcpgateway/templates/prompts_selector_items.html index bfdbfe395..fc7b3fd94 100644 --- a/mcpgateway/templates/prompts_selector_items.html +++ b/mcpgateway/templates/prompts_selector_items.html @@ -21,7 +21,7 @@
- {% if tool.teamName %}{{ tool.teamName }}{% else %}None{% endif %} diff --git a/mcpgateway/templates/tools_selector_items.html b/mcpgateway/templates/tools_selector_items.html index 62346dc14..706aaabc1 100644 --- a/mcpgateway/templates/tools_selector_items.html +++ b/mcpgateway/templates/tools_selector_items.html @@ -21,7 +21,7 @@