diff --git a/.github/workflows/sql-sanitizer.yml b/.github/workflows/sql-sanitizer.yml index 7a23d9a5c2..49968185d2 100644 --- a/.github/workflows/sql-sanitizer.yml +++ b/.github/workflows/sql-sanitizer.yml @@ -154,7 +154,7 @@ jobs: - name: Verify plugins is responding run: | - curl -sf -X GET http://localhost:4444/admin/plugins/stats \ + curl -sf -X GET http://localhost:4444/v1/admin/plugins/stats \ -H "Authorization: Bearer ${{ steps.generate_jwt.outputs.token }}" \ -H "Content-Type: application/json" | yq @@ -202,7 +202,7 @@ jobs: # Register gateway echo "Registering fast_time gateway..." - GATEWAY_RESPONSE=$(curl -s -X POST http://localhost:4444/gateways \ + GATEWAY_RESPONSE=$(curl -s -X POST http://localhost:4444/v1/gateways \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -215,7 +215,7 @@ jobs: # Extract gateway ID (handle existing gateway) if echo "$GATEWAY_RESPONSE" | grep -q "already exists"; then echo "Gateway already exists, fetching existing ID..." - GATEWAYS=$(curl -s http://localhost:4444/gateways \ + GATEWAYS=$(curl -s http://localhost:4444/v1/gateways \ -H "Authorization: Bearer $TOKEN") GATEWAY_ID=$(echo "$GATEWAYS" | jq -r '.[] | select(.name=="fast_time") | .id') else @@ -226,7 +226,7 @@ jobs: # Wait for tools to sync echo "Waiting for tools to sync..." for i in {1..30}; do - TOOLS=$(curl -s http://localhost:4444/tools \ + TOOLS=$(curl -s http://localhost:4444/v1/tools \ -H "Authorization: Bearer $TOKEN") TOOL_COUNT=$(echo "$TOOLS" | jq --arg gid "$GATEWAY_ID" '[.[] | select(.gatewayId==$gid)] | length') if [ "$TOOL_COUNT" -gt 0 ]; then @@ -238,7 +238,7 @@ jobs: sleep 2 done - TOOLS=$(curl -s http://localhost:4444/tools \ + TOOLS=$(curl -s http://localhost:4444/v1/tools \ -H "Authorization: Bearer $TOKEN") TOOL_IDS=$(echo "$TOOLS" | jq --arg gid "$GATEWAY_ID" -c '[.[] | select(.gatewayId==$gid) | .id]') echo "Tool IDs: $TOOL_IDS" @@ -250,7 +250,7 @@ jobs: # Create virtual server echo "Creating virtual server..." - SERVER_RESPONSE=$(curl -s -X POST http://localhost:4444/servers \ + SERVER_RESPONSE=$(curl -s -X POST http://localhost:4444/v1/servers \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{ diff --git a/.secrets.baseline b/.secrets.baseline index 5ffaf61b3d..ebd64e1dfa 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-04-27T19:52:22Z", + "generated_at": "2026-04-28T08:13:50Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -4844,7 +4844,7 @@ "hashed_secret": "c377074d6473f35a91001981355da793dc808ffd", "is_secret": false, "is_verified": false, - "line_number": 753, + "line_number": 751, "type": "Hex High Entropy String", "verified_result": null } @@ -4862,7 +4862,7 @@ "hashed_secret": "ff37a98a9963d347e9749a5c1b3936a4a245a6ff", "is_secret": false, "is_verified": false, - "line_number": 2420, + "line_number": 2421, "type": "Secret Keyword", "verified_result": null } @@ -9262,7 +9262,7 @@ "hashed_secret": "6745fa570d36be08400efa1cbc2f057bb001290e", "is_secret": false, "is_verified": false, - "line_number": 2523, + "line_number": 2554, "type": "Hex High Entropy String", "verified_result": null }, @@ -9270,7 +9270,7 @@ "hashed_secret": "4f13f134744a2fadbbe2d624687246347d12fa63", "is_secret": false, "is_verified": false, - "line_number": 2811, + "line_number": 2842, "type": "Hex High Entropy String", "verified_result": null } diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000000..89285cdef8 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,161 @@ +# Migration Guide — API v1 Prefix + +**PR:** [#4403 feat: API endpoint is served under the `/v1` prefix](https://github.com/IBM/mcp-context-forge/pull/4403) +**Branch:** `API_v1` → `main` +**Type:** Breaking change + +--- + +## Overview + +This release introduces API versioning for ContextForge. Most resource management and business-logic endpoints are now served under the `/v1` URL prefix. A new `mcpgateway/api/v1/__init__.py` module centralises router assembly via a `build_v1_router()` factory, keeping `main.py` clean and making future version additions (`/v2`, etc.) straightforward. + +Protocol-level routes, infrastructure-compatible routes, and some maintenance APIs intentionally remain unversioned at the root. These either follow external standards, must stay stable for infrastructure compatibility, or are mounted separately from the main versioned router. + +--- + +## Breaking Change + +Most previously unversioned resource-management paths are now served under `/v1`. Clients must update their base URLs accordingly. + +**Affected paths (previously at root, now under `/v1`):** + +`/tools`, `/resources`, `/prompts`, `/gateways`, `/servers`, `/roots`, `/metrics`, `/tags`, `/a2a`, `/admin`, `/auth`, `/teams`, `/tokens`, `/rbac`, `/observability`, `/cancellation`, `/toolops`, `/reverse-proxy`, `/export`, `/import` + +**Important exception:** not every metrics-related route becomes `/v1/metrics/**`. Metrics maintenance endpoints remain available at `/api/metrics/**`, and may also be exposed under `/v1/api/metrics/**` when enabled. + +--- + +## Endpoint Classification + +### Versioned — now served under `/v1` + +| Endpoint Group | Old Path | New Path | Feature Flag | +|---|---|---|---| +| MCP Protocol | `/protocol/**` | `/v1/protocol/**` | always-on | +| Tools | `/tools/**` | `/v1/tools/**` | always-on | +| Resources | `/resources/**` | `/v1/resources/**` | always-on | +| Prompts | `/prompts/**` | `/v1/prompts/**` | always-on | +| Gateways | `/gateways/**` | `/v1/gateways/**` | always-on | +| Roots | `/roots/**` | `/v1/roots/**` | always-on | +| Servers | `/servers/**` | `/v1/servers/**` | always-on | +| Metrics | `/metrics/**` | `/v1/metrics/**` | always-on | +| Tags | `/tags/**` | `/v1/tags/**` | always-on | +| Export / Import | `/export`, `/import` | `/v1/export`, `/v1/import` | always-on | +| Tool Plugin Bindings | `/tools/plugin_bindings/**` | `/v1/tools/plugin_bindings/**` | always-on | +| Admin UI & API | `/admin/**` | `/v1/admin/**` | `MCPGATEWAY_ADMIN_API_ENABLED` | +| Runtime Admin | `/admin/runtime/**` | `/v1/admin/runtime/**` | `MCPGATEWAY_ADMIN_API_ENABLED` | +| LLM Admin | `/admin/llm/**` | `/v1/admin/llm/**` | `MCPGATEWAY_LLMCHAT_ENABLED` | +| A2A | `/a2a/**` | `/v1/a2a/**` | `MCPGATEWAY_A2A_ENABLED` | +| Observability | `/observability/**` | `/v1/observability/**` | `OBSERVABILITY_ENABLED` | +| Reverse Proxy | `/reverse-proxy/**` | `/v1/reverse-proxy/**` | `MCPGATEWAY_REVERSE_PROXY_ENABLED` | +| Tool Cancellation | `/cancellation/**` | `/v1/cancellation/**` | `MCPGATEWAY_TOOL_CANCELLATION_ENABLED` | +| ToolOps | `/toolops/**` | `/v1/toolops/**` | `TOOLOPS_ENABLED` | +| Authentication | `/auth/**` | `/v1/auth/**` | `EMAIL_AUTH_ENABLED` | +| Email Authentication | `/auth/email/**` | `/v1/auth/email/**` | `EMAIL_AUTH_ENABLED` | +| SSO Authentication | `/auth/sso/**` | `/v1/auth/sso/**` | `EMAIL_AUTH_ENABLED` + `SSO_ENABLED` | +| Teams | `/teams/**` | `/v1/teams/**` | `EMAIL_AUTH_ENABLED` | +| JWT Token Catalog | `/tokens/**` | `/v1/tokens/**` | `EMAIL_AUTH_ENABLED` | +| RBAC | `/rbac/**` | `/v1/rbac/**` | `EMAIL_AUTH_ENABLED` | +| LLM Chat | `/llmchat/**` | `/v1/llmchat/**` | `MCPGATEWAY_LLMCHAT_ENABLED` | +| LLM Config | `/llm/**` | `/v1/llm/**` | `MCPGATEWAY_LLMCHAT_ENABLED` | +| Metrics maintenance | `/api/metrics/**` | `/v1/api/metrics/**` | `metrics_cleanup_enabled` or `metrics_rollup_enabled` | + +### Not versioned — remain at root (unchanged) + +| Endpoint | Path | Reason | +|---|---|---| +| Health probes | `/health`, `/ready`, `/health/security` | Infrastructure / liveness; must remain stable | +| MCP Streamable HTTP transport | `/mcp` | MCP protocol spec — path is fixed by the spec | +| Internal MCP transport bridge | `/_internal/mcp/transport` | Internal trusted bridge; not a public API | +| OAuth 2.0 | `/oauth/**` | Standard protocol location (RFC 6749) | +| Well-known URIs | `/.well-known/**` | RFC 8615 / RFC 9116 / RFC 9728 — path is standardised | +| Per-server well-known | `/servers/{id}/.well-known/**` | RFC standard path; must not be prefixed | +| Version / Diagnostics | `/version` | Diagnostic utility, not a resource API | +| Static assets | `/static/**` | UI asset serving | +| Root redirect | `/` | Entry point / UI redirect | +| Favicon | `/favicon.ico` | Browser convention | +| Log Search | `/api/logs/**` | Internal structured-logging query interface | +| Metrics maintenance | `/api/metrics/**` | Operational maintenance API remains mounted at root | +| LLM Proxy | `{LLM_API_PREFIX}` (default `/v1`) | Prefix is runtime-configurable via `LLM_API_PREFIX`; it is mounted directly on `app`, not nested inside the gateway's own `/v1` router, even when the configured prefix is also `/v1` | + +--- + +## Migration Steps + +### 1. Update all client base URLs + +Replace any hardcoded unversioned paths in your client code, configuration files, CI scripts, or SDK wrappers: + +```diff +- GET /tools ++ GET /v1/tools + +- POST /gateways ++ POST /v1/gateways + +- GET /servers ++ GET /v1/servers + +- GET /admin/ ++ GET /v1/admin/ + +- POST /auth/login ++ POST /v1/auth/login +``` + +### 2. Update environment variables or base-URL constants + +If you configure a base URL such as `MCPGATEWAY_BASE_URL=https://example.com`, ensure downstream consumers append `/v1/` before any resource segment: + +```diff +- https://example.com/tools/list ++ https://example.com/v1/tools/list +``` + +### 3. Update API clients / SDKs + +Any SDK or HTTP client wrapper that prepends a path prefix must be updated to use `/v1`: + +```python +# Before +client = MCPGatewayClient(base_url="https://example.com") +client.get("/tools") + +# After +client = MCPGatewayClient(base_url="https://example.com/v1") +client.get("/tools") +# or +client.get("/v1/tools") +``` + +### 4. Update reverse-proxy / load-balancer rules + +If you have Nginx, HAProxy, or cloud load-balancer rules that route on path prefixes, add `/v1` to resource-path matchers. Health, OAuth, well-known, `/mcp`, `/api/logs/**`, and `/api/metrics/**` paths do not change. + +### 5. Migrate test paths (automated helper) + +A utility script is provided to migrate Python test file path references automatically: + +```bash +python scripts/update_test_paths.py +``` + +This script rewrites path strings such as `"/tools"` → `"/v1/tools"` across Python test files under `tests/` while skipping paths that are intentionally unversioned (`.well-known`, `/oauth`, `/mcp`, `/health`, `/api/logs`, `/api/metrics`, etc.). + +--- + +## Code Changes (Summary) + +| File | Change | +|---|---| +| `mcpgateway/api/v1/__init__.py` | **New** — `build_v1_router()` factory that assembles all versioned routers under `/v1`; feature-flagged routers are conditionally included here | +| `mcpgateway/api/__init__.py` | **New** — namespace package | +| `mcpgateway/main.py` | Router registration refactored; versioned routers delegated to `build_v1_router()`; unversioned routers mounted directly on `app` | +| `mcpgateway/admin.py` | Admin redirect paths updated to `/v1/admin/*` | +| `mcpgateway/middleware/path_filter.py` | Path references updated to `/v1` prefixed patterns | +| `mcpgateway/middleware/rbac.py` | Path references updated to `/v1` prefixed patterns | +| `mcpgateway/middleware/token_scoping.py` | Path references updated; `/v1` prefix stripped for internal pattern matching | +| `mcpgateway/routers/metrics_maintenance.py` | Metrics maintenance endpoints remain mounted at `/api/metrics/**` and are also conditionally included under `/v1/api/metrics/**` | +| `tests/` | Python test paths are migrated toward `/v1` prefixed resource endpoints | +| `scripts/update_test_paths.py` | **New** — utility script for migrating Python test path references under `tests/` | diff --git a/charts/mcp-stack/files/ocp/locustfile_mcp_protocol.py b/charts/mcp-stack/files/ocp/locustfile_mcp_protocol.py index 6ee88f4f2f..a122f26106 100644 --- a/charts/mcp-stack/files/ocp/locustfile_mcp_protocol.py +++ b/charts/mcp-stack/files/ocp/locustfile_mcp_protocol.py @@ -960,10 +960,10 @@ def rpc_list_tools(self): """tools/list via /rpc (REST JSON-RPC).""" payload = _jsonrpc("tools/list", {}) with self.client.post( - "/rpc", + "/v1/rpc", data=json.dumps(payload), headers=self._auth_headers, - name="REST /rpc tools/list", + name="REST /v1/rpc tools/list", catch_response=True, ) as resp: if resp.status_code == 200: @@ -987,10 +987,10 @@ def rpc_call_tool(self): args = {} payload = _jsonrpc("tools/call", {"name": tool, "arguments": args}) with self.client.post( - "/rpc", + "/v1/rpc", data=json.dumps(payload), headers=self._auth_headers, - name="REST /rpc tools/call", + name="REST /v1/rpc tools/call", catch_response=True, ) as resp: if resp.status_code == 200: @@ -1001,11 +1001,11 @@ def rpc_call_tool(self): @task(5) @tag("baseline", "rest", "list") def rest_list_tools(self): - """/tools via REST API.""" + """/v1/tools via REST API.""" with self.client.get( - "/tools", + "/v1/tools", headers=self._auth_headers, - name="REST /tools", + name="REST /v1/tools", catch_response=True, ) as resp: if resp.status_code == 200: diff --git a/charts/mcp-stack/templates/registration-jobs.yaml b/charts/mcp-stack/templates/registration-jobs.yaml index 44e6fc9212..94970c845b 100644 --- a/charts/mcp-stack/templates/registration-jobs.yaml +++ b/charts/mcp-stack/templates/registration-jobs.yaml @@ -94,37 +94,37 @@ spec: return {} return json.loads(raw) - gateways = api_request("GET", "/gateways") + gateways = api_request("GET", "/v1/gateways") for gateway in gateways: if gateway.get("name") == gateway_name: try: - api_request("DELETE", f"/gateways/{gateway['id']}") + api_request("DELETE", f"/v1/gateways/{gateway['id']}") except Exception: pass - created = api_request("POST", "/gateways", {"name": gateway_name, "url": gateway_url, "transport": transport}) + created = api_request("POST", "/v1/gateways", {"name": gateway_name, "url": gateway_url, "transport": transport}) gateway_id = created.get("id") if create_virtual_server and gateway_id: for _ in range(30): time.sleep(1) try: - tools = api_request("GET", "/tools") + tools = api_request("GET", "/v1/tools") if any(tool.get("gatewayId") == gateway_id for tool in tools): break except Exception: pass - tools = api_request("GET", "/tools") - resources = api_request("GET", "/resources") - prompts = api_request("GET", "/prompts") + tools = api_request("GET", "/v1/tools") + resources = api_request("GET", "/v1/resources") + prompts = api_request("GET", "/v1/prompts") tool_ids = [tool["id"] for tool in tools if tool.get("gatewayId") == gateway_id] resource_ids = [resource["id"] for resource in resources] prompt_ids = [prompt["id"] for prompt in prompts] try: - api_request("DELETE", f"/servers/{virtual_server_id}") + api_request("DELETE", f"/v1/servers/{virtual_server_id}") except Exception: pass @@ -138,7 +138,7 @@ spec: "associated_prompts": prompt_ids, } } - api_request("POST", "/servers", payload) + api_request("POST", "/v1/servers", payload) print("fast_time registration complete") PY @@ -227,15 +227,15 @@ spec: raw = resp.read().decode("utf-8") return json.loads(raw) if raw else {} - gateways = api_request("GET", "/gateways") + gateways = api_request("GET", "/v1/gateways") for gateway in gateways: if gateway.get("name") == gateway_name: try: - api_request("DELETE", f"/gateways/{gateway['id']}") + api_request("DELETE", f"/v1/gateways/{gateway['id']}") except Exception: pass - api_request("POST", "/gateways", {"name": gateway_name, "url": gateway_url, "transport": transport}) + api_request("POST", "/v1/gateways", {"name": gateway_name, "url": gateway_url, "transport": transport}) print("fast_test registration complete") PY @@ -321,14 +321,14 @@ spec: raw = resp.read().decode("utf-8") return json.loads(raw) if raw else {} - agents = api_request("GET", "/a2a") + agents = api_request("GET", "/v1/a2a") if isinstance(agents, dict): agents = agents.get("agents", agents.get("items", [])) for agent in agents: if agent.get("name") == agent_name and agent.get("id"): try: - api_request("DELETE", f"/a2a/{agent['id']}") + api_request("DELETE", f"/v1/a2a/{agent['id']}") except Exception: pass @@ -345,7 +345,7 @@ spec: "visibility": "{{ .Values.testing.a2a.register.visibility }}", } - api_request("POST", "/a2a", payload) + api_request("POST", "/v1/a2a", payload) print("a2a echo registration complete") PY @@ -440,7 +440,7 @@ spec: "transport": transport, } try: - api_request("POST", "/gateways", payload) + api_request("POST", "/v1/gateways", payload) except urllib.error.HTTPError as exc: if exc.code != 409: raise diff --git a/crates/request_logging_masking_native_extension/python/request_logging_masking_native_extension/__init__.pyi b/crates/request_logging_masking_native_extension/python/request_logging_masking_native_extension/__init__.pyi index a594f1727b..8cd57cd122 100644 --- a/crates/request_logging_masking_native_extension/python/request_logging_masking_native_extension/__init__.pyi +++ b/crates/request_logging_masking_native_extension/python/request_logging_masking_native_extension/__init__.pyi @@ -3,7 +3,6 @@ import builtins import typing - __all__ = [ "mask_sensitive_data", "mask_sensitive_headers", @@ -11,5 +10,7 @@ __all__ = [ ] def mask_sensitive_data(data: typing.Any, max_depth: typing.Optional[builtins.int]) -> typing.Any: ... + def mask_sensitive_headers(headers: typing.Any) -> typing.Any: ... + def mask_sensitive_json_bytes(payload: typing.Sequence[builtins.int], max_depth: typing.Optional[builtins.int]) -> typing.Any: ... diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index b296cdfa3f..6ad52379ff 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -831,7 +831,7 @@ def _build_admin_redirect(root_path: str, fragment: str, *, error: Optional[str] pass query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) if params else "" sep = "/?" if query else "" - return f"{root_path}/admin{sep}{query}#{fragment}" + return f"{root_path}/v1/admin{sep}{query}#{fragment}" def get_client_ip(request: Request) -> str: @@ -1420,7 +1420,7 @@ def _admin_cookie_path(request: Request) -> str: Admin cookie path scoped under the deployed app root. """ root_path = _resolve_root_path(request) - return f"{root_path}/admin" if root_path else "/admin" + return f"{root_path}/v1/admin" if root_path else "/v1/admin" def _normalize_origin_parts(scheme: str, netloc: str) -> tuple[str, str, int]: @@ -2713,7 +2713,7 @@ async def admin_servers_partial_html( # Use unified pagination function root_path = _resolve_root_path(request) - base_url = f"{root_path}/admin/servers/partial" + base_url = f"{root_path}/v1/admin/servers/partial" paginated_result = await paginate_query( db=db, query=query, @@ -4041,7 +4041,7 @@ def _to_dict_and_filter(raw_list): cookie_action = ui_visibility_config.get("cookie_action") if cookie_action: scope_root_path = _resolve_root_path(request) - ui_cookie_path = f"{scope_root_path}/admin" if scope_root_path else "/admin" + ui_cookie_path = f"{scope_root_path}/v1/admin" if scope_root_path else "/v1/admin" use_secure = (settings.environment == "production") or settings.secure_cookies samesite = settings.cookie_samesite if cookie_action == "set": @@ -4105,7 +4105,7 @@ async def admin_login_page(request: Request) -> Response: # Check if email auth is enabled if not getattr(settings, "email_auth_enabled", False): root_path = _resolve_root_path(request) - return RedirectResponse(url=f"{root_path}/admin", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin", status_code=303) root_path = settings.app_root_path @@ -4124,7 +4124,7 @@ async def admin_login_page(request: Request) -> Response: # creating an infinite redirect loop. is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False) if is_admin: - return RedirectResponse(url=f"{root_path}/admin", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin", status_code=303) except (HTTPException, jwt.PyJWTError): # Token is invalid or expired - mark for clearing to prevent redirect loop clear_invalid_cookies = True @@ -4206,7 +4206,7 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) - root_path = _resolve_root_path(request) if not getattr(settings, "email_auth_enabled", False): - return RedirectResponse(url=f"{root_path}/admin", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin", status_code=303) try: form = await request.form() @@ -4219,7 +4219,7 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) - params = "error=missing_fields" if email: params += f"&email={urllib.parse.quote(email)}" - return RedirectResponse(url=f"{root_path}/admin/login?{params}", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?{params}", status_code=303) # Authenticate using the email auth service auth_service = EmailAuthService(db) @@ -4232,11 +4232,11 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) - if not user: LOGGER.warning(f"Authentication failed for {email} - user is None") - return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303) if settings.sso_enabled and settings.sso_preserve_admin_auth and not bool(getattr(user, "is_admin", False)): LOGGER.info("Blocking local password login for non-admin user %s because SSO_PRESERVE_ADMIN_AUTH is enabled", email) - return RedirectResponse(url=f"{root_path}/admin/login?error=sso_required&email={urllib.parse.quote(email)}", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?error=sso_required&email={urllib.parse.quote(email)}", status_code=303) # Password change enforcement respects master switch and toggles needs_password_change = False @@ -4282,14 +4282,14 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) - token, _ = await create_access_token(user) # Create redirect response to password change page - response = RedirectResponse(url=f"{root_path}/admin/change-password-required", status_code=303) + response = RedirectResponse(url=f"{root_path}/v1/admin/change-password-required", status_code=303) # Set JWT token as secure cookie for the password change process try: set_auth_cookie(response, token, remember_me=False) except CookieTooLargeError: return RedirectResponse( - url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}", + url=f"{root_path}/v1/admin/login?error=token_too_large&email={urllib.parse.quote(email)}", status_code=303, ) @@ -4300,14 +4300,14 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) - token, _ = await create_access_token(user) # expires_seconds not needed here # Create redirect response - response = RedirectResponse(url=f"{root_path}/admin", status_code=303) + response = RedirectResponse(url=f"{root_path}/v1/admin", status_code=303) # Set JWT token as secure cookie try: set_auth_cookie(response, token, remember_me=False) except CookieTooLargeError: return RedirectResponse( - url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}", + url=f"{root_path}/v1/admin/login?error=token_too_large&email={urllib.parse.quote(email)}", status_code=303, ) @@ -4321,11 +4321,11 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) - if settings.secure_cookies and settings.environment == "development": LOGGER.warning("Login failed - set SECURE_COOKIES to false in config for HTTP development") - return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303) except Exception as e: LOGGER.error(f"Login handler error: {e}") - return RedirectResponse(url=f"{root_path}/admin/login?error=server_error", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?error=server_error", status_code=303) @admin_router.get("/forgot-password") @@ -4340,7 +4340,7 @@ async def admin_forgot_password_page(request: Request) -> Response: """ root_path = settings.app_root_path if not getattr(settings, "email_auth_enabled", False): - return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login", status_code=303) response = request.app.state.templates.TemplateResponse( request, "forgot-password.html", @@ -4369,25 +4369,25 @@ async def admin_forgot_password_handler(request: Request, db: Session = Depends( """ root_path = _resolve_root_path(request) if not getattr(settings, "email_auth_enabled", False): - return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login", status_code=303) if not getattr(settings, "password_reset_enabled", True): - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=password_reset_disabled", status_code=303) try: form = await request.form() email_val = form.get("email") email = str(email_val).strip() if email_val else "" if not email: - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=missing_email", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=missing_email", status_code=303) auth_service = EmailAuthService(db) result = await auth_service.request_password_reset(email=email, ip_address=get_client_ip(request), user_agent=get_user_agent(request)) if result.rate_limited: - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=rate_limited", status_code=303) - return RedirectResponse(url=f"{root_path}/admin/login?notice=reset_email_sent", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=rate_limited", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?notice=reset_email_sent", status_code=303) except Exception as exc: LOGGER.warning("Forgot-password request failed: %s", exc) - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=server_error", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=server_error", status_code=303) @admin_router.get("/reset-password/{token}") @@ -4404,9 +4404,9 @@ async def admin_reset_password_page(token: str, request: Request, db: Session = """ root_path = settings.app_root_path if not getattr(settings, "email_auth_enabled", False): - return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login", status_code=303) if not getattr(settings, "password_reset_enabled", True): - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=password_reset_disabled", status_code=303) auth_service = EmailAuthService(db) token_valid = False @@ -4449,34 +4449,34 @@ async def admin_reset_password_handler(token: str, request: Request, db: Session """ root_path = _resolve_root_path(request) if not getattr(settings, "email_auth_enabled", False): - return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login", status_code=303) if not getattr(settings, "password_reset_enabled", True): - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=password_reset_disabled", status_code=303) try: form = await request.form() password = str(form.get("password", "")) confirm_password = str(form.get("confirm_password", "")) if not password or not confirm_password: - return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=missing_fields", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/reset-password/{urllib.parse.quote(token)}?error=missing_fields", status_code=303) if password != confirm_password: - return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=password_mismatch", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/reset-password/{urllib.parse.quote(token)}?error=password_mismatch", status_code=303) auth_service = EmailAuthService(db) await auth_service.reset_password_with_token(token=token, new_password=password, ip_address=get_client_ip(request), user_agent=get_user_agent(request)) - return RedirectResponse(url=f"{root_path}/admin/login?notice=password_reset_success", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?notice=password_reset_success", status_code=303) except PasswordValidationError as exc: - return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error={urllib.parse.quote(str(exc))}", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/reset-password/{urllib.parse.quote(token)}?error={urllib.parse.quote(str(exc))}", status_code=303) except AuthenticationError as exc: msg = str(exc).lower() if "expired" in msg: - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_expired", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=reset_link_expired", status_code=303) if "used" in msg: - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_used", status_code=303) - return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_invalid", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=reset_link_used", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/forgot-password?error=reset_link_invalid", status_code=303) except Exception as exc: LOGGER.warning("Password reset failed: %s", exc) - return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=server_error", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/reset-password/{urllib.parse.quote(token)}?error=server_error", status_code=303) async def _admin_logout(request: Request) -> Response: @@ -4485,7 +4485,7 @@ async def _admin_logout(request: Request) -> Response: Supports three logout scenarios: - POST: User-initiated logout from the UI (redirects to login page or Keycloak logout) - - GET with browser headers: Browser navigation to /admin/logout (redirects to login page) + - GET with browser headers: Browser navigation to /v1/admin/logout (redirects to login page) - GET without browser headers: OIDC front-channel logout callback from IdP (returns 200 OK) For OIDC front-channel logout (per OpenID Connect Front-Channel Logout 1.0 spec), @@ -4567,7 +4567,7 @@ def _build_absolute_login_url(root_path: str) -> Optional[str]: Returns: Optional[str]: Absolute login URL when resolvable, otherwise ``None``. """ - login_path = f"{root_path}/admin/login" + login_path = f"{root_path}/v1/admin/login" request_url = getattr(request, "url", None) scheme = getattr(request_url, "scheme", None) if request_url is not None else None netloc = getattr(request_url, "netloc", None) if request_url is not None else None @@ -4679,7 +4679,7 @@ def _build_keycloak_logout_url(root_path: str) -> Optional[str]: if is_browser_request: # Browser navigation - redirect to login (cookies cleared below) - response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303) + response = RedirectResponse(url=f"{root_path}/v1/admin/login", status_code=303) else: # OIDC front-channel logout from IdP - return 200 OK per OIDC spec # Reference: OpenID Connect Front-Channel Logout 1.0 @@ -4688,7 +4688,7 @@ def _build_keycloak_logout_url(root_path: str) -> Optional[str]: response = Response(content="Logged out", status_code=200) else: # POST requests (user-initiated) - redirect to login (cookies cleared below) - response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303) + response = RedirectResponse(url=f"{root_path}/v1/admin/login", status_code=303) auth_provider = await _extract_auth_provider_from_jwt_cookie() if auth_provider == "keycloak": @@ -4773,7 +4773,7 @@ async def change_password_required_page(request: Request) -> HTMLResponse: """ if not getattr(settings, "email_auth_enabled", False): root_path = _resolve_root_path(request) - return RedirectResponse(url=f"{root_path}/admin", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin", status_code=303) # Get root path for template root_path = _resolve_root_path(request) @@ -4843,7 +4843,7 @@ async def change_password_required_handler(request: Request, db: Session = Depen root_path = _resolve_root_path(request) if not getattr(settings, "email_auth_enabled", False): - return RedirectResponse(url=f"{root_path}/admin", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin", status_code=303) try: form = await request.form() @@ -4856,10 +4856,10 @@ async def change_password_required_handler(request: Request, db: Session = Depen confirm_password = confirm_password_val if isinstance(confirm_password_val, str) else None if not all([current_password, new_password, confirm_password]): - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=missing_fields", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=missing_fields", status_code=303) if new_password != confirm_password: - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=mismatch", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=mismatch", status_code=303) # Get user from JWT token in cookie try: @@ -4873,7 +4873,7 @@ async def change_password_required_handler(request: Request, db: Session = Depen current_user = None if not current_user: - return RedirectResponse(url=f"{root_path}/admin/login?error=session_expired", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?error=session_expired", status_code=303) # Authenticate using the email auth service auth_service = EmailAuthService(db) @@ -4902,44 +4902,44 @@ async def change_password_required_handler(request: Request, db: Session = Depen current_user = db.query(EmailUser).filter(EmailUser.email == user_email).first() if current_user is None: LOGGER.error(f"User {user_email} not found after successful password change - possible race condition") - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=server_error", status_code=303) except Exception as e: # Return early to avoid creating token with empty team claims LOGGER.error(f"Failed to re-attach user {user_email} to session: {e} - password changed but token creation skipped") - return RedirectResponse(url=f"{root_path}/admin/login?message=password_changed", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/login?message=password_changed", status_code=303) # Create new JWT token token, _ = await create_access_token(current_user) # Create redirect response to admin panel - response = RedirectResponse(url=f"{root_path}/admin", status_code=303) + response = RedirectResponse(url=f"{root_path}/v1/admin", status_code=303) # Update JWT token cookie try: set_auth_cookie(response, token, remember_me=False) except CookieTooLargeError: return RedirectResponse( - url=f"{root_path}/admin/login?error=token_too_large", + url=f"{root_path}/v1/admin/login?error=token_too_large", status_code=303, ) LOGGER.info(f"User {current_user.email} successfully changed their expired password") return response - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=change_failed", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=change_failed", status_code=303) except AuthenticationError: - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=invalid_password", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=invalid_password", status_code=303) except PasswordValidationError as e: LOGGER.warning(f"Password validation failed for {current_user.email}: {e}") - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=weak_password", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=weak_password", status_code=303) except Exception as e: LOGGER.error(f"Password change failed for {current_user.email}: {e}", exc_info=True) - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=server_error", status_code=303) except Exception as e: LOGGER.error(f"Password change handler error: {e}") - return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/change-password-required?error=server_error", status_code=303) # ============================================================================ # @@ -5308,7 +5308,7 @@ async def admin_teams_partial_html( root_path = _resolve_root_path(request) # Base URL for pagination links - preserve search query and relationship filter - base_url = f"{root_path}/admin/teams/partial" + base_url = f"{root_path}/v1/admin/teams/partial" query_parts = [] if q: query_parts.append(f"q={urllib.parse.quote(q, safe='')}") @@ -5546,7 +5546,7 @@ async def admin_list_teams( # Call list_teams logic (similar to admin_teams_partial_html but inline) if current_user.is_admin: # Default first page - base_url = f"{root_path}/admin/teams/partial" + base_url = f"{root_path}/v1/admin/teams/partial" if q: base_url += f"?q={urllib.parse.quote(q, safe='')}" @@ -5794,7 +5794,7 @@ async def admin_view_team_members(
@@ -5815,7 +5815,7 @@ async def admin_view_team_members( id="team-members-container-{team.id}" class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700" data-per-page="{per_page}" - hx-get="{root_path}/admin/teams/{team.id}/members/partial?page={page}&per_page={per_page}" + hx-get="{root_path}/v1/admin/teams/{team.id}/members/partial?page={page}&per_page={per_page}" hx-trigger="load delay:100ms" hx-target="this" hx-swap="innerHTML" @@ -5950,7 +5950,7 @@ async def admin_add_team_members_view(
- +
@@ -5958,7 +5958,7 @@ async def admin_add_team_members_view( type="text" id="user-search-{team.id}" data-team-id="{team.id}" - data-search-url="{root_path}/admin/users/search" + data-search-url="{root_path}/v1/admin/users/search" data-search-limit="10" placeholder="Search by name or email..." class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 text-gray-900 dark:text-white" @@ -5974,7 +5974,7 @@ async def admin_add_team_members_view(

Edit Team

- +
Error updating team: {html.escape(str(e))}
', status_code=400) # For regular form submission, redirect to admin page with error parameter error_msg = urllib.parse.quote(f"Error updating team: {str(e)}") - return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303) + return RedirectResponse(url=f"{root_path}/v1/admin/?error={error_msg}#teams", status_code=303) @admin_router.delete("/teams/{team_id}") @@ -7183,7 +7183,7 @@ def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, f'' ] @@ -7193,7 +7193,7 @@ def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, f'' ) @@ -7202,7 +7202,7 @@ def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, f'' ) else: @@ -7210,7 +7210,7 @@ def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, f'' ) @@ -7224,7 +7224,7 @@ def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, f'' ) @@ -7233,7 +7233,7 @@ def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, f'' @@ -7444,7 +7444,7 @@ async def admin_users_partial_html( }, ) elif render == "controls": - base_url = f"{_resolve_root_path(request)}/admin/users/partial" + base_url = f"{_resolve_root_path(request)}/v1/admin/users/partial" response = request.app.state.templates.TemplateResponse( request, "pagination_controls.html", @@ -7546,7 +7546,7 @@ async def admin_team_members_partial_html( root_path = _resolve_root_path(request) search_param = f"&search={urllib.parse.quote(search_term)}" if search_term else "" - next_page_url = f"{root_path}/admin/teams/{team_id}/members/partial?page={pagination.page + 1}&per_page={pagination.per_page}{search_param}" + next_page_url = f"{root_path}/v1/admin/teams/{team_id}/members/partial?page={pagination.page + 1}&per_page={pagination.per_page}{search_param}" response = request.app.state.templates.TemplateResponse( request, "team_users_selector.html", @@ -7654,7 +7654,7 @@ async def admin_team_non_members_partial_html( root_path = _resolve_root_path(request) search_param = f"&search={urllib.parse.quote(search_term)}" if search_term else "" - next_page_url = f"{root_path}/admin/teams/{team_id}/non-members/partial?page={pagination.page + 1}&per_page={pagination.per_page}{search_param}" + next_page_url = f"{root_path}/v1/admin/teams/{team_id}/non-members/partial?page={pagination.page + 1}&per_page={pagination.per_page}{search_param}" response = request.app.state.templates.TemplateResponse( request, "team_users_selector.html", @@ -7889,7 +7889,7 @@ async def admin_get_user_edit(

Edit User

- +
toggle was flipped on. 2. If ``toggle_plugins_global`` swallowed an admin-cache sync failure, the stale cache would persist forever — now the next GET repairs it. - 3. A remote disable (``PUT /admin/plugins {"enabled": false}`` on another + 3. A remote disable (``PUT /v1/admin/plugins {"enabled": false}`` on another worker) would leave this worker's ``app.state.plugin_manager`` populated from a prior enable, making admin views serve plugin metadata the cluster had already turned off. @@ -16806,8 +16806,8 @@ async def toggle_plugins_global( redis_persisted = await enable_plugins_shared(payload.enabled) - # Sync the admin-side cache so ``GET /admin/plugins`` and - # ``GET /admin/plugins/{name}`` reflect the toggle on a process that + # Sync the admin-side cache so ``GET /v1/admin/plugins`` and + # ``GET /v1/admin/plugins/{name}`` reflect the toggle on a process that # started with plugins disabled (``app.state.plugin_manager`` stayed unset # and ``PluginService`` was never wired). Without this, enabling plugins # at runtime leaves the admin surfaces reading stale/empty metadata until @@ -17193,7 +17193,7 @@ async def register_catalog_server( -
+ - +
- +
-
+
diff --git a/mcpgateway/templates/gateways_partial.html b/mcpgateway/templates/gateways_partial.html index 92c269258c..215c5c1029 100644 --- a/mcpgateway/templates/gateways_partial.html +++ b/mcpgateway/templates/gateways_partial.html @@ -117,7 +117,7 @@
- +
- + -
+
{% if resource.enabled %} - +
{% else %} -
+
{% endif %} -
+ {% if can_modify %}
- +
-
+
{% if tool.enabled %} - +
{% else %} -
+
{% endif %} -
+
{% else %} -
+
@@ -295,14 +295,14 @@
{% if tool.enabled %} - + {% else %} -
+
diff --git a/mcpgateway/templates/users_partial.html b/mcpgateway/templates/users_partial.html index c0ab1c5ec3..1184f1b90d 100644 --- a/mcpgateway/templates/users_partial.html +++ b/mcpgateway/templates/users_partial.html @@ -1,7 +1,7 @@
{{ user.full_nam