diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index adca1be..b4111f8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,6 @@ name: Lint -on: [push, pull_request] +on: [pull_request] permissions: contents: read # Required to check out the code diff --git a/.gitignore b/.gitignore index 63e2f7e..959c35d 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,7 @@ cython_debug/ # PyPI configuration file .pypirc cookies.txt + +# MCP Gateway specific +registry/server_state.json +registry/nginx_mcp_revproxy.conf diff --git a/README.md b/README.md index 1f574b8..709d169 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +MCP Gateway Logo + # ⚠️ ACTIVE DEVELOPMENT - WORK IN PROGRESS ⚠️ > **WARNING**: This repository is under active development. Expect frequent updates and breaking changes as we improve functionality and refine APIs. We recommend pinning to specific versions for production use. Star the repository to track our progress! @@ -7,114 +9,156 @@ # MCP Gateway Registry -This application provides a web interface and API for registering and managing backend services that can be proxied through a gateway (like Nginx). It allows viewing service status, descriptions, and toggling their availability (currently simulated). +This application provides a web interface and API for registering and managing backend MCP (Meta-Computation Protocol) services. It acts as a central registry, health monitor, and dynamic reverse proxy configuration generator for Nginx. + +## Features + +* **Service Registration:** Register MCP services via JSON files or the web UI/API. +* **Web UI:** Manage services, view status, and monitor health through a web interface. +* **Authentication:** Secure login system for the web UI and API access. +* **Health Checks:** + * Periodic background checks for enabled services (checks `/sse` endpoint). + * Manual refresh trigger via UI button or API endpoint. +* **Real-time UI Updates:** Uses WebSockets to push health status, tool counts, and last-checked times to all connected clients. +* **Dynamic Nginx Configuration:** Generates an Nginx reverse proxy configuration file (`registry/nginx_mcp_revproxy.conf`) based on registered services and their enabled/disabled state. +* **MCP Tool Discovery:** Automatically fetches and displays the list of tools (name, description, schema) for healthy services using the MCP client library. +* **Service Management:** + * Enable/Disable services directly from the UI. + * Edit service details (name, description, URL, tags, etc.). +* **Filtering & Statistics:** Filter the service list in the UI (All, Enabled, Disabled, Issues) and view basic statistics. +* **UI Customization:** + * Dark/Light theme toggle (persisted in local storage). + * Collapsible sidebar (state persisted in local storage). +* **State Persistence:** Enabled/Disabled state is saved to `registry/server_state.json` (and ignored by Git). ## Prerequisites -* Python 3.12+ -* [uv](https://github.com/astral-sh/uv) (or `pip`) for package management. +* Python 3.11+ (or compatible version supporting FastAPI and MCP Client) +* [uv](https://github.com/astral-sh/uv) (recommended) or `pip` for package management. +* Nginx (or another reverse proxy) installed and configured to use the generated configuration file. ## Installation -1. **Clone the repository (if you haven't already):** +1. **Clone the repository:** ```bash git clone cd mcp-gateway ``` 2. **Create and activate a virtual environment (recommended):** - Using `venv` (standard Python): - ```bash - python -m venv .venv - source .venv/bin/activate # On Windows use `.venv\Scripts\activate` - ``` - Using `uv` (which handles environments automatically): - You can skip explicit environment creation if you use `uv run`. - -3. **Install dependencies:** Dependencies are defined in `pyproject.toml`. - - Using `uv`: - ```bash - # Installs dependencies defined in pyproject.toml - uv pip install . - ``` - Using `pip`: - ```bash - # Installs dependencies defined in pyproject.toml - pip install . - ``` + * Using `venv`: + ```bash + python -m venv .venv + source .venv/bin/activate # Linux/macOS + # .venv\Scripts\activate # Windows + ``` + * `uv` handles environments automatically via `uv run` or `uv pip sync`. + +3. **Install dependencies:** (Defined in `pyproject.toml` and locked in `uv.lock`) + * Using `uv`: + ```bash + uv pip sync + ``` + * Using `pip`: + ```bash + pip install . + ``` ## Configuration -1. **Environment Variables:** The application uses a `.env` file in the project root (`mcp-gateway/`) for configuration. Create this file if it doesn't exist: +1. **Environment Variables:** Create a `.env` file in the project root (`mcp-gateway/`). ```bash - cp .env.example .env # If you create an example file - # Or create it manually touch .env ``` - -2. **Edit `.env`:** Add the following variables: + Add the following variables, replacing placeholders with secure values: ```dotenv - # A strong, randomly generated secret key for session security - SECRET_KEY='your_strong_random_secret_key' + # REQUIRED: A strong, randomly generated secret key for session security + SECRET_KEY='your_strong_random_secret_key_32_chars_or_more' - # Credentials for the web interface login + # REQUIRED: Credentials for the web interface login ADMIN_USER='admin' ADMIN_PASSWORD='your_secure_password' ``` - *Replace the placeholder values with secure ones.* - -3. **Service Definitions:** Services are defined by JSON files in the `registry/servers/` directory. See existing files for the expected format. The application loads these on startup. + **⚠️ IMPORTANT:** Use a strong, unpredictable `SECRET_KEY` for production environments. + +2. **Service Definitions:** Services can be added via the UI after starting the application. Alternatively, you can manually create JSON files in the `registry/servers/` directory before the first run. Each file defines one service. Example (`my_service.json`): + ```json + { + "server_name": "My Example Service", + "description": "Provides example functionality.", + "path": "/my-service", + "proxy_pass_url": "http://localhost:8001", + "tags": ["example", "test"], + "num_tools": 0, + "num_stars": 0, + "is_python": true, + "license": "MIT", + "tool_list": [] + } + ``` ## Running the Application -**Using `uv run`:** - -This command leverages `uv` to manage the environment and run the development server. - -```bash -uv run uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 -``` -* `--reload`: Enables auto-reload on code changes. -* `--host 0.0.0.0`: Makes the server accessible on your network. -* `--port 7860`: Specifies the port to run on. - -**Using `uvicorn` directly (after installing dependencies and activating venv):** - -```bash -uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 -``` - -Once running, you can access the web interface at `http://:7860`. +1. **Start the FastAPI server:** + * Using `uv`: + ```bash + uv run uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 + ``` + * Using `uvicorn` directly (ensure virtual environment is active): + ```bash + uvicorn registry.main:app --reload --host 0.0.0.0 --port 7860 + ``` + * `--reload`: Enables auto-reload for development. Remove for production. + * `--host 0.0.0.0`: Makes the server accessible on your network. + * `--port 7860`: Specifies the port. + +2. **Configure Nginx:** + * The application generates `registry/nginx_mcp_revproxy.conf` on startup. + * Ensure your Nginx instance is running and includes this configuration file in its main `nginx.conf` (e.g., using an `include` directive in the `http` block). + * Reload or restart Nginx to apply the configuration (`sudo nginx -s reload`). + * **Note:** Detailed Nginx setup is beyond the scope of this README. The generated file assumes Nginx is listening on a standard port (e.g., 80 or 443) and proxies requests starting with registered paths (e.g., `/my-service`) to the appropriate backend defined by `proxy_pass_url`. + +3. **Access the UI:** Open your web browser and navigate to the address where Nginx is serving the application (e.g., `http://`). You should be redirected to the login page at `/login` (served by the FastAPI app). *Direct access via port 7860 is primarily for the UI itself; service proxying relies on Nginx.* + +## Usage + +1. **Login:** Use the `ADMIN_USER` and `ADMIN_PASSWORD` from your `.env` file. +2. **Register Service:** Use the "Register New Service" form in the UI (or the API). +3. **Manage Services:** + * Toggle the Enabled/Disabled switch. The Nginx config automatically comments/uncomments the relevant `location` block. + * Click "Modify" to edit service details. + * Click the refresh icon (🔄) in the card header to manually trigger a health check and tool list update for enabled services. +4. **View Tools:** Click the tool count icon (🔧) in the card footer to open a modal displaying discovered tools and their schemas for healthy services. +5. **Filter:** Use the sidebar links to filter the displayed services. ## Project Structure -* `registry/`: Contains the main FastAPI application (`main.py`). +* `registry/`: Main FastAPI application (`main.py`). * `servers/`: Stores JSON definitions for each registered service. * `static/`: Static assets (CSS, JS, images). - * `templates/`: Jinja2 HTML templates. -* `.env`: Configuration file (needs to be created). + * `templates/`: Jinja2 HTML templates (`index.html`, `login.html`, etc.). + * `server_state.json`: Stores the enabled/disabled state (created automatically, **ignored by Git**). + * `nginx_mcp_revproxy.conf`: Nginx config generated dynamically (**ignored by Git**). + * `nginx_template.conf`: Template used for Nginx config generation. +* `.env`: Environment variables (local configuration, **ignored by Git**). +* `.gitignore`: Specifies files ignored by Git. +* `pyproject.toml`: Project metadata and dependencies. +* `uv.lock`: Locked dependency versions (used by `uv`). * `README.md`: This file. +* `LICENSE`: Project license file. + +## API Endpoints (Brief Overview) + +* `POST /register`: Register a new service (form data). +* `POST /toggle/{service_path}`: Enable/disable a service (form data). +* `POST /edit/{service_path}`: Update service details (form data). +* `GET /api/server_details/{service_path}`: Get full details for a service (JSON). +* `GET /api/tools/{service_path}`: Get the discovered tool list for a service (JSON). +* `POST /api/refresh/{service_path}`: Manually trigger a health check/tool update. +* `GET /login`, `POST /login`, `POST /logout`: Authentication routes. +* `WS /ws/health_status`: WebSocket endpoint for real-time updates. -## Registering New Services (API) - -You can register new services programmatically by sending a POST request to the `/register` endpoint. - -* **URL:** `/register` -* **Method:** `POST` -* **Authentication:** Requires a valid session cookie obtained via login. -* **Content-Type:** `application/x-www-form-urlencoded` -* **Form Data:** - * `name`: (String) Display name of the service. - * `description`: (String) Description of the service. - * `path`: (String) URL path for the service (e.g., `/my-service`). - * `tags`: (String, Optional) Comma-separated list of tags. - * `num_tools`: (Integer, Optional, Default: 0) Number of tools. - * `num_stars`: (Integer, Optional, Default: 0) Number of stars. - * `is_python`: (Boolean, Optional, Default: false) Whether it's a Python service. - * `license`: (String, Optional, Default: "N/A") License information. - -**Example using `curl` (after logging in via the browser to get a cookie):** +*(Authentication via session cookie is required for most non-login routes)* ```bash curl -X POST http://localhost:7860/register \ diff --git a/registry/main.py b/registry/main.py index 6ff4e1f..9e889c0 100644 --- a/registry/main.py +++ b/registry/main.py @@ -155,17 +155,20 @@ def regenerate_nginx_config(): server_info = REGISTERED_SERVERS[path] proxy_url = server_info.get("proxy_pass_url") is_enabled = MOCK_SERVICE_STATE.get(path, False) # Default to disabled if state unknown + health_status = SERVER_HEALTH_STATUS.get(path) # Get current health status if not proxy_url: print(f"Warning: Skipping server '{server_info['server_name']}' ({path}) - missing proxy_pass_url.") continue - if is_enabled: + # Only create an active block if the service is enabled AND healthy + if is_enabled and health_status == "healthy": block = LOCATION_BLOCK_TEMPLATE.format( path=path, proxy_pass_url=proxy_url ) else: + # Comment out the block if disabled OR not healthy block = COMMENTED_LOCATION_BLOCK_TEMPLATE.format( path=path, proxy_pass_url=proxy_url @@ -459,7 +462,7 @@ async def perform_single_health_check(path: str) -> tuple[str, datetime | None]: return "error: server not registered", None url = server_info.get("proxy_pass_url") - # Removed previous_status fetching from here as it's now at the top + is_enabled = MOCK_SERVICE_STATE.get(path, False) # Get enabled state for later check # --- Record check time --- last_checked_time = datetime.now(timezone.utc) @@ -470,6 +473,11 @@ async def perform_single_health_check(path: str) -> tuple[str, datetime | None]: current_status = "error: missing URL" SERVER_HEALTH_STATUS[path] = current_status print(f"Health check skipped for {path}: Missing URL.") + # --- Regenerate Nginx if status affecting it changed --- START + if is_enabled and previous_status == "healthy": # Was healthy, now isn't (due to missing URL) + print(f"Status changed from healthy for {path}, regenerating Nginx config...") + regenerate_nginx_config() + # --- Regenerate Nginx if status affecting it changed --- END return current_status, last_checked_time # Update status to 'checking' before performing the check @@ -503,9 +511,14 @@ async def perform_single_health_check(path: str) -> tuple[str, datetime | None]: print(f"Health check successful for {path} ({url}).") # --- Check for transition to healthy state --- START + # Note: Tool list fetching moved inside the status transition check if previous_status != "healthy": - print(f"Service {path} transitioned to healthy. Attempting to fetch tool list...") - # Ensure url is not None before attempting connection + print(f"Service {path} transitioned to healthy. Regenerating Nginx config and fetching tool list...") + # --- Regenerate Nginx on transition TO healthy --- START + regenerate_nginx_config() + # --- Regenerate Nginx on transition TO healthy --- END + + # Ensure url is not None before attempting connection (redundant check as url is checked above, but safe) if url: tool_list = await get_tools_from_server(url) # Get the list of dicts @@ -533,6 +546,7 @@ async def perform_single_health_check(path: str) -> tuple[str, datetime | None]: else: print(f"Failed to retrieve tool list for healthy service {path}. List/Count remains unchanged.") else: + # This case should technically not be reachable due to earlier url check print(f"Cannot fetch tool list for {path}: proxy_pass_url is missing.") # --- Check for transition to healthy state --- END @@ -568,6 +582,19 @@ async def perform_single_health_check(path: str) -> tuple[str, datetime | None]: SERVER_HEALTH_STATUS[path] = current_status print(f"Final health status for {path}: {current_status}") + # --- Regenerate Nginx if status affecting it changed --- START + # Check if the service is enabled AND its Nginx-relevant status changed + if is_enabled: + if previous_status == "healthy" and current_status != "healthy": + print(f"Status changed FROM healthy for enabled service {path}, regenerating Nginx config...") + regenerate_nginx_config() + # Regeneration on transition TO healthy is handled within the proc.returncode == 0 block above + # elif previous_status != "healthy" and current_status == "healthy": + # print(f"Status changed TO healthy for {path}, regenerating Nginx config...") + # regenerate_nginx_config() # Already handled above + # --- Regenerate Nginx if status affecting it changed --- END + + return current_status, last_checked_time @@ -579,12 +606,19 @@ async def run_health_checks(): paths_to_check = list(REGISTERED_SERVERS.keys()) needs_broadcast = False # Flag to check if any status actually changed + # --- Use a copy of MOCK_SERVICE_STATE for stable iteration --- START + current_enabled_state = MOCK_SERVICE_STATE.copy() + # --- Use a copy of MOCK_SERVICE_STATE for stable iteration --- END + for path in paths_to_check: if path not in REGISTERED_SERVERS: # Check if server was removed during the loop continue - is_enabled = MOCK_SERVICE_STATE.get(path, False) - previous_status = SERVER_HEALTH_STATUS.get(path) # Get status before check cycle + # --- Use copied state for check --- START + # is_enabled = MOCK_SERVICE_STATE.get(path, False) + is_enabled = current_enabled_state.get(path, False) + # --- Use copied state for check --- END + previous_status = SERVER_HEALTH_STATUS.get(path) if not is_enabled: new_status = "disabled" @@ -631,21 +665,61 @@ async def run_health_checks(): @asynccontextmanager async def lifespan(app: FastAPI): print("Running startup tasks...") - load_registered_servers_and_state() # Loads servers, initializes health status - # --- Initialize health status based on enabled state --- START - global SERVER_HEALTH_STATUS + # 1. Load server definitions and persisted enabled/disabled state + load_registered_servers_and_state() + + # 2. Perform initial health checks concurrently for *enabled* services + print("Performing initial health checks for enabled services...") + initial_check_tasks = [] + enabled_paths = [path for path, is_enabled in MOCK_SERVICE_STATE.items() if is_enabled] + + global SERVER_HEALTH_STATUS, SERVER_LAST_CHECK_TIME + # Initialize status for all servers (defaults for disabled) for path in REGISTERED_SERVERS.keys(): SERVER_LAST_CHECK_TIME[path] = None # Initialize last check time - if MOCK_SERVICE_STATE.get(path, False): - SERVER_HEALTH_STATUS[path] = "checking" # Check enabled services + if path not in enabled_paths: + SERVER_HEALTH_STATUS[path] = "disabled" else: - SERVER_HEALTH_STATUS[path] = "disabled" # Mark disabled services - print(f"Initialized health status based on enabled state: {SERVER_HEALTH_STATUS}") - # --- Initialize health status based on enabled state --- END - regenerate_nginx_config() # Generate config after loading state + # Will be set by the check task below (or remain unset if check fails badly) + SERVER_HEALTH_STATUS[path] = "checking" # Tentative status before check runs + + print(f"Initially enabled services to check: {enabled_paths}") + if enabled_paths: + for path in enabled_paths: + # Create a task for each enabled service check + task = asyncio.create_task(perform_single_health_check(path)) + initial_check_tasks.append(task) + + # Wait for all initial checks to complete + results = await asyncio.gather(*initial_check_tasks, return_exceptions=True) + + # Log results/errors from initial checks + for i, result in enumerate(results): + path = enabled_paths[i] + if isinstance(result, Exception): + print(f"ERROR during initial health check for {path}: {result}") + # Status might have already been set to an error state within the check function + else: + status, _ = result # Unpack the result tuple + print(f"Initial health check completed for {path}: Status = {status}") + else: + print("No services are initially enabled.") + + print(f"Initial health status after checks: {SERVER_HEALTH_STATUS}") + + # 3. Generate Nginx config *after* initial checks are done + print("Generating initial Nginx configuration...") + regenerate_nginx_config() # Generate config based on initial health status + + # 4. Start the background periodic health check task print("Starting background health check task...") health_check_task = asyncio.create_task(run_health_checks()) + + # --- Yield to let the application run --- START yield + # --- Yield to let the application run --- END + + # --- Shutdown tasks --- START print("Running shutdown tasks...") print("Cancelling background health check task...") health_check_task.cancel() @@ -653,6 +727,7 @@ async def lifespan(app: FastAPI): await health_check_task except asyncio.CancelledError: print("Health check task cancelled successfully.") + # --- Shutdown tasks --- END app = FastAPI(lifespan=lifespan) @@ -906,9 +981,28 @@ async def send_specific_update(): if not regenerate_nginx_config(): print("ERROR: Failed to update Nginx configuration after toggle.") - query_param = request.query_params.get("query", "") - redirect_url = f"/?query={query_param}" if query_param else "/" - return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER) + # --- Return JSON instead of Redirect --- START + final_status = SERVER_HEALTH_STATUS.get(service_path, "unknown") + final_last_checked_dt = SERVER_LAST_CHECK_TIME.get(service_path) + final_last_checked_iso = final_last_checked_dt.isoformat() if final_last_checked_dt else None + final_num_tools = REGISTERED_SERVERS.get(service_path, {}).get("num_tools", 0) + + return JSONResponse( + status_code=200, + content={ + "message": f"Toggle request for {service_path} processed.", + "service_path": service_path, + "new_enabled_state": new_state, # The state it was set to + "status": final_status, # The status after potential immediate check + "last_checked_iso": final_last_checked_iso, + "num_tools": final_num_tools + } + ) + # --- Return JSON instead of Redirect --- END + + # query_param = request.query_params.get("query", "") + # redirect_url = f"/?query={query_param}" if query_param else "/" + # return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER) @app.post("/register") @@ -1041,6 +1135,66 @@ async def get_service_tools( # --- Endpoint to get tool list for a service --- END +# --- Refresh Endpoint --- START +@app.post("/api/refresh/{service_path:path}") +async def refresh_service(service_path: str, username: Annotated[str, Depends(api_auth)]): + if not service_path.startswith('/'): + service_path = '/' + service_path + + # Check if service exists + if service_path not in REGISTERED_SERVERS: + raise HTTPException(status_code=404, detail="Service path not registered") + + # Check if service is enabled + is_enabled = MOCK_SERVICE_STATE.get(service_path, False) + if not is_enabled: + raise HTTPException(status_code=400, detail="Cannot refresh a disabled service") + + print(f"Manual refresh requested for {service_path} by user '{username}'...") + try: + # Trigger the health check (which also updates tools if healthy) + await perform_single_health_check(service_path) + # --- Regenerate Nginx config after manual refresh --- START + # The health check itself might trigger regeneration, but do it explicitly + # here too to ensure it happens after the refresh attempt completes. + print(f"Regenerating Nginx config after manual refresh for {service_path}...") + regenerate_nginx_config() + # --- Regenerate Nginx config after manual refresh --- END + except Exception as e: + # Catch potential errors during the check itself + print(f"ERROR during manual refresh check for {service_path}: {e}") + # Update status to reflect the error + error_status = f"error: refresh execution failed ({type(e).__name__})" + SERVER_HEALTH_STATUS[service_path] = error_status + SERVER_LAST_CHECK_TIME[service_path] = datetime.now(timezone.utc) + # Still broadcast the error state + await broadcast_single_service_update(service_path) + # --- Regenerate Nginx config even after refresh failure --- START + # Ensure Nginx reflects the error state if it was previously healthy + print(f"Regenerating Nginx config after manual refresh failed for {service_path}...") + regenerate_nginx_config() + # --- Regenerate Nginx config even after refresh failure --- END + # Return error response + raise HTTPException(status_code=500, detail=f"Refresh check failed: {e}") + + # Check completed, broadcast the latest status + await broadcast_single_service_update(service_path) + + # Return the latest status from global state + final_status = SERVER_HEALTH_STATUS.get(service_path, "unknown") + final_last_checked_dt = SERVER_LAST_CHECK_TIME.get(service_path) + final_last_checked_iso = final_last_checked_dt.isoformat() if final_last_checked_dt else None + final_num_tools = REGISTERED_SERVERS.get(service_path, {}).get("num_tools", 0) + + return { + "service_path": service_path, + "status": final_status, + "last_checked_iso": final_last_checked_iso, + "num_tools": final_num_tools + } +# --- Refresh Endpoint --- END + + # --- Add Edit Routes --- @app.get("/edit/{service_path:path}", response_class=HTMLResponse) @@ -1120,6 +1274,51 @@ async def edit_server_submit( return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) +# --- Helper function to broadcast single service update --- START +async def broadcast_single_service_update(service_path: str): + """Sends the current status, tool count, and last check time for a specific service.""" + global active_connections, SERVER_HEALTH_STATUS, SERVER_LAST_CHECK_TIME, REGISTERED_SERVERS + + if not active_connections: + return # No clients connected + + status = SERVER_HEALTH_STATUS.get(service_path, "unknown") + last_checked_dt = SERVER_LAST_CHECK_TIME.get(service_path) + last_checked_iso = last_checked_dt.isoformat() if last_checked_dt else None + num_tools = REGISTERED_SERVERS.get(service_path, {}).get("num_tools", 0) + + update_data = { + service_path: { + "status": status, + "last_checked_iso": last_checked_iso, + "num_tools": num_tools + } + } + message = json.dumps(update_data) + print(f"--- BROADCAST SINGLE: Sending update for {service_path}: {message}") + + # Use the same concurrent sending logic as in toggle + disconnected_clients = set() + current_connections = list(active_connections) # Copy to iterate safely + send_tasks = [] + for conn in current_connections: + send_tasks.append((conn, conn.send_text(message))) + + results = await asyncio.gather(*(task for _, task in send_tasks), return_exceptions=True) + + for i, result in enumerate(results): + conn, _ = send_tasks[i] + if isinstance(result, Exception): + print(f"Error sending single update to WebSocket client {conn.client}: {result}. Marking for removal.") + disconnected_clients.add(conn) + if disconnected_clients: + print(f"Removing {len(disconnected_clients)} disconnected clients after single update broadcast.") + for conn in disconnected_clients: + if conn in active_connections: + active_connections.remove(conn) +# --- Helper function to broadcast single service update --- END + + # --- WebSocket Endpoint --- @app.websocket("/ws/health_status") async def websocket_endpoint(websocket: WebSocket): diff --git a/registry/static/logo.png b/registry/static/logo.png index eba2f30..e2fd0ef 100644 Binary files a/registry/static/logo.png and b/registry/static/logo.png differ diff --git a/registry/static/mcp_gateway_horizontal_white_logo.png b/registry/static/mcp_gateway_horizontal_white_logo.png new file mode 100644 index 0000000..ae7bc06 Binary files /dev/null and b/registry/static/mcp_gateway_horizontal_white_logo.png differ diff --git a/registry/templates/index.html b/registry/templates/index.html index 9d48f45..18905a8 100644 --- a/registry/templates/index.html +++ b/registry/templates/index.html @@ -17,56 +17,68 @@ 100% { transform: rotate(360deg); } } - /* -- Theme Color Variables -- */ + /* -- Theme Color Variables -- Nova-Inspired Light Mode */ :root { - --bg-color: #f8f9fa; /* Light background */ - --text-color: #212529; /* Dark text */ + --bg-color: #f8f9fa; /* Very light gray/off-white */ + --text-color: #16191f; /* Very dark gray/near black */ --card-bg: #ffffff; /* White cards */ - --card-border: #dee2e6; /* Keep border color */ - --header-bg: #e9ecef; - --sidebar-bg: var(--bg-color); /* Match main background */ - --button-bg: #0d6efd; /* Primary button */ + --card-border: #e0e0e0; /* Lighter gray border */ + --header-bg: #ffffff; /* White header */ + --sidebar-bg: var(--bg-color); /* Sidebar matches main background */ + --accent-color: #7a00cc; /* Purple accent */ + --accent-light-bg: #f7f5ff; /* Very light purple background */ + --button-bg: var(--accent-color); /* Purple button */ --button-text: #ffffff; - --secondary-button-bg: #6c757d; /* Gray secondary */ - --secondary-button-text: #ffffff; - --link-color: #0d6efd; - --badge-bg: #6c757d; - --badge-text: #ffffff; - --official-badge-bg: #0d6efd; - --official-badge-text: #ffffff; + --secondary-button-bg: #e9ecef; /* Light gray secondary button */ + --secondary-button-text: var(--text-color); + --link-color: var(--accent-color); /* Purple links */ + --badge-bg: #e9ecef; /* Light gray badge */ + --badge-text: var(--text-color); + --official-badge-bg: var(--accent-light-bg); /* Light purple badge */ + --official-badge-text: var(--accent-color); /* Purple text on badge */ --input-bg: #ffffff; - --input-text: #212529; + --input-text: var(--text-color); --input-placeholder: #6c757d; - /* Theme Toggle Button Colors - Light Mode */ - --toggle-button-bg: #ffffff; /* White background */ - --toggle-button-text: #212529; /* Dark icon */ - --toggle-button-border: #dee2e6; /* Light border */ - /* Add more variables as needed */ - } - + --input-border: #ced4da; + --input-border-focus: var(--accent-color); /* Purple focus border */ + /* Theme Toggle (Keep consistent or adapt?) */ + --toggle-button-bg: var(--input-bg); + --toggle-button-text: var(--text-color); + --toggle-button-border: var(--input-border); + /* Sidebar Toggle (Keep consistent or adapt?) */ + --sidebar-toggle-bg-light: none; + --sidebar-toggle-text-light: var(--text-color); + --sidebar-toggle-border-light: none; + } + + /* Dark mode would also need updating to match */ html.dark-mode { - --bg-color: #212529; /* Dark background */ - --text-color: #f8f9fa; /* Light text */ - --card-bg: #343a40; /* Darker cards */ + /* TODO: Define Nova-inspired dark theme variables */ + --bg-color: #212529; /* Placeholder dark */ + --text-color: #f8f9fa; /* Placeholder light text */ + --card-bg: #343a40; /* Placeholder dark card */ --card-border: #495057; - --header-bg: #495057; - --sidebar-bg: var(--bg-color); /* Match main background */ - --button-bg: #0d6efd; /* Keep primary button */ + --header-bg: #212529; /* Dark header */ + --accent-color: #a040ff; /* Lighter purple for dark */ + --accent-light-bg: #3a304f; /* Darker purple bg */ + --button-bg: var(--accent-color); --button-text: #ffffff; - --secondary-button-bg: #6c757d; /* Keep secondary button gray */ - --secondary-button-text: #ffffff; - --link-color: #6ea8fe; /* Lighter blue link */ - --badge-bg: #adb5bd; - --badge-text: #212529; - --official-badge-bg: #6ea8fe; /* Lighter blue official */ - --official-badge-text: #212529; /* Dark text on light blue */ - --input-bg: #495057; - --input-text: #f8f9fa; + --secondary-button-bg: #495057; /* Dark gray secondary */ + --secondary-button-text: var(--text-color); + --link-color: var(--accent-color); + --badge-bg: #495057; + --badge-text: var(--text-color); + --official-badge-bg: var(--accent-light-bg); + --official-badge-text: var(--accent-color); + --input-bg: #343a40; + --input-text: var(--text-color); --input-placeholder: #adb5bd; - /* Theme Toggle Button Colors - Dark Mode */ - --toggle-button-bg: #495057; /* Dark background (matches header) */ - --toggle-button-text: #f8f9fa; /* Light icon */ - --toggle-button-border: #6c757d; /* Darker border */ + --input-border: #495057; + --input-border-focus: var(--accent-color); + /* Keep toggles simple for now */ + --toggle-button-bg: #495057; + --toggle-button-text: var(--text-color); + --toggle-button-border: #6c757d; } /* Apply variables */ @@ -79,6 +91,8 @@ .main-header { background-color: var(--header-bg); border-bottom: 1px solid var(--card-border); /* Add subtle border */ + /* Add box-shadow for slight elevation */ + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); /* Assuming header text color is inherited or set in style.css */ } .sidebar { @@ -106,19 +120,38 @@ border: 1px solid var(--card-border); color: var(--text-color); /* Ensure card text uses theme color */ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); /* Subtle shadow */ + border-radius: 12px; /* --- Add rounding --- */ } .service-card h2, .service-card .owner, .service-card .description { color: var(--text-color); /* Explicitly set text color */ } /* Example for a specific badge if needed */ - .badge { - background-color: var(--badge-bg); - color: var(--badge-text); + .badge, + .official-badge { + /* background-color set by specific class/variable */ + /* color set by specific class/variable */ + padding: 0.25em 0.75em; + border-radius: 1em; /* Pill shape */ + font-size: 0.8em; + font-weight: 600; + vertical-align: middle; + display: inline-block; /* Ensure padding applies */ } .edit-button { /* Assuming style.css defines button styles, override if needed */ /* background-color: var(--button-bg); */ /* color: var(--button-text); */ + background-color: var(--button-bg); + color: var(--button-text); + border: none; + padding: 8px 16px; + border-radius: 8px; /* --- Add rounding --- */ + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; + } + .edit-button:hover { + opacity: 0.9; /* Slight fade on hover */ } a { color: var(--link-color); @@ -130,22 +163,33 @@ .search-bar button { background-color: var(--secondary-button-bg); color: var(--secondary-button-text); - border: 1px solid var(--secondary-button-bg); + border: 1px solid var(--card-border); /* Use card border for light gray */ + padding: 8px 16px; + border-radius: 8px; /* --- Add rounding --- */ + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; } - .edit-button { /* Assuming this is primary */ - background-color: var(--button-bg); - color: var(--button-text); - /* Add padding/border etc. if needed from style.css */ + .logout-button:hover, + .search-bar button:hover { + background-color: var(--card-border); /* Darken slightly on hover */ } .search-bar input[type="search"] { background-color: var(--input-bg); color: var(--input-text); - border: 1px solid var(--card-border); + border: 1px solid var(--input-border); + border-radius: 8px; /* --- Add rounding --- */ + padding: 8px 12px; } .search-bar input[type="search"]::placeholder { color: var(--input-placeholder); opacity: 1; /* Override browser defaults */ } + .search-bar input[type="search"]:focus { + outline: none; + border-color: var(--input-border-focus); + box-shadow: 0 0 0 2px rgba(122, 0, 204, 0.2); /* Optional focus ring */ + } .official-badge { background-color: var(--official-badge-bg); color: var(--official-badge-text); @@ -216,6 +260,22 @@ display: flex; align-items: center; gap: 8px; + flex-shrink: 0; /* --- Prevent icons from shrinking --- */ + } + + /* Added overflow:hidden to card-header */ + .card-header { + display: flex; + align-items: center; + margin-bottom: 10px; + flex-wrap: nowrap; /* Explicitly prevent wrapping */ + } + + /* --- Add style for h2 within card-header --- */ + .card-header h2 { + flex-grow: 1; /* Allow title to take up available space */ + min-width: 0; /* Allow title to shrink below its content size */ + margin-right: 10px; /* Add some space between title and right items */ } /* New style for controls row */ @@ -344,12 +404,16 @@ /* z-index: 10; */ background: none; border: none; - color: var(--text-color); + color: #ffffff; /* Set color explicitly to white */ font-size: 1.5em; /* Adjust size */ cursor: pointer; /* Restore original padding/margin */ padding: 0 10px; margin-right: 10px; + /* Add transition */ + transition: opacity 0.2s ease; /* Only transition opacity */ + position: relative; /* Allow manual position adjustment */ + top: -2px; /* Nudge UP slightly */ } .sidebar-toggle-button:hover { opacity: 0.7; @@ -420,18 +484,19 @@ display: block; color: var(--text-color); text-decoration: none; - padding: 8px 10px; - border-radius: 4px; + padding: 10px 12px; /* Adjust padding */ + border-radius: 8px; /* --- Add rounding --- */ font-size: 0.95em; + font-weight: 500; /* Slightly less bold */ transition: background-color 0.2s ease, color 0.2s ease; } .sidebar-link:hover { - background-color: var(--card-bg); /* Subtle hover */ - color: var(--link-color); /* Highlight on hover */ + background-color: var(--secondary-button-bg); /* Use light gray hover */ + color: var(--text-color); /* Keep text dark on hover */ } .sidebar-link.active-filter { - background-color: rgba(13, 110, 253, 0.1); /* Light primary background for active */ - color: var(--link-color); + background-color: var(--accent-light-bg); /* Light purple background for active */ + color: var(--accent-color); /* Purple text */ font-weight: 600; } .sidebar-stats li { @@ -447,61 +512,87 @@ .sidebar-stats span:last-child { font-weight: 600; } + + /* Refresh button specific styles */ + .refresh-button { + background: none; + border: none; + padding: 0; + margin: 0 0 0 5px; + cursor: pointer; + vertical-align: middle; + color: inherit; + font-size: 1em; /* Match icon span */ + } + .refresh-button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + /* --- Logo Dark Mode Styling --- */ + .main-header .logo img { + transition: filter 0.3s ease; /* Smooth transition for filter */ + height: 3.5rem; /* Match toggle button effective size using root em */ + width: auto; /* Maintain aspect ratio */ + } + html.dark-mode .main-header .logo img { + /* filter: grayscale(100%) brightness(0) invert(100%) brightness(1.5); <<< Old filter */ + filter: grayscale(100%) brightness(0) invert(100%) brightness(1.5); /* Restore: Force to white and brighten */ + } + + /* --- Style the logo container --- */ + .logo { + display: flex; /* Make logo container a flexbox */ + align-items: center; /* Vertically center items inside logo */ + gap: 8px; /* Space between logo image and text */ + } @@ -947,7 +1356,7 @@ {# Button Moved Back To Header #} @@ -1028,7 +1437,18 @@

MCP Servers

{{ service.display_name }}

official - ☁️ 💻 🔄 + + ☁️ 💻 + {# Wrap the refresh icon in a button #} + +
@@ -1067,10 +1487,11 @@

{{ service.display_name }}

{% set display_text = 'unknown' %} {% endif %} - {# Generate IDs #} - {% set badge_id = 'status-badge-' + service.path | replace('/', '_') | replace(':', '_') %} - {% set spinner_id = 'spinner-for-' + service.path | replace('/', '_') | replace(':', '_') %} - {% set last_checked_id = 'last-checked-' + service.path | replace('/', '_') | replace(':', '_') %} + {# Generate IDs - REMOVE leading slash BEFORE replacing #} + {% set safe_path = service.path | replace('/', '', 1) | replace('/', '_') | replace(':', '_') %} + {% set badge_id = 'status-badge-' + safe_path %} + {% set spinner_id = 'spinner-for-' + safe_path %} + {% set last_checked_id = 'last-checked-' + safe_path %} {# Render badge with determined initial text/class #}
@@ -1081,23 +1502,33 @@

{{ service.display_name }}

Modify + {# Add ID to form if needed, but action URL is sufficient #}
- {{ 'Enabled' if service.is_enabled else 'Disabled' }} + {# Add ID to label span #} + + {{ 'Enabled' if service.is_enabled else 'Disabled' }} +
+